Compare commits

...
12 Commits
Author SHA1 Message Date
nicolas 8fe67053e1 Merge pull request 'Dev/3.4.1' (#144) from dev/3.4.1 into master
Reviewed-on: #144
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:59:13 +01:00
nicolas 927df6c2b2 Version 3.4.1 2026-11-10 16:55:01 +01:00
nicolas 197f5186d0 UiSnapshot : captures en WebP (--format webp, --quality)
- --format png|webp (png par défaut), --quality 1-100 (85 par défaut,
  100 = sans perte) ; transparence conservée, pages .p2.webp comprises.
- Encodage WebP par SkiaSharp 3.119.1 (Microsoft, MIT) : WIC lit le WebP sans
  savoir l'écrire. Outil de développement uniquement.
- index.html et report.md reconnaissent PNG et WebP ; une série complète
  efface aussi les anciens .webp.
- LISEZMOI : captures pour le site vitrine ; README §5 et §9 mis à jour.
2026-11-10 16:50:13 +01:00
nicolas 98cf17c294 Merge pull request 'Web/1.0.0' (#143) from web/1.0.0 into master
Reviewed-on: #143
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:29:33 +01:00
nicolas 4da86bcc6a README : site vitrine (§9)
Ligne GameLauncher.Website dans la structure de la solution, nouvelle section
§9 : pages, thèmes, isolation de la Launcher API, téléchargement vérifié,
sécurité HTTP, service durci, liseuse Markdown, tests, captures à mettre à jour.
2026-11-10 16:17:16 +01:00
nicolas 52761f53b6 Site vitrine : publication et déploiement sur le VPS
- publish-site.bat : exe autonome linux-x64, archive et empreinte SHA-256.
- installer-site.sh :
  - archive vérifiée par son empreinte ;
  - binaires dans /opt/lanisys-site (propriétaire root) ;
  - appsettings.Production.json conservé d'une version à l'autre ;
  - sorties réseau autorisées recalculées (serveur du launcher + DNS) dans
    sortie.conf ;
  - retour à la version précédente si le site ne répond pas.
- lanisys-site.service :
  - compte temporaire (DynamicUser), système en lecture seule, aucun privilège ;
  - dossier personnel /var/lib/lanisys-site (700), nécessaire à .NET pour
    vérifier un certificat HTTPS sous Debian 11 ;
  - connexions sortantes limitées au serveur du launcher ;
  - mémoire limitée.
- nginx-site.conf :
  - redirection http → https et domaine nu → www ;
  - HTTP/2 compatible nginx 1.18 (listen … http2) ;
  - TLS 1.2 et 1.3, renouvellement Let's Encrypt par webroot ;
  - lectures seulement, fichiers cachés jamais servis, gzip, limite de
    requêtes (nginx-site-zones.conf).
- LISEZMOI.md : pourquoi un VPS séparé, domaine, publication, installation,
  nginx et HTTPS, mentions légales, journaux, mise à jour, écriture des
  actualités, ajout d'un jeu, test en local, dépannage, fichiers sur le VPS.
  Debian 11, 12 ou 13.
2026-11-10 16:16:58 +01:00
nicolas cf75a22f19 Tests : site vitrine
Les sources du site (Services, Options) et la clé publique sont compilées
dans le projet de tests, sans référence au projet du site : une référence
copierait l'appsettings.json du site à la place de celui de la Launcher API
dans le dossier des tests.

Tests :
- launcher-update.json : signature valide, contenu modifié, autre clé,
  exécutable hors du serveur (autre hôte, http, autre port, paramètres),
  produit inattendu, clé intégrée ;
- manifest : bornes et nettoyage des notes, autre jeu, fichier illisible ;
- réseau : adresses https uniquement, lecture bornée, message d'erreur
  détaillé (certificat refusé) ;
- Markdown : HTML brut, liens javascript:, data: et //, injection
  d'attribut, images externes, syntaxe prise en charge ;
- actualités : en-tête, noms de fichiers invalides, brouillons.
2026-11-10 16:16:26 +01:00
nicolas 009c84de9f Site vitrine : premières actualités
- Ouverture du site.
- Exovival Zombie Reborn 1.8 : zombies rôdeurs, ouïe des zombies, armes,
  soins.
- Launcher 3.4 : versions prioritaires.
2026-11-10 16:15:28 +01:00
nicolas 9f9b9b9d4d Site vitrine : images, polices et icônes
- Polices auto-hébergées Sora, Barlow et Barlow Condensed (WOFF2), avec leurs
  licences OFL : aucune requête vers un service externe.
- Captures d'Exovival : couloir de l'école, encre UV, images des cassettes VHS
  sans les dates qui révèlent un code, et vues de l'abri en développement. Le
  tout en WebP.
- Captures du launcher (docs/preview*.png et UiSnapshot) en WebP.
- Favicon et icônes reprises du launcher, images de partage (Open Graph) du
  launcher et d'Exovival.
2026-11-10 16:14:55 +01:00
nicolas 12ab8377c9 Site vitrine : pages, thèmes par univers et script
Pages :
- accueil ;
- /launcher : présentation des fonctions joueur, téléchargement vérifié
  (version, taille, empreinte SHA-256), première installation, notes de la
  dernière version, lien vers les publications Gitea, questions fréquentes ;
- /jeux et /jeux/exovival-zombie-reborn : histoire, contenu, cassettes VHS,
  extension « L'abri » en développement, configuration ;
- /jeux/<jeu>/notes-de-version : toutes les versions ;
- page générique des jeux en préparation (projet multijoueur) ;
- actualités, mentions légales, confidentialité, erreurs 404 et 500.

Thèmes par page (data-theme) :
- launcher : violet et cyan, police Sora ;
- exovival : noir et vert, Barlow Condensed ; en-tête « lampe UV » qui révèle
  le message de Léa sous le pointeur ;
- multi : ambre.

Notes de version affichées comme dans le launcher (titre, sous-titre,
introduction, sections, étiquettes), textes toujours encodés.

Bouton « Nous contacter » : simple lien mailto. Aucun formulaire.

Responsive jusqu'au mobile, focus clavier visible, mouvement réduit respecté.
Script sans dépendance (menu mobile, copie de l'empreinte, lampe UV),
compatible avec la CSP (aucun script en ligne).
2026-11-10 16:14:00 +01:00
nicolas 5aa22df409 Site vitrine : nouveau projet GameLauncher.Website (ASP.NET Core Razor Pages)
Projet ajouté à la solution (dossier « website »), sans paquet NuGet tiers.
Références : GameLauncher.Contracts et GameLauncher.Core.

Isolé de la Launcher API : le site ne l'appelle jamais. En arrière-plan, côté
serveur, il lit seulement les fichiers publics launcher-update.json et
manifest.json :
- toutes les 10 minutes, avec requêtes conditionnelles (ETag, Last-Modified) ;
- https uniquement, aucune redirection suivie, taille et délai bornés.
Les pages ne déclenchent aucune requête sortante.

launcher-update.json :
- signature vérifiée avec la clé update-signing.pub, intégrée à l'exe du site,
  par le même code que le launcher (UpdateSignature) ;
- l'exécutable doit être sur le même serveur, en https ;
- produit attendu facultatif (Site:Launcher:Product) ;
- fichier refusé : téléchargement retiré ; serveur injoignable : dernière
  version valide conservée.

Notes de version : bornées et nettoyées comme dans le launcher
(ChangelogLimits.Normalize). Sections réservées à l'équipe masquées pour le
launcher (Site:Launcher:HiddenSections).

Certificat HTTPS du serveur du launcher : même règle que par défaut ; en cas de
refus, le journal détaille la chaîne de certificats (TlsDiagnostics) et la
cause complète de l'erreur réseau.

Actualités en Markdown (Content/News) : liseuse maison SafeMarkdown.
- tout le texte est encodé ;
- liens http(s), mailto et chemins du site seulement ;
- images du site (/img/) seulement.

Sécurité HTTP :
- CSP stricte (aucun script en ligne, aucune ressource externe), HSTS,
  nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP ;
- GET/HEAD seulement (405 sinon) ;
- limite de requêtes par IP ;
- aucun cookie, clés de protection des données en mémoire ;
- en-tête Server retiré.

Autres :
- réglages contrôlés au démarrage (SiteOptionsValidator) ;
- dates en français sans dépendance à ICU (InvariantGlobalization) ;
- robots.txt, sitemap.xml, /healthz réservé à la machine locale.
2026-11-10 16:12:47 +01:00
nicolas 3df009912b Git : fins de ligne LF pour website/deploy/*.conf
Les configurations nginx du site vitrine sont copiées telles quelles sur le VPS :
même règle que server/deploy/*.conf.
2026-11-10 16:10:33 +01:00
98 changed files with 5471 additions and 66 deletions

No files matched your search

+1
View File
@@ -68,3 +68,4 @@
*.sh text eol=lf
*.service text eol=lf
server/deploy/*.conf text eol=lf
website/deploy/*.conf text eol=lf
+24
View File
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
tools\publish-site.bat = tools\publish-site.bat
server-example\web.config = server-example\web.config
EndProjectSection
EndProject
@@ -70,6 +71,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
ProjectSection(SolutionItems) = preProject
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
website\deploy\installer-site.sh = website\deploy\installer-site.sh
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -182,6 +196,14 @@ Global
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -201,6 +223,8 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+43 -1
View File
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -367,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -707,3 +708,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
## 9. Site vitrine
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
| Page | Contenu |
|---|---|
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
pas affichées (`Site:Launcher:HiddenSections`).
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
toujours encodés (jamais interprétés comme du HTML).
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
aucune donnée écrite sur le disque.
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
liens `javascript:`/`data:` retirés, images du site uniquement.
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.4.0</Version>
<Version>3.4.1</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -7,6 +7,7 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -35,6 +36,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<!--
Site vitrine : ses services sont compilés ici plutôt que référencés. Une référence au projet du site
copierait son appsettings.json dans le dossier des tests, à la place de celui de la Launcher API
(même nom de fichier), et fausserait les tests du serveur.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
<Compile Include="..\..\website\GameLauncher.Website\Services\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<Compile Include="..\..\website\GameLauncher.Website\Options\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Website\Site\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -0,0 +1,320 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Website.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace GameLauncher.Tests.Website;
/// <summary>
/// Site vitrine : lecture des fichiers du serveur du launcher (signature, adresses, bornes) et liseuse
/// Markdown des actualités (aucun HTML ni lien dangereux ne doit passer).
/// </summary>
public sealed class WebsiteRemoteContentTests : IDisposable
{
private static readonly Uri UpdateUrl = new("https://launcher.example/launcher/launcher-update.json");
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _trusted;
public WebsiteRemoteContentTests()
{
_trusted = UpdateSignature.ParsePublicKeys(_key.ExportSubjectPublicKeyInfoPem());
}
public void Dispose() => _key.Dispose();
private static LauncherRelease Release(string file = "GameLauncher-3.4.0.exe", string product = "EZRLauncher") => new()
{
Product = product,
Version = "3.4.0",
File = file,
Size = 1234,
Sha256 = new string('A', 64),
PublishedAt = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero),
PriorityVersion = "3.4.0",
Changelog = new()
{
new ChangelogEntry
{
Version = "3.4.0",
Title = "Launcher 3.4.0",
Sections = new()
{
new ChangelogSection { Title = "EN BREF", Items = { new ChangelogItem { Text = "Nouveauté" } } },
new ChangelogSection { Title = "DÉVELOPPEMENT", Items = { new ChangelogItem { Text = "Interne" } } },
new ChangelogSection { Title = "Serveur", Items = { new ChangelogItem { Text = "Rien" } } }
}
},
new ChangelogEntry { Version = "3.3.1", Title = "Ancienne" }
}
};
private byte[] Envelope(LauncherRelease release, ECDsa? key = null) =>
Encoding.UTF8.GetBytes(UpdateSignature.CreateEnvelope(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options), key ?? _key));
private static readonly string[] Hidden = { "Serveur", "Developpement" };
[Fact]
public void Launcher_signature_valide_donne_la_version_l_adresse_et_les_notes_filtrees()
{
var snapshot = RemoteContentReader.ReadLauncher(Envelope(Release()), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow);
Assert.Equal("3.4.0", snapshot.Version);
Assert.Equal("https://launcher.example/launcher/GameLauncher-3.4.0.exe", snapshot.DownloadUrl.ToString());
Assert.Equal("GameLauncher-3.4.0.exe", snapshot.FileName);
Assert.Equal(new string('a', 64), snapshot.Sha256);
Assert.True(snapshot.IsPriority);
Assert.NotNull(snapshot.LatestNotes);
Assert.Equal("Launcher 3.4.0", snapshot.LatestNotes!.Title);
// Sections réservées à l'équipe retirées, sans tenir compte des accents ni de la casse.
Assert.Equal(new[] { "EN BREF" }, snapshot.LatestNotes.Sections.Select(s => s.Title).ToArray());
}
[Fact]
public void Launcher_contenu_modifie_est_refuse()
{
var json = Encoding.UTF8.GetString(Envelope(Release()));
var envelope = JsonSerializer.Deserialize<SignedEnvelope>(json, JsonDefaults.Options)!;
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)).Replace("GameLauncher-3.4.0.exe", "Piege-3.4.0.exe");
envelope.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var tampered = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(tampered, UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_signe_par_une_autre_cle_est_refuse()
{
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(), other), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://ailleurs.example/GameLauncher.exe")]
[InlineData("http://launcher.example/launcher/GameLauncher.exe")]
[InlineData("https://launcher.example:8443/launcher/GameLauncher.exe")]
[InlineData("GameLauncher.exe?x=1")]
[InlineData("notes.txt")]
public void Launcher_executable_hors_du_serveur_ou_inattendu_est_refuse(string file)
{
// Même signé, un fichier ne peut pas envoyer les visiteurs vers un autre serveur.
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(file)), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_produit_inattendu_est_refuse()
{
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(product: "AutreLauncher")), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_cle_publique_integree_au_site()
{
var keys = RemoteContentReader.LoadEmbeddedKeys();
Assert.NotEmpty(keys);
Assert.All(keys, k => Assert.Equal(16, k.KeyId.Length));
}
private static byte[] Manifest(string gameId, int entries, string extraChangelogText = "Texte")
{
var manifest = new
{
formatVersion = 1,
gameId,
version = "1.8",
releaseDate = new DateTimeOffset(2026, 11, 1, 18, 0, 0, TimeSpan.FromHours(1)),
archive = new { url = "jeu.zip", size = 1000L, sha256 = new string('b', 64) },
files = new[] { new { path = "a.bin", size = 600L }, new { path = "b.bin", size = 400L } },
changelog = Enumerable.Range(0, entries).Select(i => new
{
version = $"1.{entries - i}",
title = "Version‮ " + i,
sections = new[] { new { title = "NOUVEAU", items = new[] { new { text = extraChangelogText } } } }
})
};
return JsonSerializer.SerializeToUtf8Bytes(manifest);
}
[Fact]
public void Jeu_manifest_lu_borne_et_nettoye_comme_dans_le_launcher()
{
var snapshot = RemoteContentReader.ReadGame(Manifest("EZR", 50, new string('x', 5000)), "EZR", DateTimeOffset.UtcNow);
Assert.Equal("1.8", snapshot.Version);
Assert.Equal(1000, snapshot.DownloadSize);
Assert.Equal(1000, snapshot.InstalledSize);
Assert.Equal(ChangelogLimits.MaxEntries, snapshot.Changelog.Count);
Assert.DoesNotContain('‮', snapshot.Changelog[0].Title!);
Assert.True(snapshot.Changelog[0].Sections[0].Items[0].Text.Length <= ChangelogLimits.MaxTextLength);
}
[Fact]
public void Jeu_manifest_d_un_autre_jeu_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Manifest("AUTRE", 1), "EZR", DateTimeOffset.UtcNow));
}
[Fact]
public void Jeu_manifest_illisible_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{ pas du json"), "EZR", DateTimeOffset.UtcNow));
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{\"gameId\":\"EZR\",\"version\":\"abc\"}"), "EZR", DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://launcher.example/manifest.json", false, true)]
[InlineData("http://launcher.example/manifest.json", true, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", false, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", true, true)]
[InlineData("https://user:secret@launcher.example/manifest.json", false, false)]
[InlineData("file:///etc/passwd", true, false)]
[InlineData("pas une adresse", false, false)]
public void Adresses_lues_https_seulement(string url, bool allowLoopbackHttp, bool expected)
{
Assert.Equal(expected, RemoteFetcher.IsAllowedUrl(url, allowLoopbackHttp, out _));
}
[Fact]
public void Erreur_reseau_detaillee_certificat_refuse()
{
var ex = new HttpRequestException("The SSL connection could not be established, see inner exception.",
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid: PartialChain"));
var text = RemoteFetcher.Describe(ex);
Assert.StartsWith("certificat HTTPS refusé", text);
Assert.Contains("PartialChain", text);
Assert.StartsWith("serveur injoignable", RemoteFetcher.Describe(new HttpRequestException("Name or service not known")));
}
[Fact]
public async Task Lecture_bornee_au_dela_de_la_limite()
{
using var small = new MemoryStream(new byte[100]);
Assert.NotNull(await RemoteFetcher.ReadBoundedAsync(small, 100, CancellationToken.None));
using var big = new MemoryStream(new byte[101]);
Assert.Null(await RemoteFetcher.ReadBoundedAsync(big, 100, CancellationToken.None));
}
}
public sealed class WebsiteMarkdownTests
{
[Fact]
public void Html_brut_affiche_jamais_interprete()
{
var html = SafeMarkdown.ToHtml("Bonjour <script>alert(1)</script> <img src=x onerror=alert(1)>");
Assert.DoesNotContain("<script", html);
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;script&gt;", html);
}
[Theory]
[InlineData("[clic](javascript:alert(1))")]
[InlineData("[clic](JaVaScRiPt:alert(1))")]
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
[InlineData("[clic](//evil.example/x)")]
[InlineData("[clic](vbscript:msgbox)")]
public void Liens_dangereux_retires(string markdown)
{
var html = SafeMarkdown.ToHtml(markdown);
Assert.DoesNotContain("href", html);
Assert.Contains("clic", html);
}
[Fact]
public void Lien_externe_sans_acces_a_la_page_d_origine()
{
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
}
[Fact]
public void Attribut_impossible_a_injecter_dans_un_lien()
{
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
Assert.DoesNotContain("\"onmouseover", html);
}
[Theory]
[InlineData("![photo](https://evil.example/x.png)")]
[InlineData("![photo](/img/../appsettings.json)")]
[InlineData("![photo](/uploads/x.png)")]
public void Images_hors_du_site_refusees(string markdown)
{
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
}
[Fact]
public void Syntaxe_prise_en_charge()
{
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n![Le gymnase](/img/exovival/vhs-gymnase.webp)");
Assert.Contains("<h2>Titre</h2>", html);
Assert.Contains("<strong>gras</strong>", html);
Assert.Contains("<em>italique</em>", html);
Assert.Contains("<code>du code</code>", html);
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
Assert.Contains("<hr>", html);
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
}
}
public sealed class WebsiteNewsTests
{
[Fact]
public void Article_lu_avec_son_en_tete()
{
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
Assert.NotNull(article);
Assert.Equal("Titre : avec deux-points", article!.Title);
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
Assert.Equal("exovival", article.Theme);
Assert.Equal("/img/exovival/couloir.webp", article.Image);
Assert.Contains("<strong>gras</strong>", article.Html);
}
[Theory]
[InlineData("Mon_Article")]
[InlineData("../secret")]
[InlineData("")]
public void Nom_de_fichier_incorrect_ignore(string slug)
{
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
}
[Fact]
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
{
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
Assert.Equal("launcher", article!.Theme);
Assert.Null(article.Image);
}
[Fact]
public void Brouillons_masques_hors_developpement()
{
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
try
{
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
}
finally
{
Directory.Delete(folder, true);
}
}
}
+51 -7
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Globalization;
using System.IO;
using System.Text;
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using SkiaSharp;
namespace UiSnapshot;
@@ -54,7 +56,7 @@ internal interface ISceneSet
}
/// <summary>
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
/// </summary>
internal sealed class Capture
@@ -222,7 +224,7 @@ internal sealed class Capture
root.UpdateLayout();
var image = Render(root);
Save(image, record.Id + ".png", record);
Save(image, record.Id + _options.ImageExtension, record);
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
Diagnose(root, record);
@@ -267,16 +269,58 @@ internal sealed class Capture
private void Save(BitmapSource image, string fileName, SceneRecord record)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
encoder.Save(stream);
{
if (_options.Format == SnapshotOptions.FormatWebp)
{
SaveWebp(image, stream, _options.Quality);
}
else
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
}
}
record.Files.Add(fileName);
}
/// <summary>
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
/// </summary>
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
{
if (image.Format != PixelFormats.Pbgra32)
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
var width = image.PixelWidth;
var height = image.PixelHeight;
var stride = width * 4;
var pixels = new byte[stride * height];
image.CopyPixels(pixels, stride, 0);
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
try
{
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
data.SaveTo(stream);
}
finally
{
handle.Free();
}
}
/// <summary>
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
/// (&lt;scène&gt;.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// (&lt;scène&gt;.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
/// </summary>
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
@@ -295,7 +339,7 @@ internal sealed class Capture
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
scroller.ScrollToVerticalOffset(target);
await SettleAsync(160);
Save(Render(root), $"{record.Id}.p{page}.png", record);
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
if (target >= scroller.ScrollableHeight) break;
}
scroller.ScrollToVerticalOffset(original);
+25 -4
View File
@@ -1,6 +1,6 @@
# UiSnapshot — captures de toutes les fenêtres du launcher
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
Outil de développement : il rend **chaque écran du launcher en PNG** (ou en WebP, `--format webp`), avec des données de démonstration,
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
@@ -8,6 +8,7 @@ partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launch
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
tools\ui-snapshot.bat --filter "main-*" --format webp captures pour le site vitrine (WebP)
tools\ui-snapshot.bat --list liste des scènes
tools\ui-snapshot.bat --help toutes les options
```
@@ -19,14 +20,34 @@ arguments éventuels dans Propriétés › Déboguer.
| Fichier | Contenu |
|---|---|
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
| `<scène>.png` (ou `.webp`) | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… (ou `.p2.webp`) | Pages suivantes quand une zone défile (jusqu'à 4) |
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
| `launcher.log` | Journal du launcher pendant les captures |
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels des images).
## Captures pour le site vitrine (WebP)
Le site (`website\GameLauncher.Website\wwwroot\img\launcher\`) utilise des images **WebP** : plus légères que le PNG
à qualité égale. `--format webp` les produit directement :
```
tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp --quality 85
```
* `--quality` : 1 à 100 (défaut 85, très bon compromis pour des captures d'interface) ; **100 = sans perte**.
Ignorée en PNG, toujours sans perte.
* `--backdrop transparent` : la transparence (coins arrondis, ombre) est conservée en WebP.
* `--scale 1` (défaut) donne la taille de la fenêtre (environ 1200 px de large), adaptée au site ; `--scale 2`
double la taille et le poids.
* Copier ensuite les fichiers voulus de `artifacts\ui-snapshots` dans `wwwroot\img\launcher\` sous les noms
utilisés par les pages (`apercu-exovival.webp`, `apercu-menu.webp`, `apercu-telechargement.webp`,
`apercu-multijeux.webp`, `mise-a-jour-verifiee.webp`), puis republier le site.
* Encodage par **SkiaSharp** (paquet NuGet maintenu par Microsoft, licence MIT, restauré automatiquement par
Visual Studio) : Windows sait lire le WebP mais pas l'écrire. Seul cet outil l'utilise.
## Scènes
+8 -4
View File
@@ -7,6 +7,10 @@ namespace UiSnapshot;
/// <summary>report.md (problèmes détectés, à lire en premier) et index.html (planche de toutes les captures).</summary>
internal static class Report
{
/// <summary>Image de capture (PNG ou WebP, selon --format), par opposition aux autres fichiers produits.</summary>
private static bool IsImage(string file) =>
file.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
public static void Write(Capture capture, SnapshotOptions options, TimeSpan duration)
{
File.WriteAllText(Path.Combine(capture.OutputDirectory, "report.md"), Markdown(capture, options, duration), new UTF8Encoding(false));
@@ -21,7 +25,7 @@ internal static class Report
sb.AppendLine($"Échelle {options.Scale:0.##} · thème {(options.Theme ?? "du jeu")} · attente {options.WaitMilliseconds} ms · " +
$"{(options.Filters.Count == 0 ? "toutes les scènes" : "filtre " + string.Join(", ", options.Filters))} · " +
$"{duration.TotalSeconds:0} s");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels du PNG). Données de démonstration, aucun serveur réel.");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels des images). Données de démonstration, aucun serveur réel.");
sb.AppendLine();
sb.AppendLine("| Scène | Image | Taille | Problèmes | Remarques |");
@@ -29,7 +33,7 @@ internal static class Report
foreach (var r in capture.Records)
{
var status = r.Error is not null ? "**ÉCHEC**" : r.Skipped is not null ? "ignorée" : (r.Issues.Count + r.Bindings.Count).ToString();
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)))} | {r.Size} | {status} | {r.Notes.Count} |");
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(IsImage))} | {r.Size} | {status} | {r.Notes.Count} |");
}
sb.AppendLine();
@@ -72,12 +76,12 @@ internal static class Report
sb.AppendLine(".pages a{color:var(--muted);margin-right:8px;font-size:12px}");
sb.AppendLine("ul{margin:6px 0 0;padding-left:18px;font-size:12px}li.warn{color:var(--warn)}li.bad{color:var(--bad)}li.note{color:var(--muted)}");
sb.AppendLine("</style></head><body>");
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · " +
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · {options.Format.ToUpperInvariant()} · " +
$"{capture.Records.Count} scène(s) · données de démonstration · détails dans report.md</p>");
sb.AppendLine("<div class=\"grid\">");
foreach (var r in capture.Records)
{
var pngs = r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)).ToList();
var pngs = r.Files.Where(IsImage).ToList();
sb.AppendLine("<div class=\"card\">");
if (pngs.Count > 0) sb.AppendLine($"<a href=\"{E(pngs[0])}\"><img loading=\"lazy\" src=\"{E(pngs[0])}\" alt=\"{E(r.Title)}\"></a>");
sb.AppendLine($"<div class=\"meta\"><div class=\"id\">{E(r.Id)}</div><div class=\"title\">{E(r.Title)} {E(r.Size)}</div>");
+29 -1
View File
@@ -29,6 +29,18 @@ internal sealed class SnapshotOptions
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
public bool Scroll { get; private set; } = true;
/// <summary>Format des images : « png » (défaut) ou « webp » (format des images du site vitrine).</summary>
public string Format { get; private set; } = FormatPng;
/// <summary>Qualité WebP, 1 à 100 (100 : sans perte). Ignorée en PNG (toujours sans perte).</summary>
public int Quality { get; private set; } = 85;
public const string FormatPng = "png";
public const string FormatWebp = "webp";
/// <summary>Extension des images produites, avec le point (« .png », « .webp »).</summary>
public string ImageExtension => "." + Format;
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
@@ -52,11 +64,14 @@ internal sealed class SnapshotOptions
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
--no-scroll pas de pages supplémentaires pour les zones qui défilent
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
--format <png|webp> format des images : « png » (défaut, sans perte) ou « webp » (format des
images du site vitrine, website\GameLauncher.Website\wwwroot\img)
--quality <1-100> qualité WebP (défaut 85 ; 100 = sans perte) ; ignorée en PNG
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
--open ouvre index.html à la fin
--help cette aide
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
Résultat : une image par scène (PNG ou WebP), index.html (planche de toutes les captures) et report.md
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
""";
@@ -117,6 +132,19 @@ internal sealed class SnapshotOptions
}
}
break;
case "--format":
var format = Next().Trim().TrimStart('.').ToLowerInvariant();
if (format is not (FormatPng or FormatWebp))
throw new ArgumentException($"Format inconnu : « {format} » (png ou webp).");
options.Format = format;
break;
case "--quality":
var qualityText = Next();
if (!int.TryParse(qualityText, NumberStyles.None, CultureInfo.InvariantCulture, out var quality) ||
quality < 1 || quality > 100)
throw new ArgumentException($"Qualité invalide : « {qualityText} » (1 à 100).");
options.Quality = quality;
break;
case "--out":
case "-o":
options.OutputDirectory = Next();
+1 -1
View File
@@ -113,7 +113,7 @@ internal sealed class SnapshotRunner
var marker = Path.Combine(output, MarkerFile);
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
{
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
foreach (var pattern in new[] { "*.png", "*.webp", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
{
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
{
+10
View File
@@ -20,6 +20,16 @@
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<!--
Option « format webp » de l'outil : Windows (WIC) lit le WebP mais ne sait pas l'écrire. SkiaSharp (maintenu par Microsoft,
licence MIT) encode les captures en WebP. Outil de développement uniquement : le launcher, la Launcher API
et le site ne dépendent pas de ce paquet.
-->
<PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="3.119.1" />
</ItemGroup>
<ItemGroup>
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
+12 -46
View File
@@ -1,9 +1,9 @@
# Launcher 3.4.0
# Launcher 3.4.1
LAUNCHER - NOTES DE MISE À JOUR
Mises à jour prioritaires du launcher - Mise à jour du 10 novembre 2026
Version de maintenance du launcher - Mise à jour du 10 novembre 2026
=========================================================================
Une version du launcher peut désormais être publiée comme prioritaire, pour un correctif critique (faille de sécurité…) : elle s'installe au démarrage même si vous avez désactivé la mise à jour automatique du launcher. Votre réglage n'est pas modifié : les versions normales suivantes ne s'installeront toujours pas toutes seules.
Version de maintenance : le launcher fonctionne exactement comme en 3.4.0. Elle accompagne des améliorations de nos outils de développement.
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
@@ -11,40 +11,11 @@ Aucune action sur le serveur. Le launcher se met à jour automatiquement.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : version prioritaire, installée au démarrage même avec
la mise à jour automatique du launcher désactivée.
- NOUVEAU : pastille « Prioritaire » dans la fenêtre de mise à
jour, avertissement dans le bandeau et les paramètres tant
qu'elle n'est pas installée.
- SÉCURITÉ : l'indication « prioritaire » est signée, comme le
reste de la mise à jour.
- QUALITÉ : nouveaux tests automatisés.
------------------------------------------------------------------
VERSION PRIORITAIRE
------------------------------------------------------------------
- Votre réglage « Mettre à jour le launcher automatiquement »
n'est jamais modifié : effet ponctuel, le temps d'installer le
correctif.
- « Plus tard » reste possible : nouvelle tentative au prochain
démarrage du launcher.
- Une erreur de téléchargement est retentée au démarrage suivant.
- Si la nouvelle version ne démarre pas, l'ancienne est remise en
place et le bouton « Vérifier les mises à jour » permet de
réessayer.
- Mise à jour automatique désactivée : le launcher vérifie
désormais au démarrage si une version prioritaire existe.
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
- L'indication « prioritaire » fait partie du contenu signé : un
serveur compromis ne peut ni imposer une mise à jour, ni
retirer la priorité d'un correctif.
- Seule une version signée et strictement supérieure peut être
installée, comme avant.
- INFO : aucun changement dans l'utilisation du launcher :
installation, mises à jour, vérification et réparation des
jeux restent identiques.
- SÉCURITÉ : comme pour chaque version, la signature numérique
et l'empreinte SHA-256 sont vérifiées avant l'installation.
------------------------------------------------------------------
@@ -56,15 +27,10 @@ SERVEUR
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- publish-launcher-update.bat --priority (version prioritaire,
confirmation demandée) et --clear-priority (ne reprend pas la
priorité déjà publiée).
- LauncherPublisher : options --priority, --clear-priority, --yes ;
ligne « Priorité » dans publish et verify.
- Core : LauncherRelease.PriorityVersion (reprise d'une
publication à l'autre), LauncherUpdateCheck.IsPriority.
- UiSnapshot : scène launcher-update-priority.
- Tests : LauncherPriorityTests.
- UiSnapshot : captures d'écran au format WebP (--format webp,
--quality 1 à 100, 100 = sans perte), en plus du PNG.
- UiSnapshot : paquet SkiaSharp (encodage WebP), utilisé par
l'outil seulement.
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
+50
View File
@@ -0,0 +1,50 @@
@echo off
REM ---------------------------------------------------------------------------
REM Publie le SITE VITRINE pour le VPS (Linux x64, autonome : aucun .NET a
REM installer sur le serveur) et prepare les fichiers a envoyer :
REM Publish\Site\lanisys-site.tar.gz site + service systemd
REM Publish\Site\installer-site.sh script d'installation / mise a jour
REM Guide complet : website\deploy\LISEZMOI.md
REM ---------------------------------------------------------------------------
setlocal
set ROOT=%~dp0..
set OUT=%ROOT%\Publish\Site
set STAGE=%OUT%\lanisys-site
if exist "%STAGE%" rmdir /s /q "%STAGE%"
if exist "%OUT%\lanisys-site.tar.gz" del /q "%OUT%\lanisys-site.tar.gz"
set HASH=
echo === 1/2 Compilation (linux-x64) ===
dotnet publish "%ROOT%\website\GameLauncher.Website\GameLauncher.Website.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
if errorlevel 1 goto :error
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
echo.
echo === 2/2 Archive ===
mkdir "%STAGE%\deploy"
copy /Y "%ROOT%\website\deploy\lanisys-site.service" "%STAGE%\deploy\" >nul
copy /Y "%ROOT%\website\deploy\installer-site.sh" "%OUT%\" >nul
tar -czf "%OUT%\lanisys-site.tar.gz" -C "%STAGE%" .
if errorlevel 1 goto :error
REM Empreinte SHA-256 : verifiee par installer-site.sh (archive alteree = refusee)
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\lanisys-site.tar.gz').Hash.ToLower()"') do set HASH=%%h
if "%HASH%"=="" goto :error
> "%OUT%\lanisys-site.tar.gz.sha256" echo %HASH%
echo.
echo Pret. Envoi sur le VPS du site (compte admin) :
echo scp "%OUT%\lanisys-site.tar.gz" "%OUT%\installer-site.sh" admin@^<VPS du site^>:/tmp/
echo Puis, sur le VPS :
echo sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz %HASH%
echo (empreinte aussi enregistree dans lanisys-site.tar.gz.sha256)
explorer "%OUT%"
pause
exit /b 0
:error
echo.
echo La publication a echoue.
pause
exit /b 1
+2 -1
View File
@@ -2,7 +2,7 @@
rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
@@ -12,6 +12,7 @@ rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
rem tools\ui-snapshot.bat --list
rem ============================================================================
setlocal
@@ -0,0 +1,21 @@
---
title: Bienvenue sur le site de LanisysGames
date: 2026-11-10
summary: Nos jeux, le launcher et les nouvelles du studio ont maintenant leur adresse.
topic: Studio
image: /img/launcher/planete.webp
imageAlt: Planète aux anneaux sur un ciel violet, l'illustration du launcher.
---
Jusqu'ici, il fallait passer par le launcher pour suivre nos jeux. Ce site rassemble désormais tout au même endroit :
- **le launcher**, à télécharger en un clic, avec les nouveautés de sa dernière version ;
- **Exovival Zombie Reborn**, son histoire, ce qui vous attend dans l'école Jean-Moulin, et toutes ses [notes de version](/jeux/exovival-zombie-reborn/notes-de-version) ;
- **les actualités** du studio, comme celle-ci.
## Un site discret
Pas de compte, pas de cookie, pas de mesure d'audience : le site ne vous suit pas. Les polices et les images sont servies par le site lui-même, sans passer par un service externe. Tout est expliqué sur la page [Confidentialité](/confidentialite).
## Une question, une idée ?
Le bouton « Nous contacter » ouvre votre messagerie. Pour un bug dans un jeu, le bouton **Aide** du launcher reste le plus pratique : il joint automatiquement la version du jeu, et vous pouvez ajouter des captures d'écran.
@@ -0,0 +1,30 @@
---
title: "Exovival Zombie Reborn 1.8 : les zombies vous entendent"
date: 2026-11-01
summary: Huit zombies rôdent dès le début de la partie, ils réagissent au bruit de vos pas, et de petits soins à fabriquer soi-même font leur apparition.
topic: Exovival Zombie Reborn
theme: exovival
image: /img/exovival/vhs-gymnase.webp
imageAlt: Le gymnase de l'école, image extraite d'une cassette VHS du jeu.
---
La version 1.8 remplit l'école de zombies, leur donne de l'oreille et rééquilibre les armes. Merci aux bêta-testeurs pour leurs retours !
## L'école n'est plus vide
Huit zombies rôdent dès le début de la partie : autour du car scolaire, dans la cour de service, sur le toit bas, au gymnase et à l'étage. Plus lents et moins forts que ceux qui vous traquent pendant l'histoire, ils se distancent en marchant. Mais attention aux groupes : quand l'un d'eux attaque, ceux d'à côté accourent.
## Faites moins de bruit
- Marcher fait du bruit, courir s'entend de beaucoup plus loin, même dans le dos d'un zombie.
- Un mur ou une porte fermée étouffe vos pas.
- **Accroupi** (touche Ctrl), vous êtes silencieux : de quoi approcher un zombie par derrière.
## Des armes plus lisibles
Chaque arme fait toujours les mêmes dégâts, et viser la tête les double. La hache reste la plus puissante : un seul coup à la tête suffit.
## Se soigner à la Resident Evil
Des herbes et des huiles médicinales sont cachées dans l'école. Mélangez-les dans l'inventaire pour obtenir une potion bien plus efficace. Une note de l'infirmière, quelque part dans les toilettes, vous explique tout.
Le détail complet est dans les [notes de version](/jeux/exovival-zombie-reborn/notes-de-version). La mise à jour s'installe automatiquement avec le launcher.
@@ -0,0 +1,21 @@
---
title: "Launcher 3.4 : les correctifs importants arrivent chez tout le monde"
date: 2026-11-10
summary: Une mise à jour critique du launcher s'installe désormais même si vous avez désactivé la mise à jour automatique. Votre réglage, lui, ne change pas.
topic: Launcher
image: /img/launcher/apercu-exovival.webp
imageAlt: Le launcher ouvert sur Exovival Zombie Reborn.
---
Vous pouvez choisir, dans les paramètres du launcher, de ne pas le mettre à jour automatiquement. C'est votre droit, et ça le reste. Mais si une faille de sécurité devait un jour être corrigée, il faut que le correctif arrive chez tout le monde.
## Ce qui change
- Une version du launcher peut être publiée comme **prioritaire**. Elle s'installe au démarrage, même avec la mise à jour automatique désactivée.
- **Votre réglage n'est pas modifié** : une fois le correctif installé, les versions normales suivantes ne s'installeront toujours pas toutes seules.
- La fenêtre de mise à jour affiche une pastille « Prioritaire ». « Plus tard » reste possible : le launcher réessaiera au prochain démarrage.
## Et la sécurité ?
L'indication « prioritaire » fait partie du contenu signé de la mise à jour : un serveur piraté ne pourrait ni imposer une mise à jour, ni retirer la priorité d'un correctif. Comme avant, seule une version signée et plus récente peut s'installer.
Le launcher se met à jour tout seul. Pour une première installation, il se [télécharge ici](/launcher#telecharger).
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Site vitrine (LanisysGames) : présentation des jeux et du launcher, téléchargement du launcher,
notes de version, actualités. ASP.NET Core Razor Pages, sans base de données, sans formulaire,
sans cookie, sans paquet NuGet tiers.
Isolé de la Launcher API : le site ne l'appelle jamais. Il lit seulement, côté serveur et en
arrière-plan, les fichiers publics launcher-update.json (signature vérifiée avec la même clé
publique que le launcher) et manifest.json de chaque jeu (notes de version).
Voir website\deploy\LISEZMOI.md.
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
tools\publish-site.bat
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Website</RootNamespace>
<AssemblyName>GameLauncher.Website</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
<Version>1.0.0</Version>
<Product>Site vitrine LanisysGames</Product>
<!-- Pas de dépendance à libicu sur le VPS (comme la Launcher API) : les dates sont mises en forme
en français par le site lui-même (Services\FrenchText.cs). -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
<!-- Pas de web.config (IIS) dans la publication : le site tourne derrière nginx. -->
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
</PropertyGroup>
<ItemGroup>
<!-- Notes de version : même modèle, mêmes bornes et même nettoyage que le launcher. -->
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
<!-- launcher-update.json : même vérification de signature que le launcher (UpdateSignature). -->
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Clé publique de mise à jour du launcher, INTÉGRÉE à l'exécutable (comme dans le launcher) :
elle ne peut pas être remplacée par un fichier déposé sur le serveur. -->
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Security\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<!-- Actualités (Markdown) : publiées avec le site, lues au démarrage. -->
<None Update="Content\**\*.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
namespace GameLauncher.Website.Hosting;
/// <summary>
/// En-têtes de sécurité de toutes les réponses, et refus de tout ce qui n'est pas une lecture.
///
/// Politique de sécurité du contenu (CSP) stricte : scripts, styles, images et polices du site
/// uniquement, aucun script en ligne, aucune ressource externe (ni police Google, ni statistiques),
/// aucun formulaire, aucune intégration du site dans un cadre. Le site n'a pas de cookie.
/// </summary>
public static class SecurityHeaders
{
public const string ContentSecurityPolicy =
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; " +
"manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
public static IApplicationBuilder UseSiteSecurityHeaders(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
var request = context.Request;
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
{
// Site en lecture seule : ni formulaire, ni API.
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
context.Response.Headers.Allow = "GET, HEAD";
return;
}
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.ContentSecurityPolicy = ContentSecurityPolicy;
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), " +
"payment=(), usb=(), interest-cohort=(), browsing-topics=()";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
headers["X-Permitted-Cross-Domain-Policies"] = "none";
headers.Remove("Server");
headers.Remove("X-Powered-By");
return Task.CompletedTask;
});
await next();
});
}
@@ -0,0 +1,34 @@
using System.Xml.Linq;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
namespace GameLauncher.Website.Hosting;
/// <summary>Plan du site (sitemap.xml) pour les moteurs de recherche.</summary>
public static class Sitemap
{
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
public static string Build(SiteOptions options, NewsService news)
{
var root = options.PublicUrl.TrimEnd('/');
var paths = new List<(string Path, string? LastModified)>
{
("/", null), ("/launcher", null), ("/jeux", null), ("/actualites", null),
("/mentions-legales", null), ("/confidentialite", null)
};
foreach (var game in options.VisibleGames)
{
paths.Add(($"/jeux/{game.Slug}", null));
if (!string.IsNullOrWhiteSpace(game.ManifestUrl)) paths.Add(($"/jeux/{game.Slug}/notes-de-version", null));
}
paths.AddRange(news.All.Select(a => ($"/actualites/{a.Slug}", (string?)FrenchText.IsoDate(a.Date))));
var document = new XDocument(new XDeclaration("1.0", "utf-8", null),
new XElement(Ns + "urlset",
paths.Select(p => new XElement(Ns + "url",
new XElement(Ns + "loc", root + p.Path),
p.LastModified is null ? null : new XElement(Ns + "lastmod", p.LastModified)))));
return document.Declaration + "\n" + document.Root;
}
}
@@ -0,0 +1,36 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Hosting;
/// <summary>
/// Contrôle du certificat du serveur du launcher : EXACTEMENT la règle par défaut de .NET (aucune erreur
/// acceptée), avec en plus, en cas de refus, le détail dans le journal du site (nom attendu, certificat
/// présenté, maillons de la chaîne et leurs erreurs). Sans ce détail, .NET ne dit que « The SSL connection
/// could not be established ».
/// </summary>
public static class TlsDiagnostics
{
public static RemoteCertificateValidationCallback Create(ILogger logger) => (sender, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None) return true;
var host = (sender as SslStream)?.TargetHostName ?? "?";
var links = chain?.ChainElements.Select((e, i) =>
$"[{i}] {e.Certificate.Subject} (émis par {e.Certificate.Issuer}, jusqu'au {e.Certificate.NotAfter:yyyy-MM-dd})" +
(e.ChainElementStatus.Length == 0 ? "" : " : " + string.Join(", ", e.ChainElementStatus.Select(s => s.Status))))
.ToList() ?? new List<string>();
var chainStatus = chain?.ChainStatus.Select(s => s.Status.ToString()).Distinct().ToList() ?? new List<string>();
logger.LogError(
"Certificat de {Host} REFUSÉ ({Errors}). Chaîne : {Status}. Maillons reçus et reconstruits :{Links}{Hint}",
host, errors, chainStatus.Count == 0 ? "—" : string.Join(", ", chainStatus),
links.Count == 0 ? " aucun" : "\n " + string.Join("\n ", links),
chainStatus.Contains(nameof(X509ChainStatusFlags.PartialChain))
? "\n → Chaîne incomplète : le serveur n'envoie pas le certificat intermédiaire. Sur le VPS du launcher, " +
"nginx doit utiliser fullchain.pem (et non cert.pem) dans ssl_certificate (voir website/deploy/LISEZMOI.md, Dépannage)."
: "");
return false;
};
}
@@ -0,0 +1,137 @@
namespace GameLauncher.Website.Options;
/// <summary>
/// Réglages du site (section « Site » d'appsettings.json, complétée sur le VPS par
/// appsettings.Production.json, conservé d'une mise à jour à l'autre).
/// </summary>
public sealed class SiteOptions
{
public const string Section = "Site";
/// <summary>Adresse publique du site (https), sans « / » final : liens absolus (Open Graph, plan du site).</summary>
public string PublicUrl { get; set; } = "";
/// <summary>Nom du studio, affiché dans l'en-tête et le pied de page.</summary>
public string StudioName { get; set; } = "LanisysGames";
/// <summary>
/// Adresse du bouton « Nous contacter » (lien mailto: : le joueur écrit avec sa propre messagerie,
/// le site ne reçoit ni ne stocke rien). Vide : bouton masqué.
/// </summary>
public string ContactEmail { get; set; } = "";
public LauncherOptions Launcher { get; set; } = new();
public List<GameOptions> Games { get; set; } = new();
public RemoteOptions Remote { get; set; } = new();
public LegalOptions Legal { get; set; } = new();
/// <summary>Jeux affichés (masqués exclus), dans l'ordre du fichier.</summary>
public IEnumerable<GameOptions> VisibleGames => Games.Where(g => g.Visible);
public GameOptions? FindGame(string? slug) =>
string.IsNullOrEmpty(slug) ? null : VisibleGames.FirstOrDefault(g => string.Equals(g.Slug, slug, StringComparison.Ordinal));
}
public sealed class LauncherOptions
{
/// <summary>Nom du launcher affiché sur le site.</summary>
public string Name { get; set; } = "Launcher LanisysGames";
/// <summary>
/// launcher-update.json (https) : version, exécutable à télécharger et notes de version, SIGNÉS.
/// Le site n'affiche le bouton de téléchargement que si la signature est valide.
/// </summary>
public string UpdateUrl { get; set; } = "";
/// <summary>
/// Identifiant de produit attendu dans launcher-update.json (« launcherId », ligne « Produit » de
/// LauncherPublisher verify). Vide : non contrôlé (le produit lu est inscrit au journal du site).
/// </summary>
public string Product { get; set; } = "";
/// <summary>Page « Publications » de Gitea : historique complet des versions du launcher. Vide : lien masqué.</summary>
public string ReleasesUrl { get; set; } = "";
/// <summary>
/// Sections des notes du launcher qui ne concernent pas les joueurs (serveur, développement…),
/// retirées de l'affichage. Comparaison sans tenir compte de la casse ni des accents.
/// </summary>
public List<string> HiddenSections { get; set; } = new();
}
public sealed class GameOptions
{
/// <summary>Adresse de la page : /jeux/&lt;slug&gt; (lettres minuscules, chiffres, tirets).</summary>
public string Slug { get; set; } = "";
public string Name { get; set; } = "";
/// <summary>Thème de la page : « exovival », « multi » ou « launcher ».</summary>
public string Theme { get; set; } = "launcher";
/// <summary>« available » (disponible dans le launcher) ou « upcoming » (en préparation).</summary>
public string Status { get; set; } = "upcoming";
public bool Visible { get; set; } = true;
/// <summary>Identifiant du jeu (« gameId » du manifest), contrôlé à la lecture du manifest.</summary>
public string GameId { get; set; } = "";
/// <summary>manifest.json du jeu (https). Vide : pas de notes de version.</summary>
public string ManifestUrl { get; set; } = "";
/// <summary>Accroche (cartes de l'accueil et de la liste des jeux).</summary>
public string Tagline { get; set; } = "";
/// <summary>Présentation courte d'un jeu en préparation (page générique).</summary>
public string Teaser { get; set; } = "";
/// <summary>Image de la carte (chemin local commençant par /img/).</summary>
public string CardImage { get; set; } = "";
public string CardImageAlt { get; set; } = "";
public bool IsAvailable => string.Equals(Status, "available", StringComparison.OrdinalIgnoreCase);
/// <summary>Page dédiée (Pages/Jeux/&lt;…&gt;.cshtml) plutôt que la page générique.</summary>
public bool HasDedicatedPage { get; set; }
}
public sealed class RemoteOptions
{
/// <summary>Intervalle entre deux lectures des fichiers JSON (minutes).</summary>
public int RefreshMinutes { get; set; } = 10;
/// <summary>Délai maximal d'une lecture (secondes).</summary>
public int TimeoutSeconds { get; set; } = 20;
/// <summary>Taille maximale d'un manifest.json (Mo) : il contient la liste de tous les fichiers du jeu.</summary>
public int MaxManifestMegabytes { get; set; } = 32;
/// <summary>
/// Développement uniquement : autorise http://localhost (fichiers de test servis en local).
/// Ignoré en production (https obligatoire).
/// </summary>
public bool AllowLoopbackHttp { get; set; }
}
public sealed class LegalOptions
{
/// <summary>Éditeur du site (nom ou raison sociale).</summary>
public string Publisher { get; set; } = "";
/// <summary>Statut et coordonnées de l'éditeur (adresse, SIRET… ou mention « éditeur non professionnel »).</summary>
public List<string> PublisherDetails { get; set; } = new();
/// <summary>Directeur de la publication.</summary>
public string Director { get; set; } = "";
/// <summary>Hébergeur du site (nom, adresse, téléphone).</summary>
public List<string> Host { get; set; } = new();
/// <summary>Durée de conservation des journaux du serveur web, en jours (page « Confidentialité »).</summary>
public int LogRetentionDays { get; set; } = 14;
}
@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Options;
/// <summary>Contrôle des réglages au démarrage : un réglage incorrect arrête le site avec un message clair.</summary>
public sealed partial class SiteOptionsValidator : IValidateOptions<SiteOptions>
{
public static readonly string[] Themes = { "launcher", "exovival", "multi" };
public ValidateOptionsResult Validate(string? name, SiteOptions options)
{
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PublicUrl) &&
(!Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var site) || site.Scheme is not ("https" or "http") ||
options.PublicUrl.EndsWith('/')))
errors.Add("Site:PublicUrl doit être une adresse absolue, sans « / » final (ex. https://www.lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.ContactEmail) && !EmailRegex().IsMatch(options.ContactEmail))
errors.Add("Site:ContactEmail n'est pas une adresse e-mail simple (ex. contact@lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.Launcher.ReleasesUrl) &&
(!Uri.TryCreate(options.Launcher.ReleasesUrl, UriKind.Absolute, out var releases) || releases.Scheme != "https"))
errors.Add("Site:Launcher:ReleasesUrl doit être une adresse https.");
var slugs = new HashSet<string>(StringComparer.Ordinal);
foreach (var game in options.Games)
{
game.Slug ??= "";
if (!SlugRegex().IsMatch(game.Slug))
errors.Add($"Site:Games : « {game.Slug} » n'est pas un slug valide (minuscules, chiffres, tirets).");
else if (!slugs.Add(game.Slug))
errors.Add($"Site:Games : slug « {game.Slug} » en double.");
if (string.IsNullOrWhiteSpace(game.Name))
errors.Add($"Site:Games : nom manquant pour « {game.Slug} ».");
if (!Themes.Contains(game.Theme))
errors.Add($"Site:Games : thème inconnu « {game.Theme} » pour « {game.Slug} » ({string.Join(", ", Themes)}).");
if (!string.IsNullOrWhiteSpace(game.CardImage) && !game.CardImage.StartsWith("/img/", StringComparison.Ordinal))
errors.Add($"Site:Games : l'image de « {game.Slug} » doit être un fichier du site (/img/…).");
}
if (options.Remote.RefreshMinutes < 1)
errors.Add("Site:Remote:RefreshMinutes doit valoir au moins 1.");
return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors);
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
[GeneratedRegex(@"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")]
private static partial Regex EmailRegex();
}
@@ -0,0 +1,35 @@
@page "/actualites/{slug:maxlength(80)}"
@model ArticleModel
@{
var article = Model.Article;
ViewData["Nav"] = "actualites";
ViewData["Theme"] = article.Theme;
ViewData["Title"] = article.Title;
ViewData["Description"] = article.Summary;
ViewData["OgType"] = "article";
if (article.Image is not null && article.Image.EndsWith(".jpg", StringComparison.Ordinal)) ViewData["OgImage"] = article.Image;
}
<article>
<header class="article-head">
<div class="wrap wrap-narrow">
<p class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</p>
<h1>@article.Title</h1>
<p class="lead">@article.Summary</p>
</div>
</header>
<div class="wrap wrap-narrow">
@if (article.Image is not null)
{
<figure class="article-cover"><img src="@article.Image" alt="@(article.ImageAlt ?? "")" width="1280" height="720"></figure>
}
<div class="prose">
@* HTML produit par SafeMarkdown : tout le texte de l'article y est encodé, seules ses balises existent. *@
@Html.Raw(article.Html)
</div>
<a class="article-back" href="/actualites">Toutes les actualités</a>
</div>
</article>
@@ -0,0 +1,23 @@
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages.Actualites;
public sealed class ArticleModel : PageModel
{
private readonly NewsService _news;
public ArticleModel(NewsService news) => _news = news;
public NewsArticle Article { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
// Recherche dans les articles chargés au démarrage : l'adresse n'atteint jamais le disque.
var article = _news.Find(slug);
if (article is null) return NotFound();
Article = article;
return Page();
}
}
@@ -0,0 +1,27 @@
@page "/actualites"
@inject NewsService News
@{
ViewData["Nav"] = "actualites";
ViewData["Title"] = "Actualités";
ViewData["Description"] = "Les nouvelles des jeux et du launcher : sorties, mises à jour, coulisses.";
}
<section class="page-hero">
<div class="wrap">
<h1>Actualités</h1>
<p class="lead">Sorties, mises à jour et coulisses de nos jeux et du launcher.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@if (News.All.Count == 0)
{
<p class="notice">Aucune actualité pour le moment. Les nouveautés des jeux sont aussi dans leurs notes de version.</p>
}
else
{
<partial name="_NewsList" model="News.All" />
}
</div>
</section>
@@ -0,0 +1,29 @@
@page "/confidentialite"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Title"] = "Confidentialité";
ViewData["Description"] = "Ce que le site enregistre (presque rien) et pourquoi.";
Uri.TryCreate(site.Launcher.UpdateUrl, UriKind.Absolute, out var launcherServer);
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Confidentialité</h1>
<p class="lead">Ce site ne vous demande rien et ne vous suit pas : pas de compte, pas de formulaire, pas de cookie, pas de mesure d'audience, pas de publicité, aucun service externe (les polices et les images sont servies par le site lui-même).</p>
<h2>Journaux du serveur</h2>
<p>Comme tout serveur web, le nôtre note chaque requête : adresse IP, date, page demandée, navigateur. Ces journaux servent uniquement à la sécurité du site (repérer une attaque, un abus) et au diagnostic des pannes. Ils sont effacés au bout de @site.Legal.LogRetentionDays jours et ne sont ni croisés avec d'autres données, ni transmis à qui que ce soit, sauf obligation légale.</p>
<p>Base légale : notre intérêt légitime à protéger le site (article 6.1.f du RGPD).</p>
<h2>Téléchargement du launcher</h2>
<p>Le bouton « Télécharger le launcher » vous envoie directement vers notre serveur de téléchargement@(launcherServer is null ? "" : $" ({launcherServer.Host})"), qui tient lui aussi un journal technique dans les mêmes conditions.</p>
<h2>Nous contacter</h2>
<p>Le bouton « Nous contacter » ouvre votre propre messagerie. Votre message et votre adresse e-mail ne passent pas par ce site : nous ne les utilisons que pour vous répondre, et les supprimons quand l'échange est terminé.</p>
<h2>Vos droits</h2>
<p>Vous pouvez demander l'accès à vos données, leur rectification ou leur effacement@(string.IsNullOrWhiteSpace(site.ContactEmail) ? "" : " en écrivant à ")@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{<a href="mailto:@site.ContactEmail">@site.ContactEmail</a>}. Vous pouvez aussi adresser une réclamation à la CNIL (<a href="https://www.cnil.fr" rel="noopener noreferrer">cnil.fr</a>).</p>
</div>
</section>
@@ -0,0 +1,22 @@
@page "/erreur/{code:int?}"
@model ErreurModel
@{
ViewData["Title"] = Model.Code == 404 ? "Page introuvable" : "Erreur";
}
<section class="error-page">
<div class="wrap wrap-narrow">
<p class="error-code" aria-hidden="true">@Model.Code</p>
@if (Model.Code == 404)
{
<h1>Cette page n'existe pas.</h1>
<p class="lead">L'adresse est peut-être mal saisie, ou la page a été déplacée.</p>
}
else
{
<h1>Le site a rencontré un problème.</h1>
<p class="lead">Réessayez dans un instant. Si le problème continue, écrivez-nous.</p>
}
<p><a class="btn btn-primary" href="/">Revenir à l'accueil</a></p>
</div>
</section>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages;
/// <summary>Page d'erreur (404, 500…) : aucun détail technique n'est montré au visiteur.</summary>
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public sealed class ErreurModel : PageModel
{
public int Code { get; private set; } = 500;
public void OnGet(int? code)
{
Code = code is >= 400 and <= 599 ? code.Value : 500;
// Accès direct à /erreur/… : garder le code d'erreur dans la réponse.
if (HttpContext.Features.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>() is null)
Response.StatusCode = Code;
}
}
@@ -0,0 +1,77 @@
@page
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject NewsService News
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "accueil";
ViewData["Theme"] = "launcher";
ViewData["Description"] = $"{site.StudioName}, studio indépendant : Exovival Zombie Reborn et le launcher pour installer, mettre à jour et lancer nos jeux sur Windows.";
var available = site.VisibleGames.Where(g => g.IsAvailable).ToList();
var upcoming = site.VisibleGames.Where(g => !g.IsAvailable).ToList();
var latest = News.Latest(3).ToList();
}
<section class="home-hero">
<div class="wrap">
<div>
<h1>Nos jeux s'installent en un clic.</h1>
<p class="lead">Le launcher @site.StudioName installe nos jeux, les garde à jour et vérifie chaque fichier. Téléchargez-le une fois : il s'occupe du reste, et de lui-même.</p>
<partial name="_LauncherDownload" />
</div>
<figure class="hero-shot">
<img src="/img/launcher/apercu-exovival.webp" width="1168" height="708"
alt="Le launcher ouvert sur Exovival Zombie Reborn : bouton Installer, taille du téléchargement et notes de version.">
</figure>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Nos jeux</h2>
<a href="/jeux">Tous les jeux</a>
</div>
@foreach (var game in available)
{
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">Disponible dans le launcher</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in upcoming)
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@if (latest.Count > 0)
{
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Actualités</h2>
<a href="/actualites">Toutes les actualités</a>
</div>
<partial name="_NewsList" model="latest" />
</div>
</section>
}
@@ -0,0 +1,208 @@
@page "/jeux/exovival-zombie-reborn"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
const string Slug = "exovival-zombie-reborn";
var game = SiteOptionsMonitor.CurrentValue.FindGame(Slug);
var snapshot = Store.GetGame(Slug);
var latest = snapshot?.Changelog.FirstOrDefault();
ViewData["Nav"] = "jeux";
ViewData["Theme"] = "exovival";
ViewData["Title"] = "Exovival Zombie Reborn";
ViewData["Description"] = "Exovival Zombie Reborn : survival horror à la première personne. La ville a été évacuée, votre fille Léa manque à l'appel. Retournez seul dans l'école plongée dans le noir.";
}
<section class="exo-hero" data-uv-hero>
<div class="exo-hero-photo">
<img src="/img/exovival/couloir.webp" srcset="/img/exovival/couloir-960.webp 960w, /img/exovival/couloir.webp 1920w" sizes="100vw"
width="1920" height="1080" alt="" fetchpriority="high">
</div>
<div class="exo-uv" role="img" aria-label="Écrit à l'encre invisible sur le mur : Léa ♥ Papa."></div>
<div class="exo-torch"></div>
<div class="wrap">
<p class="exo-mark">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#biohazard" /></svg>
Survival horror à la première personne
</p>
<h1 class="exo-title">Exovival Zombie Reborn</h1>
<p class="lead">La ville a été évacuée en pleine nuit. À l'appel du point de rassemblement, une élève manquait : Léa, dix ans, votre fille. Vous retournez seul à l'école Jean-Moulin. Plus de courant, des portes condamnées, et quelque chose qui gratte dans les couloirs.</p>
<div class="exo-actions">
<a class="btn btn-primary btn-large" href="/launcher#telecharger">@Html.Icon("Download") Jouer avec le launcher</a>
<a class="btn btn-ghost btn-large" href="/jeux/@Slug/notes-de-version">Notes de version</a>
</div>
<p class="uv-hint">@Html.Icon("Flashlight") Promenez votre lampe UV sur le mur : Léa vous a laissé un message.</p>
</div>
<svg width="0" height="0" class="visually-hidden" aria-hidden="true" focusable="false">
<defs>
<mask id="biohazard-crescents" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
<circle cx="12" cy="12.8" r="3.9" fill="#000" />
</mask>
<mask id="biohazard-ring" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="12.8" r="2.2" fill="#000" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
</mask>
<symbol id="biohazard" viewBox="0 0 24 24">
<g fill="currentColor" mask="url(#biohazard-crescents)">
<circle cx="12" cy="7.8" r="5.3" />
<circle cx="16.33" cy="15.3" r="5.3" />
<circle cx="7.67" cy="15.3" r="5.3" />
</g>
<circle cx="12" cy="12.8" r="3.3" fill="currentColor" mask="url(#biohazard-ring)" />
</symbol>
</defs>
</svg>
</section>
<section class="section">
<div class="wrap exo-story">
<div class="prose">
<p>Le soir de l'épidémie, tout le quartier a été évacué. Les cars sont partis, les portes ont été condamnées. Au point de rassemblement, on a fait l'appel classe par classe. En CM1, il manquait une élève : Léa.</p>
<p>Vous revenez seul, de nuit, avec une lampe torche et ce que vous trouverez sur place. L'école Jean-Moulin n'a plus de courant. Des pas résonnent quelque part à l'étage.</p>
<p>Léa et vous avez un jeu secret : des messages à l'encre invisible, écrits avec le stylo espion que vous lui avez offert pour son anniversaire. Elle vous a laissé une piste. Il faut la suivre jusqu'au bout.</p>
</div>
<div>
<h2>Ceux qui étaient là</h2>
<ul class="cast">
<li><b>Léa</b><span>Dix ans, en CM1. Votre fille.</span></li>
<li><b>Mme Roussel</b><span>Sa maîtresse, qui filme la classe au caméscope.</span></li>
<li><b>Mme Garnier</b><span>La directrice. Elle a laissé un mot dans la classe.</span></li>
<li><b>M. Bernard</b><span>Le concierge. Il connaît chaque recoin et en garde les clés.</span></li>
<li><b>L'agent de sécurité</b><span>De garde la nuit de l'évacuation.</span></li>
</ul>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce qui vous attend</h2>
<div class="exo-features">
<div class="exo-feature">
<h3>@Html.Icon("Flashlight") Le noir, et peu de piles</h3>
<p>Lampe torche, briquet, bougie, lanterne à pétrole : chaque source de lumière s'épuise. Pour rallumer l'école, il faudra trouver des fusibles, régler une armoire électrique et remettre en marche un groupe électrogène qui consomme son carburant.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Puzzle") Des énigmes à l'ancienne</h3>
<p>Digicodes, cadenas, coffres à combinaison, crochetage, labyrinthe à bille, circuit électrique à réparer, leviers à actionner dans le bon ordre. Les indices se cachent dans les papiers, sur les cassettes, et sur les murs, à la lampe UV.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Cassette") Cassettes, caméscope et caméras</h3>
<p>Le magnétoscope de la salle des profs relit les cassettes de l'école. Le caméscope de Mme Roussel voit dans le noir. Une fois le courant revenu, les caméras de surveillance montrent des pièces où l'on ne peut pas entrer.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Ear") Les zombies vous entendent</h3>
<p>Marcher fait du bruit, courir s'entend de loin, même dans leur dos ; un mur ou une porte fermée étouffe vos pas. Accroupi, vous êtes silencieux. Cachez-vous dans un casier, observez en vous penchant, contournez. Et attention aux groupes : un zombie qui attaque attire ceux d'à côté.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Axe") Couteau, hache, pistolet</h3>
<p>Chaque arme a ses dégâts, et viser la tête les double. Un coup de hache bien placé suffit ; au pistolet, on vise avec les organes de visée et on compte ses balles.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Medical") Se soigner à l'ancienne</h3>
<p>Herbes et huiles médicinales à ramasser, à mélanger en potion dans l'inventaire. La trousse de secours reste le meilleur remède. Rien n'est gaspillé : un soin ne s'utilise pas quand vous êtes en pleine forme.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("History") Sauvegardes</h3>
<p>Jusqu'à dix sauvegardes, plus des sauvegardes automatiques aux moments clés. Après une mort, « Recommencer » reprend à la dernière. Le jeu est en français et en anglais.</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Cassettes retrouvées</h2>
<p class="lead">Des images enregistrées dans l'école, avant et pendant la nuit de l'évacuation. Elles sont dans le jeu, sur les cassettes à glisser dans le magnétoscope.</p>
<div class="tapes">
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-couloir-nuit.webp" width="640" height="480" loading="lazy" alt="Couloir de l'école filmé en vision nocturne verte, horodatage du 12 octobre 2026 à 21 h 47."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">Le couloir, en vision nocturne.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-escalier.webp" width="640" height="480" loading="lazy" alt="Cage d'escalier filmée en vision nocturne, vue plongeante."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">L'escalier, caméra à la main.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-bibliotheque.webp" width="640" height="480" loading="lazy" alt="La bibliothèque de l'école éclairée, rayonnages et tables, image VHS datée du 19 décembre 2025."></div>
<figcaption><span class="tape-label">Spectacle de Noël</span><span class="tape-text">La bibliothèque, quand tout allait bien.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-gymnase.webp" width="640" height="480" loading="lazy" alt="Le gymnase de l'école en plein jour, parquet et paniers de basket, image VHS datée du 3 mars 2026."></div>
<figcaption><span class="tape-label">EPS</span><span class="tape-text">Le gymnase, un matin de mars.</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="abri">
<div class="abri-head">
<span class="abri-tag">En développement</span>
<h2>Sous l'école, l'abri</h2>
<p>La prochaine extension du niveau ouvre ce que la ville avait muré : un abri de défense passive inauguré en 1962, le parking souterrain de l'ancien commissariat sur trois niveaux, et les égouts de 1890 qui courent sous le quartier. Au programme : de nouvelles énigmes, le stand de tir de la police, des caméras de surveillance et les enregistrements laissés par ceux qui sont passés avant vous.</p>
<p class="small">Images de développement : elles peuvent encore changer avant la sortie.</p>
</div>
<div class="abri-grid">
<figure><img src="/img/exovival/abri-collecteur.webp" width="1280" height="720" loading="lazy" alt="Galerie d'égout voûtée, cerclée de conduites métalliques."><figcaption>Le collecteur</figcaption></figure>
<figure><img src="/img/exovival/abri-camera.webp" width="1280" height="720" loading="lazy" alt="Image d'une caméra de surveillance : CAM 11, égouts, grand collecteur."><figcaption>Caméra 11</figcaption></figure>
<figure><img src="/img/exovival/abri-decantation.webp" width="1280" height="720" loading="lazy" alt="Salle de décantation circulaire avec une fosse noyée au centre."><figcaption>La salle de décantation</figcaption></figure>
<figure><img src="/img/exovival/abri-parking.webp" width="1280" height="720" loading="lazy" alt="Parking souterrain vide, piliers de béton, éclairage de secours."><figcaption>Le parking, niveau -1</figcaption></figure>
<figure><img src="/img/exovival/abri-grand-collecteur.webp" width="1280" height="720" loading="lazy" alt="Grand tunnel d'égout en briques avec une rigole d'eau au centre."><figcaption>Le grand collecteur</figcaption></figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Dernière mise à jour</h2>
<a href="/jeux/@Slug/notes-de-version">Toutes les notes de version</a>
</div>
@if (latest is not null)
{
<div class="changelog">
<partial name="_Release" model="latest" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Pour jouer</h2>
<dl class="specs">
@if (snapshot is not null)
{
<div class="spec"><dt>Version</dt><dd>@snapshot.Version</dd></div>
@if (snapshot.DownloadSize > 0)
{
<div class="spec"><dt>Téléchargement</dt><dd>@FrenchText.Size(snapshot.DownloadSize)</dd></div>
}
@if (snapshot.InstalledSize > 0)
{
<div class="spec"><dt>Espace disque</dt><dd>@FrenchText.Size(snapshot.InstalledSize)</dd></div>
}
}
<div class="spec"><dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd></div>
<div class="spec"><dt>Graphismes</dt><dd>DirectX 11</dd></div>
<div class="spec"><dt>Langues</dt><dd>Français, anglais</dd></div>
</dl>
<div class="section-tight">
<p class="lead">Exovival Zombie Reborn s'installe et se met à jour avec le launcher. Téléchargez-le, choisissez le jeu, cliquez sur Installer.</p>
<partial name="_LauncherDownload" />
</div>
</div>
</section>
@@ -0,0 +1,26 @@
@page "/jeux/{slug:maxlength(80)}"
@model GameModel
@* Page générique d'un jeu sans page dédiée (jeu en préparation, par exemple). *@
@{
var game = Model.Game;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = game.Name;
ViewData["Description"] = string.IsNullOrWhiteSpace(game.Tagline) ? game.Name : game.Tagline;
}
<section class="upcoming">
<div class="wrap">
<span class="status">@(game.IsAvailable ? "Disponible dans le launcher" : "En préparation")</span>
<h1>@game.Name</h1>
<p class="lead">@(string.IsNullOrWhiteSpace(game.Teaser) ? game.Tagline : game.Teaser)</p>
@if (game.IsAvailable)
{
<partial name="_LauncherDownload" />
}
else
{
<p class="muted">En attendant, <a href="/actualites">suivez les actualités</a> ou installez le launcher : le jeu y apparaîtra dès sa sortie.</p>
}
</div>
</section>
@@ -0,0 +1,25 @@
using GameLauncher.Website.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
public sealed class GameModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
public GameModel(IOptionsMonitor<SiteOptions> options) => _options = options;
public GameOptions Game { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null) return NotFound();
// Les jeux qui ont leur propre page (Pages/Jeux/<Jeu>.cshtml) y sont servis directement.
if (game.HasDedicatedPage) return NotFound();
Game = game;
return Page();
}
}
@@ -0,0 +1,49 @@
@page "/jeux"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "jeux";
ViewData["Title"] = "Nos jeux";
ViewData["Description"] = $"Les jeux de {site.StudioName}, à installer avec le launcher.";
}
<section class="page-hero">
<div class="wrap">
<h1>Nos jeux</h1>
<p class="lead">Tous nos jeux s'installent et se mettent à jour avec le launcher. Un seul téléchargement, pour tous.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@foreach (var game in site.VisibleGames.Where(g => g.IsAvailable))
{
var snapshot = Store.GetGame(game.Slug);
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">@(snapshot is null ? "Disponible dans le launcher" : $"Version {snapshot.Version} dans le launcher")</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in site.VisibleGames.Where(g => !g.IsAvailable))
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@@ -0,0 +1,45 @@
@page "/jeux/{slug:maxlength(80)}/notes-de-version"
@model NotesModel
@inject RemoteContentStore Store
@{
var game = Model.Game;
var snapshot = Model.Snapshot;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = $"Notes de version – {game.Name}";
ViewData["Description"] = $"Toutes les nouveautés et corrections de {game.Name}, version par version.";
}
<section class="page-hero">
<div class="wrap wrap-narrow">
<p><a href="/jeux/@game.Slug">@game.Name</a></p>
<h1>Notes de version</h1>
@if (snapshot is not null)
{
<p class="lead">Version actuelle : <strong>@snapshot.Version</strong>@(snapshot.ReleaseDate != default ? $", publiée le {FrenchText.Date(snapshot.ReleaseDate)}" : "").
Le launcher affiche les mêmes notes et installe la mise à jour pour vous.</p>
}
</div>
</section>
<section class="section-tight">
<div class="wrap wrap-narrow">
@if (snapshot is null)
{
<p class="notice">@(Store.FirstRefreshDone ? "Les notes de version sont momentanément indisponibles. Réessayez dans quelques minutes." : "Les notes de version se chargent. Rechargez la page dans quelques secondes.")</p>
}
else if (snapshot.Changelog.Count == 0)
{
<p class="notice">Aucune note de version publiée pour le moment.</p>
}
else
{
<div class="changelog">
@foreach (var entry in snapshot.Changelog)
{
<partial name="_Release" model="entry" view-data='new ViewDataDictionary(ViewData) { ["Anchor"] = "v" + System.Text.RegularExpressions.Regex.Replace(entry.Version, "[^A-Za-z0-9._-]", "") }' />
}
</div>
}
</div>
</section>
@@ -0,0 +1,32 @@
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
/// <summary>Toutes les notes de version d'un jeu (manifest.json lu par le site, jamais par le navigateur).</summary>
public sealed class NotesModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly RemoteContentStore _store;
public NotesModel(IOptionsMonitor<SiteOptions> options, RemoteContentStore store)
{
_options = options;
_store = store;
}
public GameOptions Game { get; private set; } = null!;
public GameSnapshot? Snapshot { get; private set; }
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null || string.IsNullOrWhiteSpace(game.ManifestUrl)) return NotFound();
Game = game;
Snapshot = _store.GetGame(game.Slug);
return Page();
}
}
@@ -0,0 +1,197 @@
@page "/launcher"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
var launcher = Store.Launcher;
ViewData["Nav"] = "launcher";
ViewData["Theme"] = "launcher";
ViewData["Title"] = "Le launcher";
ViewData["Description"] = "Téléchargez le launcher LanisysGames pour Windows : installation en un clic, mises à jour automatiques, vérification et réparation des fichiers, mises à jour signées.";
}
<section class="page-hero launcher-hero" id="telecharger">
<div class="wrap">
<div>
<h1>Installez, jouez, on s'occupe du reste.</h1>
<p class="lead">Le launcher télécharge nos jeux, applique les mises à jour sans tout retélécharger, vérifie chaque fichier et répare ce qui manque. Un seul fichier à télécharger, rien d'autre à installer.</p>
@{ ViewData["DownloadDetails"] = true; }
<partial name="_LauncherDownload" view-data="ViewData" />
</div>
<aside class="download-panel" aria-label="Fiche du launcher">
<h2>En bref</h2>
<dl class="facts">
@if (launcher is not null)
{
<dt>Version</dt><dd>@launcher.Version</dd>
@if (launcher.PublishedAt != default)
{
<dt>Publiée le</dt><dd><time datetime="@FrenchText.IsoDate(launcher.PublishedAt)">@FrenchText.Date(launcher.PublishedAt)</time></dd>
}
<dt>Taille</dt><dd>@FrenchText.Size(launcher.Size)</dd>
}
<dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd>
<dt>À installer</dt><dd>Rien : un seul fichier, .NET inclus</dd>
<dt>Droits</dt><dd>Aucun droit administrateur</dd>
<dt>Prix</dt><dd>Gratuit</dd>
</dl>
</aside>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<div class="shot-strip">
<figure class="shot shot-main">
<img src="/img/launcher/apercu-menu.webp" width="1200" height="740" loading="lazy"
alt="Le launcher avec son menu ouvert : rechercher les mises à jour, vérifier l'intégrité des fichiers, réparer, localiser une installation, ouvrir le dossier du jeu, paramètres, désinstaller.">
<figcaption>Tout se fait depuis un seul menu : vérifier, réparer, localiser, désinstaller.</figcaption>
</figure>
<div class="shot-side">
<figure class="shot">
<img src="/img/launcher/apercu-telechargement.webp" width="1200" height="740" loading="lazy"
alt="Téléchargement en cours : barre de progression, vitesse et temps restant.">
<figcaption>Une mise à jour ne télécharge que les fichiers modifiés.</figcaption>
</figure>
<figure class="shot">
<img src="/img/launcher/apercu-multijeux.webp" width="1256" height="708" loading="lazy"
alt="Le launcher avec la liste des jeux à gauche, chaque jeu avec son propre thème.">
<figcaption>Plusieurs jeux, chacun avec son ambiance.</figcaption>
</figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce que fait le launcher</h2>
<p class="lead">Pensé pour qu'un jeu reste jouable quoi qu'il arrive : coupure pendant un téléchargement, fichier abîmé, dossier déplacé ou supprimé par erreur.</p>
<div class="feature-columns">
<div class="feature-group">
<h3>@Html.Icon("CloudDownload") Installer et mettre à jour</h3>
<ul>
<li><strong>Installation en un clic</strong>, dans le dossier de votre choix ; l'espace disque est vérifié avant de commencer.</li>
<li><strong>Téléchargement repris</strong> là où il s'était arrêté après une coupure ou une annulation.</li>
<li><strong>Mises à jour différentielles</strong> : seuls les fichiers nouveaux ou modifiés sont téléchargés. Vos sauvegardes ne sont jamais touchées.</li>
<li>Automatiques ou manuelles, au choix, jeu par jeu. Recherche au démarrage, régulièrement pendant que le launcher est ouvert, et quand vous quittez le jeu.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Wrench") Vérifier et réparer</h3>
<ul>
<li><strong>Vérification de l'intégrité</strong> : chaque fichier du jeu est comparé à son empreinte SHA-256, même hors ligne.</li>
<li><strong>Réparation</strong> : seuls les fichiers abîmés ou manquants sont retéléchargés. Si le dossier du jeu a été supprimé à la main, le launcher le réinstalle au même endroit.</li>
<li><strong>Localiser une installation</strong> : le jeu est déjà sur votre PC ? Indiquez son dossier, le launcher le reconnaît et le met à jour.</li>
<li>Les raccourcis du Bureau sont remis en état s'ils pointent vers un ancien emplacement.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Gamepad") Au quotidien</h3>
<ul>
<li><strong>Raccourci du Bureau</strong> : il ouvre le launcher, applique les mises à jour puis lance le jeu.</li>
<li><strong>Jouer hors ligne</strong> : sans connexion, un jeu installé se lance quand même.</li>
<li>Notes de version intégrées, à lire en grand avant de jouer.</li>
<li>Pendant la partie, le launcher peut se réduire, se cacher près de l'horloge ou se fermer. Plein écran avec F11.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Delete") Désinstaller proprement</h3>
<ul>
<li>Depuis le launcher ou depuis <strong>Paramètres Windows › Applications installées</strong>, comme n'importe quel logiciel.</li>
<li>Par défaut, vos <strong>sauvegardes et réglages sont conservés</strong>.</li>
<li>Une case permet de tout effacer : sauvegardes, réglages, journaux et fichiers ajoutés dans le dossier du jeu. Les dossiers sensibles de Windows sont protégés.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Forum") Nous écrire depuis le launcher</h3>
<ul>
<li>Le bouton <strong>Aide</strong> envoie un avis, une idée ou un bug sur le jeu sélectionné.</li>
<li><strong>Anonyme</strong> : aucun compte, aucune adresse IP enregistrée. Vous recevez une référence pour en reparler.</li>
<li>Jusqu'à trois captures d'écran, nettoyées de leurs métadonnées avant l'envoi.</li>
</ul>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="trust">
<div>
<h2>Des mises à jour qu'on ne peut pas falsifier</h2>
<p class="muted">Chaque version du launcher est signée numériquement sur notre poste, avec une clé qui ne quitte jamais notre PC. Avant de se mettre à jour, le launcher vérifie cette signature et l'empreinte du fichier téléchargé : même un serveur piraté ne pourrait pas vous faire installer autre chose.</p>
<div class="feature-group"><ul aria-label="Protections">
<li><strong>Jamais de retour en arrière</strong> : seule une version plus récente peut s'installer.</li>
<li><strong>Retour automatique</strong> à la version précédente si la nouvelle ne démarre pas.</li>
<li><strong>Correctifs importants</strong> installés au démarrage, même si vous avez désactivé la mise à jour automatique ; votre réglage n'est pas modifié.</li>
<li>Les versions des jeux et les informations affichées dans le launcher sont signées elles aussi.</li>
</ul></div>
</div>
<img src="/img/launcher/mise-a-jour-verifiee.webp" width="600" height="626" loading="lazy"
alt="Fenêtre de mise à jour du launcher : nouvelle version disponible, signature et empreinte vérifiées, notes de version.">
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Première installation</h2>
<ol class="steps">
<li><strong>Téléchargez le launcher.</strong> Le bouton en haut de cette page donne toujours la dernière version, vérifiée par le site.</li>
<li><strong>Rangez-le dans un dossier à vous, puis double-cliquez dessus.</strong> Un dossier « Jeux » fait très bien l'affaire. Évitez « Program Files » : le launcher ne pourrait pas s'y mettre à jour tout seul.</li>
<li><strong>Windows affiche « Windows a protégé votre ordinateur » ?</strong> Cliquez sur « Informations complémentaires » puis « Exécuter quand même ». Pour être sûr du fichier, comparez d'abord son empreinte avec celle indiquée sous le bouton de téléchargement.</li>
<li><strong>Choisissez un jeu et cliquez sur Installer.</strong> Le launcher propose un dossier et peut créer un raccourci sur le Bureau.</li>
</ol>
</div>
</section>
<section class="section" id="notes">
<div class="wrap">
<div class="section-head">
<h2>Nouveautés de la dernière version</h2>
@if (!string.IsNullOrWhiteSpace(site.Launcher.ReleasesUrl))
{
<a href="@site.Launcher.ReleasesUrl" rel="noopener noreferrer">Toutes les versions sur notre Gitea</a>
}
</div>
@if (launcher?.LatestNotes is { } notes)
{
<div class="changelog">
<partial name="_Release" model="notes" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Questions fréquentes</h2>
<div class="faq">
<details>
<summary>Où sont mes sauvegardes ?</summary>
<p>Dans votre dossier utilisateur, à part du jeu : <code>%APPDATA%\LanisysGames\</code> puis le nom du jeu. Mettre à jour, réparer ou réinstaller un jeu ne les touche pas. Le menu « … » › Paramètres du jeu ouvre ce dossier directement.</p>
</details>
<details>
<summary>Le jeu est déjà sur mon PC, faut-il tout retélécharger ?</summary>
<p>Non. Cliquez sur « Localiser l'installation » sous le bouton Installer et choisissez le dossier du jeu : le launcher le reconnaît, puis ne télécharge que ce qui a changé.</p>
</details>
<details>
<summary>Puis-je déplacer le launcher ?</summary>
<p>Oui. Au démarrage suivant depuis son nouvel emplacement, il met à jour ses raccourcis et l'entrée « Applications installées » de Windows.</p>
</details>
<details>
<summary>Le launcher a-t-il besoin d'Internet ?</summary>
<p>Pour installer et mettre à jour, oui. Un jeu déjà installé se lance aussi hors ligne, et la vérification des fichiers fonctionne sans connexion.</p>
</details>
<details>
<summary>Comment tout désinstaller ?</summary>
<p>Menu « … » › Désinstaller, ou Paramètres Windows › Applications installées. Cochez la case proposée pour supprimer aussi sauvegardes, réglages et journaux. Le launcher lui-même est un simple fichier : supprimez-le quand vous n'avez plus de jeu installé.</p>
</details>
</div>
</div>
</section>
@@ -0,0 +1,46 @@
@page "/mentions-legales"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var legal = site.Legal;
ViewData["Title"] = "Mentions légales";
ViewData["Description"] = "Éditeur, hébergement et propriété intellectuelle du site.";
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Mentions légales</h1>
<h2>Éditeur du site</h2>
<address>
<strong>@(string.IsNullOrWhiteSpace(legal.Publisher) ? site.StudioName : legal.Publisher)</strong><br>
@foreach (var line in legal.PublisherDetails)
{
@line<br>
}
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<text>Contact : </text><a href="mailto:@site.ContactEmail">@site.ContactEmail</a>
}
</address>
@if (!string.IsNullOrWhiteSpace(legal.Director))
{
<p>Directeur de la publication : @legal.Director</p>
}
<h2>Hébergement</h2>
<address>
@foreach (var line in legal.Host)
{
@line<br>
}
</address>
<h2>Propriété intellectuelle</h2>
<p>Les textes, images, captures et vidéos des jeux présentés sur ce site appartiennent à @site.StudioName, sauf mention contraire. Toute reproduction sans autorisation est interdite.</p>
<p>Polices de caractères : Sora et Barlow, sous licence SIL Open Font License 1.1 (<a href="/fonts/OFL-Sora.txt">Sora</a>, <a href="/fonts/OFL-Barlow.txt">Barlow</a>, <a href="/fonts/OFL-Barlow-Condensed.txt">Barlow Condensed</a>). Icônes : Material Design Icons, licence Apache 2.0.</p>
<h2>Données personnelles</h2>
<p>Voir la page <a href="/confidentialite">Confidentialité</a>.</p>
</div>
</section>
@@ -0,0 +1,41 @@
@inject RemoteContentStore Store
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@* Bouton de téléchargement du launcher : affiché seulement si launcher-update.json est signé par la clé
du launcher (vérifiée par le site). L'exécutable est téléchargé directement depuis le serveur du launcher. *@
@{
var launcher = Store.Launcher;
var showDetails = ViewData["DownloadDetails"] as bool? ?? false;
}
@if (launcher is not null)
{
<div class="download">
<a class="btn btn-primary btn-large" href="@launcher.DownloadUrl">
@Html.Icon("Download") Télécharger le launcher
</a>
<p class="download-meta">
Version <strong>@launcher.Version</strong>, @FrenchText.Size(launcher.Size), pour Windows 10 et 11 (64 bits).
@if (launcher.IsPriority)
{
<span class="badge-priority">Correctif important</span>
}
</p>
@if (showDetails)
{
<details class="download-check">
<summary>@Html.Icon("ShieldCheck") Signature vérifiée</summary>
<p>Le site a vérifié la signature numérique de cette version avec la clé publique intégrée au launcher. Après le téléchargement, vous pouvez comparer l'empreinte du fichier :</p>
<code class="hash" id="empreinte-launcher">@launcher.Sha256</code>
<p class="small">Dans PowerShell : <code>Get-FileHash .\@launcher.FileName</code>
<button class="btn btn-ghost small" type="button" data-copy="empreinte-launcher" hidden>Copier l'empreinte</button></p>
</details>
}
</div>
}
else if (!Store.FirstRefreshDone)
{
<p class="notice">Le lien de téléchargement se prépare. Rechargez la page dans quelques secondes.</p>
}
else
{
<p class="notice"><strong>Le téléchargement est momentanément indisponible.</strong> Le site n'a pas pu vérifier la dernière version du launcher ; réessayez dans quelques minutes@(string.IsNullOrWhiteSpace(SiteOptionsMonitor.CurrentValue.ContactEmail) ? "." : " ou écrivez-nous.")</p>
}
@@ -0,0 +1,116 @@
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var theme = ViewData["Theme"] as string ?? "launcher";
var nav = ViewData["Nav"] as string ?? "";
var pageTitle = ViewData["Title"] as string;
var fullTitle = string.IsNullOrEmpty(pageTitle) ? site.StudioName : $"{pageTitle} – {site.StudioName}";
var description = ViewData["Description"] as string ?? $"{site.StudioName} : nos jeux et le launcher pour les installer, les mettre à jour et les lancer en un clic.";
var root = site.PublicUrl.TrimEnd('/');
var path = Context.Request.Path.Value ?? "/";
var ogImage = ViewData["OgImage"] as string ?? (theme == "exovival" ? "/img/og/exovival.jpg" : "/img/og/launcher.jpg");
string? Current(string key) => nav == key ? "page" : null;
}
<!DOCTYPE html>
<html lang="fr" data-theme="@theme">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@fullTitle</title>
<meta name="description" content="@description">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="@(theme == "exovival" ? "#060708" : theme == "multi" ? "#0a0f14" : "#0b0d13")">
@if (!string.IsNullOrEmpty(root))
{
<link rel="canonical" href="@(root + (path == "/" ? "/" : path))">
<meta property="og:type" content="@(ViewData["OgType"] as string ?? "website")">
<meta property="og:site_name" content="@site.StudioName">
<meta property="og:locale" content="fr_FR">
<meta property="og:title" content="@(pageTitle ?? site.StudioName)">
<meta property="og:description" content="@description">
<meta property="og:url" content="@(root + path)">
<meta property="og:image" content="@(root + ogImage)">
<meta name="twitter:card" content="summary_large_image">
}
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<link rel="preload" href="/fonts/@(theme == "exovival" ? "barlow-condensed-800" : "sora-variable").woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
<script src="~/js/site.js" asp-append-version="true" defer></script>
</head>
<body>
<a class="skip-link" href="#contenu">Aller au contenu</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="menu-principal">
@Html.Icon("Menu")
<span class="visually-hidden">Menu</span>
</button>
<nav class="site-nav" id="menu-principal" aria-label="Navigation principale">
<a href="/" aria-current="@Current("accueil")">Accueil</a>
<a href="/launcher" aria-current="@Current("launcher")">Launcher</a>
<a href="/jeux" aria-current="@Current("jeux")">Jeux</a>
<a href="/actualites" aria-current="@Current("actualites")">Actualités</a>
</nav>
<div class="header-actions">
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<a class="btn btn-ghost" href="mailto:@site.ContactEmail">@Html.Icon("EmailOutline") Nous contacter</a>
}
<a class="btn btn-primary" href="/launcher#telecharger">@Html.Icon("Download") Télécharger le launcher</a>
</div>
</div>
</header>
<main id="contenu">
@RenderBody()
</main>
<footer class="site-footer">
<div class="wrap">
<div class="footer-brand">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<p class="small">Studio indépendant. Nos jeux s'installent, se mettent à jour et se lancent depuis notre launcher, pour Windows.</p>
</div>
<nav aria-label="Jeux">
<h2>Jeux</h2>
<ul>
@foreach (var game in site.VisibleGames)
{
<li><a href="/jeux/@game.Slug">@game.Name</a></li>
}
</ul>
</nav>
<nav aria-label="Launcher">
<h2>Launcher</h2>
<ul>
<li><a href="/launcher">Présentation</a></li>
<li><a href="/launcher#telecharger">Télécharger</a></li>
<li><a href="/launcher#notes">Notes de version</a></li>
</ul>
</nav>
<nav aria-label="Informations">
<h2>Informations</h2>
<ul>
<li><a href="/actualites">Actualités</a></li>
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<li><a href="mailto:@site.ContactEmail">Nous contacter</a></li>
}
<li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/confidentialite">Confidentialité</a></li>
</ul>
</nav>
<p class="footer-note">© @DateTime.UtcNow.Year @site.StudioName. Ce site n'utilise ni cookie, ni outil de mesure d'audience, ni service externe.</p>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,22 @@
@model IReadOnlyList<NewsArticle>
<ul class="news-list">
@foreach (var article in Model)
{
<li>
<a class="news-item" href="/actualites/@article.Slug" data-topic-theme="@article.Theme">
@if (article.Image is not null)
{
<img src="@article.Image" alt="@(article.ImageAlt ?? "")" loading="lazy" width="640" height="360">
}
<div class="news-item-body">
<span class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</span>
<h3>@article.Title</h3>
<p>@article.Summary</p>
</div>
</a>
</li>
}
</ul>
@@ -0,0 +1,66 @@
@model ChangelogEntry
@* Une version des notes : même structure que dans le launcher (titre, sous-titre, introduction, sections).
Tous les textes viennent du manifest : ils sont encodés par Razor (jamais interprétés comme du HTML). *@
@{
var anchor = ViewData["Anchor"] as string;
}
<article class="release" id="@anchor">
<header class="release-head">
<span class="version-pill">@Model.Version</span>
@if (Model.Date != default)
{
<time datetime="@FrenchText.IsoDate(Model.Date)">@FrenchText.Date(Model.Date)</time>
}
</header>
@if (!string.IsNullOrWhiteSpace(Model.Title))
{
<h3>@Model.Title</h3>
}
@if (Model.HasSubtitle)
{
<p class="release-subtitle">@Model.Subtitle</p>
}
@foreach (var paragraph in Model.Introduction)
{
<p class="release-intro">@paragraph</p>
}
@foreach (var part in Model.DisplaySections)
{
<section class="release-section">
@if (part.HasTitle)
{
<h4>@part.Title</h4>
}
<ul class="release-items">
@foreach (var item in part.Items)
{
if (item.IsComment)
{
<li class="is-comment">@item.Text</li>
continue;
}
<li>
@if (item.HasTag)
{
<span class="tag">@item.Tag</span>
}
@item.Text
@if (item.HasDetails)
{
<ul class="release-details">
@foreach (var detail in item.Details)
{
<li>@detail</li>
}
</ul>
}
@if (item.HasNote)
{
<p class="release-note">@item.Note</p>
}
</li>
}
</ul>
</section>
}
</article>
@@ -0,0 +1,7 @@
@using GameLauncher.Website
@using GameLauncher.Website.Options
@using GameLauncher.Website.Services
@using GameLauncher.Contracts.Changelog
@using Microsoft.Extensions.Options
@namespace GameLauncher.Website.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+140
View File
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Security;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using System.Threading.RateLimiting;
using GameLauncher.Website.Hosting;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// --- Serveur web (Kestrel, derrière nginx) ---------------------------------------------------------
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.AddServerHeader = false;
kestrel.Limits.MaxRequestBodySize = 1024; // aucun envoi de données attendu
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
kestrel.Limits.MaxRequestLineSize = 4 * 1024;
kestrel.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
});
// --- Réglages ------------------------------------------------------------------------------------
builder.Services.AddOptions<SiteOptions>().Bind(builder.Configuration.GetSection(SiteOptions.Section)).ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<SiteOptions>, SiteOptionsValidator>();
// Textes en UTF-8 lisible (é, à…) dans le HTML : les caractères restent encodés là où il le faut (<, >, ", &).
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.Configure<RouteOptions>(routes =>
{
routes.LowercaseUrls = true;
routes.AppendTrailingSlash = false;
});
// --- Lecture des fichiers publics du launcher ---------------------------------------------------
builder.Services.AddHttpClient(RemoteFetcher.HttpClientName, client =>
{
client.Timeout = Timeout.InfiniteTimeSpan; // délai géré par RemoteFetcher (réglable)
client.DefaultRequestHeaders.UserAgent.ParseAdd("LanisysSite/1.0");
})
.ConfigurePrimaryHttpMessageHandler(services => new SocketsHttpHandler
{
// Même règle que par défaut (certificat valide exigé), avec le détail d'un refus dans le journal.
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = TlsDiagnostics.Create(
services.GetRequiredService<ILoggerFactory>().CreateLogger("GameLauncher.Website.Tls"))
},
AllowAutoRedirect = false,
UseCookies = false,
UseProxy = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli | DecompressionMethods.Deflate,
ConnectTimeout = TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
MaxResponseHeadersLength = 64
});
builder.Services.AddSingleton<RemoteFetcher>();
builder.Services.AddSingleton<RemoteContentStore>();
builder.Services.AddHostedService<RemoteContentRefresher>();
builder.Services.AddSingleton<NewsService>();
// Aucun cookie ni formulaire : les clés de protection des données (utilisées seulement par l'anti-falsification
// d'ASP.NET Core) restent en mémoire, rien n'est écrit sur le disque du VPS.
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
// --- Pages ---------------------------------------------------------------------------------------
builder.Services.AddRazorPages(pages =>
{
pages.Conventions.ConfigureFilter(new Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryTokenAttribute());
});
// --- Limitation du nombre de requêtes par adresse IP (en plus de celle de nginx) ------------------
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "inconnue",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 300, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddHsts(hsts =>
{
hsts.MaxAge = TimeSpan.FromDays(365);
hsts.IncludeSubDomains = false; // les autres sous-domaines (launcher, git…) gèrent leur propre politique
});
var app = builder.Build();
// nginx (même machine) transmet l'adresse du visiteur et le protocole https.
var forwarded = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto };
app.UseForwardedHeaders(forwarded);
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/erreur");
app.UseHsts();
}
app.UseSiteSecurityHeaders();
app.UseRateLimiter();
app.UseStatusCodePagesWithReExecute("/erreur/{0}");
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
// Fichiers avec empreinte (?v=… ajouté par asp-append-version) : cache long.
var versioned = context.Context.Request.Query.ContainsKey("v");
context.Context.Response.Headers.CacheControl = versioned ? "public, max-age=31536000, immutable" : "public, max-age=86400";
}
});
app.UseRouting();
app.MapRazorPages();
// Contrôle de fonctionnement (installer-site.sh) : uniquement depuis la machine elle-même.
app.MapGet("/healthz", (HttpContext context) =>
context.Connection.RemoteIpAddress is { } ip && IPAddress.IsLoopback(ip) ? Results.Text("ok") : Results.NotFound());
app.MapGet("/robots.txt", (IOptionsMonitor<SiteOptions> options) =>
{
var text = new StringBuilder("User-agent: *\nAllow: /\n");
if (!string.IsNullOrWhiteSpace(options.CurrentValue.PublicUrl))
text.Append("Sitemap: ").Append(options.CurrentValue.PublicUrl).Append("/sitemap.xml\n");
return Results.Text(text.ToString(), "text/plain; charset=utf-8");
});
app.MapGet("/sitemap.xml", (IOptionsMonitor<SiteOptions> options, NewsService news) =>
Results.Text(Sitemap.Build(options.CurrentValue, news), "application/xml; charset=utf-8"));
app.Run();
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Development": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "http://127.0.0.1:5090/",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,62 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Mise en forme en français, sans dépendre des données de culture du système (le site est compilé en
/// « InvariantGlobalization », comme la Launcher API : rien à installer sur le VPS).
/// </summary>
public static class FrenchText
{
private static readonly string[] Months =
{
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
};
/// <summary>Fuseau des dates affichées (heure de Paris) ; UTC si le système ne le connaît pas.</summary>
private static readonly TimeZoneInfo Paris = FindParis();
/// <summary>« 1er novembre 2026 », « 10 novembre 2026 ».</summary>
public static string Date(DateOnly date) =>
$"{(date.Day == 1 ? "1er" : date.Day.ToString(System.Globalization.CultureInfo.InvariantCulture))} {Months[date.Month - 1]} {date.Year}";
/// <summary>Date (heure de Paris) d'un instant ; vide si la date est absente.</summary>
public static string Date(DateTimeOffset value) =>
value == default ? "" : Date(DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime));
/// <summary>Format ISO pour l'attribut datetime de &lt;time&gt;.</summary>
public static string IsoDate(DateTimeOffset value) =>
value == default ? "" : DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
public static string IsoDate(DateOnly value) => value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
/// <summary>« 74,2 Mo », « 1,29 Go » (unités binaires, comme le launcher).</summary>
public static string Size(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "octets", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
var format = unit == 0 ? "0" : value >= 100 ? "0" : value >= 10 ? "0.0" : "0.00";
return value.ToString(format, System.Globalization.CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static TimeZoneInfo FindParis()
{
foreach (var id in new[] { "Europe/Paris", "Romance Standard Time" })
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
}
}
return TimeZoneInfo.Utc;
}
}
@@ -0,0 +1,67 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Icônes de l'interface : tracés 24×24 repris de src\GameLauncher\Themes\Icons.xaml (Material Design Icons,
/// licence Apache 2.0), pour la même identité visuelle que le launcher ; les dernières (Menu…Windows) dans le même style.
/// </summary>
public static class IconPaths
{
public static readonly IReadOnlyDictionary<string, string> All = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Download"] = "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z",
["CloudDownload"] = "M17,13L12,18L7,13H10V9H14V13M19.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",
["Refresh"] = "M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z",
["Wrench"] = "M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z",
["ShieldCheck"] = "M10,17L6,13L7.41,11.58L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z",
["Folder"] = "M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z",
["Locate"] = "M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z",
["Delete"] = "M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z",
["CheckCircle"] = "M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z",
["Help"] = "M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z",
["History"] = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.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",
["Harddisk"] = "M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M12,4A6,6 0 0,0 6,10C6,13.31 8.69,16 12.1,16L11.22,13.77C10.95,13.29 11.11,12.68 11.59,12.4L12.45,11.9C12.93,11.63 13.54,11.79 13.82,12.27L15.74,14.69C17.12,13.59 18,11.9 18,10A6,6 0 0,0 12,4M12,9A1,1 0 0,1 13,10A1,1 0 0,1 12,11A1,1 0 0,1 11,10A1,1 0 0,1 12,9M7,18A1,1 0 0,0 6,19A1,1 0 0,0 7,20A1,1 0 0,0 8,19A1,1 0 0,0 7,18M12.09,13.27L14.58,19.58L17.17,18.08L12.95,12.77L12.09,13.27Z",
["Gamepad"] = "M7,6H17A6,6 0 0,1 23,12A6,6 0 0,1 17,18C15.22,18 13.63,17.23 12.53,16H11.47C10.37,17.23 8.78,18 7,18A6,6 0 0,1 1,12A6,6 0 0,1 7,6M6,9V11H4V13H6V15H8V13H10V11H8V9H6M15.5,12A1.5,1.5 0 0,0 14,13.5A1.5,1.5 0 0,0 15.5,15A1.5,1.5 0 0,0 17,13.5A1.5,1.5 0 0,0 15.5,12M18.5,9A1.5,1.5 0 0,0 17,10.5A1.5,1.5 0 0,0 18.5,12A1.5,1.5 0 0,0 20,10.5A1.5,1.5 0 0,0 18.5,9Z",
["Update"] = "M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z",
["Web"] = "M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z",
["ContentCopy"] = "M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z",
["Fingerprint"] = "M17.81,4.47C17.73,4.47 17.65,4.45 17.58,4.41C15.66,3.42 14,3 12,3C10.03,3 8.15,3.47 6.44,4.41C6.2,4.54 5.9,4.45 5.76,4.21C5.63,3.97 5.72,3.66 5.96,3.53C7.82,2.5 9.86,2 12,2C14.14,2 16,2.47 18.04,3.5C18.29,3.65 18.38,3.95 18.25,4.19C18.16,4.37 18,4.47 17.81,4.47M3.5,9.72C3.4,9.72 3.3,9.69 3.21,9.63C3,9.47 2.93,9.16 3.09,8.93C4.08,7.53 5.34,6.43 6.84,5.66C10,4.04 14,4.03 17.15,5.65C18.65,6.42 19.91,7.5 20.9,8.9C21.06,9.12 21,9.44 20.78,9.6C20.55,9.76 20.24,9.71 20.08,9.5C19.18,8.22 18.04,7.23 16.69,6.54C13.82,5.07 10.15,5.07 7.29,6.55C5.93,7.25 4.79,8.25 3.89,9.5C3.81,9.65 3.66,9.72 3.5,9.72M9.75,21.79C9.62,21.79 9.5,21.74 9.4,21.64C8.53,20.77 8.06,20.21 7.39,19C6.7,17.77 6.34,16.27 6.34,14.66C6.34,11.69 8.88,9.27 12,9.27C15.12,9.27 17.66,11.69 17.66,14.66A0.5,0.5 0 0,1 17.16,15.16A0.5,0.5 0 0,1 16.66,14.66C16.66,12.24 14.57,10.27 12,10.27C9.43,10.27 7.34,12.24 7.34,14.66C7.34,16.1 7.66,17.43 8.27,18.5C8.91,19.66 9.35,20.15 10.12,20.93C10.31,21.13 10.31,21.44 10.12,21.64C10,21.74 9.88,21.79 9.75,21.79M16.92,19.94C15.73,19.94 14.68,19.64 13.82,19.05C12.33,18.04 11.44,16.4 11.44,14.66A0.5,0.5 0 0,1 11.94,14.16A0.5,0.5 0 0,1 12.44,14.66C12.44,16.07 13.16,17.4 14.38,18.22C15.09,18.7 15.92,18.93 16.92,18.93C17.16,18.93 17.56,18.9 17.96,18.83C18.23,18.78 18.5,18.96 18.54,19.24C18.59,19.5 18.41,19.77 18.13,19.82C17.56,19.93 17.06,19.94 16.92,19.94M14.91,22C14.87,22 14.82,22 14.78,22C13.19,21.54 12.15,20.95 11.06,19.88C9.66,18.5 8.89,16.64 8.89,14.66C8.89,13.04 10.27,11.72 11.97,11.72C13.67,11.72 15.05,13.04 15.05,14.66C15.05,15.73 16,16.6 17.13,16.6C18.28,16.6 19.21,15.73 19.21,14.66C19.21,10.89 15.96,7.83 11.96,7.83C9.12,7.83 6.5,9.41 5.35,11.86C4.96,12.67 4.76,13.62 4.76,14.66C4.76,15.44 4.83,16.67 5.43,18.27C5.53,18.53 5.4,18.82 5.14,18.91C4.88,19 4.59,18.87 4.5,18.62C4,17.31 3.77,16 3.77,14.66C3.77,13.46 4,12.37 4.45,11.42C5.78,8.63 8.73,6.82 11.96,6.82C16.5,6.82 20.21,10.33 20.21,14.65C20.21,16.27 18.83,17.59 17.13,17.59C15.43,17.59 14.05,16.27 14.05,14.65C14.05,13.58 13.12,12.71 11.97,12.71C10.82,12.71 9.89,13.58 9.89,14.65C9.89,16.36 10.55,17.96 11.76,19.16C12.71,20.1 13.62,20.62 15.03,21C15.3,21.08 15.45,21.36 15.38,21.62C15.33,21.85 15.12,22 14.91,22Z",
["EmailOutline"] = "M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z",
["Forum"] = "M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z",
["Lock"] = "M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z",
["Fullscreen"] = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z",
["Lightbulb"] = "M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z",
["Bug"] = "M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z",
["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",
["Laptop"] = "M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z",
["Broom"] = "M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z",
["PlayCircleOutline"] = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z",
["ArrowLeft"] = "M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z",
["OpenInNew"] = "M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z",
["Play"] = "M8,5.14V19.14L19,12.14L8,5.14Z",
["Star"] = "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z",
["Tune"] = "M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z",
["FileDocument"] = "M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z",
["ClockOutline"] = "M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z",
["Server"] = "M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z",
["Menu"] = "M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z",
["Close"] = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z",
["Flashlight"] = "M9,10L6,5H18L15,10H9M18,4H6V2H18V4M9,22V11H15V22H9M12,13A1,1 0 0,0 11,14A1,1 0 0,0 12,15A1,1 0 0,0 13,14A1,1 0 0,0 12,13Z",
["Cassette"] = "M4,5A2,2 0 0,0 2,7V17A2,2 0 0,0 4,19H6L7,17H17L18,19H20A2,2 0 0,0 22,17V7A2,2 0 0,0 20,5H4M7,9.5A2,2 0 0,1 9,11.5A2,2 0 0,1 7,13.5A2,2 0 0,1 5,11.5A2,2 0 0,1 7,9.5M17,9.5A2,2 0 0,1 19,11.5A2,2 0 0,1 17,13.5A2,2 0 0,1 15,11.5A2,2 0 0,1 17,9.5M9,10.5H15V12.5H9V10.5Z",
["Ear"] = "M17,20C16.71,20 16.44,19.94 16.24,19.85C15.53,19.5 15.03,18.97 14.53,17.47C14,15.91 13.06,15.2 12.15,14.5C11.37,13.9 10.56,13.28 9.86,12C9.34,11.05 9,10 9,9C9,6.2 11.2,4 14,4C16.8,4 19,6.2 19,9H21C21,5.07 17.93,2 14,2C10.07,2 7,5.07 7,9C7,10.26 7.38,11.65 8.07,12.9C8.98,14.55 10.04,15.38 10.9,16.04C11.7,16.65 12.27,17.09 12.63,18.1C13.3,20.1 14.16,21.22 15.4,21.73C15.89,21.92 16.42,22 17,22A4,4 0 0,0 21,18H19A2,2 0 0,1 17,20M11.5,9A2.5,2.5 0 0,0 14,11.5A2.5,2.5 0 0,0 16.5,9A2.5,2.5 0 0,0 14,6.5A2.5,2.5 0 0,0 11.5,9Z",
["Medical"] = "M10,3L8,5V7H4A2,2 0 0,0 2,9V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V9A2,2 0 0,0 20,7H16V5L14,3H10M10,5H14V7H10V5M11,10H13V13H16V15H13V18H11V15H8V13H11V10Z",
["Puzzle"] = "M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z",
["Axe"] = "M12.5,6.2L6.2,12.5L4.5,10.8C2.8,9.2 2.8,6.6 4.5,4.9C6.1,3.3 8.7,3.3 10.3,4.9L12.5,6.2M13.9,7.6L21.7,15.4L20.3,16.8L18.2,14.7L16.8,16.1L18.9,18.2L17.5,19.6L9.7,11.8L13.9,7.6Z",
["Cctv"] = "M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.68,20 16.77,20H22V18.04H16.77V16.27C17.95,15.77 18.5,14.4 18,13.2C17.92,13 17.81,12.83 17.69,12.66L19.5,11.64C20.45,11.08 20.78,9.86 20.22,8.91L18.72,6.32C18.35,5.68 17.73,5 17.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L7.5,15.34L6.22,13.17Z",
["Windows"] = "M3,12V6.75L9,5.43V11.91L3,12M20,3V11.75L10,11.9V5.21L20,3M3,13L9,13.09V19.9L3,18.75V13M20,13.25V22L10,20.09V13.1L20,13.25Z",
};
}
/// <summary>Icône SVG en ligne (<c>@Html.Icon("Download")</c>) : tracés constants, aucune donnée de visiteur.</summary>
public static class IconHtmlExtensions
{
public static Microsoft.AspNetCore.Html.IHtmlContent Icon(this Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper _, string name) =>
IconPaths.All.TryGetValue(name, out var path)
? new Microsoft.AspNetCore.Html.HtmlString(
$"<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"{path}\"/></svg>")
: Microsoft.AspNetCore.Html.HtmlString.Empty;
}
@@ -0,0 +1,156 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Services;
/// <summary>Article de la page « Actualités » (fichier Content/News/&lt;slug&gt;.md).</summary>
public sealed class NewsArticle
{
public required string Slug { get; init; }
public required string Title { get; init; }
public DateOnly Date { get; init; }
public required string Summary { get; init; }
/// <summary>Thème de la page : celui du jeu concerné (« exovival »…), sinon « launcher ».</summary>
public string Theme { get; init; } = "launcher";
/// <summary>Jeu ou produit concerné, affiché sur la carte (« Exovival Zombie Reborn », « Launcher »…).</summary>
public string? Topic { get; init; }
/// <summary>Image de couverture (chemin /img/…), facultative.</summary>
public string? Image { get; init; }
public string? ImageAlt { get; init; }
/// <summary>Corps de l'article, déjà converti par <see cref="SafeMarkdown"/>.</summary>
public required string Html { get; init; }
public bool Draft { get; init; }
}
/// <summary>
/// Actualités : un fichier Markdown par article dans Content/News, versionné avec le site.
/// Lus une fois au démarrage (publier une actualité = publier le site) ; aucune entrée de visiteur
/// n'atteint le système de fichiers (le slug d'une adresse est cherché dans un dictionnaire en mémoire).
///
/// En-tête de chaque fichier (entre deux lignes « --- ») :
/// title: Titre de l'article
/// date: 2026-11-10
/// summary: Une ou deux phrases pour la liste des actualités
/// topic: Exovival Zombie Reborn (facultatif)
/// theme: exovival (facultatif : launcher, exovival, multi)
/// image: /img/exovival/vhs-gymnase.webp (facultatif)
/// imageAlt: Description de l'image (facultatif)
/// draft: true (facultatif : visible seulement en développement)
/// </summary>
public sealed partial class NewsService
{
public const string Folder = "Content/News";
private const int MaxArticles = 500;
private static readonly string[] Themes = { "launcher", "exovival", "multi" };
private readonly IReadOnlyList<NewsArticle> _articles;
private readonly Dictionary<string, NewsArticle> _bySlug;
public NewsService(IWebHostEnvironment environment, ILogger<NewsService> logger)
: this(Path.Combine(environment.ContentRootPath, Folder), environment.IsDevelopment(), logger)
{
}
internal NewsService(string folder, bool includeDrafts, ILogger logger)
{
var list = new List<NewsArticle>();
if (Directory.Exists(folder))
{
foreach (var file in Directory.EnumerateFiles(folder, "*.md", SearchOption.TopDirectoryOnly).Take(MaxArticles))
{
try
{
var article = Parse(Path.GetFileNameWithoutExtension(file), File.ReadAllText(file));
if (article is null)
{
logger.LogWarning("Actualité ignorée (nom de fichier ou en-tête incorrect) : {File}", Path.GetFileName(file));
continue;
}
if (article.Draft && !includeDrafts) continue;
list.Add(article);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Actualité illisible : {File}", Path.GetFileName(file));
}
}
}
_articles = list.OrderByDescending(a => a.Date).ThenBy(a => a.Slug, StringComparer.Ordinal).ToList();
_bySlug = _articles.ToDictionary(a => a.Slug, StringComparer.Ordinal);
logger.LogInformation("{Count} actualité(s) chargée(s).", _articles.Count);
}
public IReadOnlyList<NewsArticle> All => _articles;
public IEnumerable<NewsArticle> Latest(int count) => _articles.Take(count);
public NewsArticle? Find(string? slug) => slug is not null && _bySlug.TryGetValue(slug, out var a) ? a : null;
public static bool IsValidSlug(string? slug) => slug is not null && slug.Length <= 80 && SlugRegex().IsMatch(slug);
internal static NewsArticle? Parse(string slug, string text)
{
if (!IsValidSlug(slug)) return null;
text = text.Replace("\r\n", "\n");
if (text.StartsWith('')) text = text[1..];
if (!text.StartsWith("---\n", StringComparison.Ordinal)) return null;
var end = text.IndexOf("\n---", 4, StringComparison.Ordinal);
if (end < 0) return null;
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in text[4..end].Split('\n'))
{
var colon = raw.IndexOf(':');
if (colon <= 0) continue;
var value = raw[(colon + 1)..].Trim();
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') value = value[1..^1];
fields[raw[..colon].Trim()] = value;
}
var bodyStart = text.IndexOf('\n', end + 1);
var body = bodyStart < 0 ? "" : text[(bodyStart + 1)..];
if (!fields.TryGetValue("title", out var title) || string.IsNullOrWhiteSpace(title)) return null;
if (!fields.TryGetValue("date", out var dateText) ||
!DateOnly.TryParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return null;
fields.TryGetValue("summary", out var summary);
fields.TryGetValue("theme", out var theme);
fields.TryGetValue("topic", out var topic);
fields.TryGetValue("image", out var image);
fields.TryGetValue("imageAlt", out var imageAlt);
fields.TryGetValue("draft", out var draft);
return new NewsArticle
{
Slug = slug,
Title = Limit(title, 160),
Date = date,
Summary = string.IsNullOrWhiteSpace(summary) ? SafeMarkdown.ToPlainText(body, 220) : Limit(summary, 300),
Theme = theme is not null && Themes.Contains(theme) ? theme : "launcher",
Topic = string.IsNullOrWhiteSpace(topic) ? null : Limit(topic, 60),
Image = image is not null && SafeMarkdown.IsSafeImage(image) ? image : null,
ImageAlt = string.IsNullOrWhiteSpace(imageAlt) ? null : Limit(imageAlt, 200),
Html = SafeMarkdown.ToHtml(body),
Draft = string.Equals(draft, "true", StringComparison.OrdinalIgnoreCase)
};
}
private static string Limit(string value, int max)
{
value = new string(value.Where(c => !char.IsControl(c)).ToArray()).Trim();
return value.Length <= max ? value : value[..(max - 1)].TrimEnd() + "…";
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
}
@@ -0,0 +1,225 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Website.Services;
/// <summary>Dernière version du launcher, lue dans un launcher-update.json dont la signature est valide.</summary>
public sealed class LauncherSnapshot
{
public required string Version { get; init; }
public required string Product { get; init; }
public DateTimeOffset PublishedAt { get; init; }
public required Uri DownloadUrl { get; init; }
public required string FileName { get; init; }
public long Size { get; init; }
public required string Sha256 { get; init; }
public bool IsPriority { get; init; }
/// <summary>Notes de la dernière version seulement (sections réservées à l'équipe retirées).</summary>
public ChangelogEntry? LatestNotes { get; init; }
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Résumé d'un manifest.json de jeu : version et notes de version (la liste des fichiers n'est pas gardée).</summary>
public sealed class GameSnapshot
{
public required string Version { get; init; }
public DateTimeOffset ReleaseDate { get; init; }
public long DownloadSize { get; init; }
public long InstalledSize { get; init; }
public IReadOnlyList<ChangelogEntry> Changelog { get; init; } = Array.Empty<ChangelogEntry>();
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Contenu lu par erreur ou refusé : jamais affiché.</summary>
public sealed class RemoteContentException : Exception
{
public RemoteContentException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Lecture et contrôle des fichiers JSON du serveur du launcher. Fonctions pures (sans réseau) :
/// testées dans GameLauncher.Tests (WebsiteRemoteContentTests).
/// </summary>
public static class RemoteContentReader
{
/// <summary>
/// Vérifie launcher-update.json avec les clés publiques intégrées (même code que le launcher :
/// <see cref="UpdateSignature.ReadRelease"/>), puis contrôle l'adresse de l'exécutable : https, même
/// serveur que launcher-update.json (un fichier signé ne peut pas envoyer les visiteurs ailleurs).
/// </summary>
public static LauncherSnapshot ReadLauncher(byte[] json, Uri updateUrl, IReadOnlyCollection<UpdateSignature.TrustedKey> keys,
string? expectedProduct, IEnumerable<string> hiddenSections, DateTimeOffset now)
{
LauncherRelease release;
try
{
release = UpdateSignature.ReadRelease(DecodeUtf8(json), keys);
}
catch (UpdateSecurityException ex)
{
throw new RemoteContentException(ex.Message, ex);
}
if (!string.IsNullOrWhiteSpace(expectedProduct) &&
!string.Equals(release.Product, expectedProduct.Trim(), StringComparison.Ordinal))
throw new RemoteContentException($"produit inattendu « {release.Product} » (attendu : « {expectedProduct} »).");
if (!Uri.TryCreate(updateUrl, release.File, out var download) ||
download.Scheme != updateUrl.Scheme ||
!string.Equals(download.IdnHost, updateUrl.IdnHost, StringComparison.OrdinalIgnoreCase) ||
download.Port != updateUrl.Port ||
!string.IsNullOrEmpty(download.Query) || !string.IsNullOrEmpty(download.Fragment) || !string.IsNullOrEmpty(download.UserInfo))
throw new RemoteContentException($"adresse de l'exécutable refusée « {release.File} » (même serveur que launcher-update.json attendu).");
var fileName = Path.GetFileName(download.AbsolutePath);
if (!fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || fileName.Length > 100)
throw new RemoteContentException($"nom d'exécutable inattendu « {fileName} ».");
var version = VersionUtil.Display(release.ParsedVersion);
var notes = release.Changelog.FirstOrDefault(e => VersionUtil.TryParse(e.Version, out var v) && v == release.ParsedVersion)
?? release.Changelog.FirstOrDefault()
?? release.ToLegacyEntry();
return new LauncherSnapshot
{
Version = version,
Product = release.Product,
PublishedAt = release.PublishedAt,
DownloadUrl = download,
FileName = fileName,
Size = release.Size,
Sha256 = release.Sha256.ToLowerInvariant(),
IsPriority = release.IsPriorityRelease,
LatestNotes = notes is null ? null : WithoutSections(notes, hiddenSections),
CheckedAt = now
};
}
/// <summary>
/// Lit le résumé d'un manifest.json : version, dates, tailles et notes de version, bornées et nettoyées
/// comme dans le launcher (<see cref="ChangelogLimits.Normalize"/>). Le jeu doit être celui attendu.
/// </summary>
public static GameSnapshot ReadGame(byte[] json, string expectedGameId, DateTimeOffset now)
{
ManifestSummary? manifest;
try
{
manifest = JsonSerializer.Deserialize<ManifestSummary>(json, ManifestJson);
}
catch (JsonException ex)
{
throw new RemoteContentException("manifest illisible : " + ex.Message, ex);
}
if (manifest is null) throw new RemoteContentException("manifest vide.");
if (!string.IsNullOrWhiteSpace(expectedGameId) &&
!string.Equals(manifest.GameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
throw new RemoteContentException($"manifest d'un autre jeu (« {manifest.GameId} », attendu « {expectedGameId} »).");
if (!VersionUtil.TryParse(manifest.Version, out var parsed))
throw new RemoteContentException($"version illisible « {manifest.Version} ».");
long installed = 0;
foreach (var file in manifest.Files ?? new List<ManifestFileSize>())
if (file is not null && file.Size > 0) installed += file.Size;
return new GameSnapshot
{
Version = ChangelogLimits.Clean(manifest.Version, ChangelogLimits.MaxVersionLength) ?? VersionUtil.Display(parsed),
ReleaseDate = manifest.ReleaseDate,
DownloadSize = Math.Max(0, manifest.Archive?.Size ?? 0),
InstalledSize = installed,
Changelog = ChangelogLimits.Normalize(manifest.Changelog),
CheckedAt = now
};
}
/// <summary>Copie d'une entrée sans les sections dont le titre figure dans <paramref name="hidden"/>.</summary>
public static ChangelogEntry WithoutSections(ChangelogEntry entry, IEnumerable<string> hidden)
{
var keys = hidden.Select(Fold).Where(k => k.Length > 0).ToHashSet(StringComparer.Ordinal);
if (keys.Count == 0) return entry;
return new ChangelogEntry
{
Version = entry.Version,
Date = entry.Date,
Title = entry.Title,
Subtitle = entry.Subtitle,
Introduction = entry.Introduction.ToList(),
Changes = entry.Changes.ToList(),
Sections = entry.Sections.Where(s => s.Title is null || !keys.Contains(Fold(s.Title))).ToList()
};
}
/// <summary>Clé de comparaison : sans accents, sans casse, espaces réduits.</summary>
internal static string Fold(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return "";
var decomposed = text.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(decomposed.Length);
foreach (var c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
sb.Append(char.IsWhiteSpace(c) ? ' ' : char.ToUpperInvariant(c));
}
return string.Join(' ', sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>Clés publiques intégrées à l'exécutable du site (même fichier que le launcher).</summary>
public static IReadOnlyList<UpdateSignature.TrustedKey> LoadEmbeddedKeys()
{
using var stream = typeof(RemoteContentReader).Assembly.GetManifestResourceStream("GameLauncher.Website.update-signing.pub")
?? throw new InvalidOperationException("Clé publique de mise à jour absente de l'exécutable.");
using var reader = new StreamReader(stream, Encoding.UTF8);
try
{
return UpdateSignature.ParsePublicKeys(reader.ReadToEnd());
}
catch (CryptographicException ex)
{
throw new InvalidOperationException("Clé publique de mise à jour illisible.", ex);
}
}
private static string DecodeUtf8(byte[] bytes)
{
// BOM toléré (fichier édité à la main), comme File.ReadAllText.
var span = bytes.AsSpan();
if (span.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) span = span[3..];
return Encoding.UTF8.GetString(span);
}
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
private sealed class ManifestSummary
{
public string? GameId { get; set; }
public string? Version { get; set; }
public DateTimeOffset ReleaseDate { get; set; }
public ArchiveSize? Archive { get; set; }
public List<ManifestFileSize>? Files { get; set; }
public List<ChangelogEntry>? Changelog { get; set; }
}
private sealed class ArchiveSize
{
public long Size { get; set; }
}
private sealed class ManifestFileSize
{
public long Size { get; set; }
}
}
@@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Website.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Services;
/// <summary>
/// Dernières données lues sur le serveur du launcher, servies aux pages. Les pages ne déclenchent
/// jamais de requête sortante : seul <see cref="RemoteContentRefresher"/> lit les fichiers, en arrière-plan.
/// </summary>
public sealed class RemoteContentStore
{
private volatile LauncherSnapshot? _launcher;
private readonly ConcurrentDictionary<string, GameSnapshot> _games = new(StringComparer.Ordinal);
public LauncherSnapshot? Launcher => _launcher;
/// <summary>Faux tant que la première lecture n'est pas terminée (message « chargement » plutôt qu'« indisponible »).</summary>
public bool FirstRefreshDone { get; private set; }
public GameSnapshot? GetGame(string slug) => _games.TryGetValue(slug, out var game) ? game : null;
internal void SetLauncher(LauncherSnapshot? snapshot) => _launcher = snapshot;
internal void SetGame(string slug, GameSnapshot? snapshot)
{
if (snapshot is null) _games.TryRemove(slug, out _);
else _games[slug] = snapshot;
}
internal void MarkFirstRefreshDone() => FirstRefreshDone = true;
}
/// <summary>
/// Relit launcher-update.json et les manifest.json au démarrage puis toutes les N minutes.
///
/// Politique d'erreur :
/// • serveur injoignable, délai dépassé : la dernière version valide reste affichée ;
/// • fichier refusé (signature invalide, produit ou serveur inattendu, manifest d'un autre jeu…) :
/// l'information est retirée du site (rien plutôt qu'un lien douteux) et l'erreur est inscrite au journal.
/// </summary>
public sealed class RemoteContentRefresher : BackgroundService
{
private readonly RemoteFetcher _fetcher;
private readonly RemoteContentStore _store;
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly ILogger<RemoteContentRefresher> _logger;
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _keys;
private readonly Dictionary<string, (string? ETag, string? LastModified)> _validators = new();
/// <summary>launcher-update.json : même limite que le launcher.</summary>
private const long MaxLauncherBytes = UpdateSignature.MaxEnvelopeLength;
public RemoteContentRefresher(RemoteFetcher fetcher, RemoteContentStore store, IOptionsMonitor<SiteOptions> options,
ILogger<RemoteContentRefresher> logger)
{
_fetcher = fetcher;
_store = store;
_options = options;
_logger = logger;
_keys = RemoteContentReader.LoadEmbeddedKeys();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Clés publiques du launcher intégrées : {Keys}.", string.Join(", ", _keys.Select(k => k.KeyId)));
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RefreshAllAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Lecture des fichiers du launcher : erreur inattendue.");
}
finally
{
_store.MarkFirstRefreshDone();
}
var minutes = Math.Clamp(_options.CurrentValue.Remote.RefreshMinutes, 1, 24 * 60);
// Petit décalage aléatoire : pas de rafale à heure fixe sur le serveur du launcher.
var delay = TimeSpan.FromMinutes(minutes) + TimeSpan.FromSeconds(Random.Shared.Next(0, 30));
try
{
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
internal async Task RefreshAllAsync(CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
await RefreshLauncherAsync(options, cancellationToken);
foreach (var game in options.Games.Where(g => !string.IsNullOrWhiteSpace(g.ManifestUrl)))
await RefreshGameAsync(game, options, cancellationToken);
}
private async Task RefreshLauncherAsync(SiteOptions options, CancellationToken cancellationToken)
{
var url = options.Launcher.UpdateUrl;
if (string.IsNullOrWhiteSpace(url))
{
_store.SetLauncher(null);
return;
}
if (!_fetcher.IsAllowedUrl(url, out var uri))
{
_logger.LogError("Site:Launcher:UpdateUrl refusée (https obligatoire) : {Url}", url);
_store.SetLauncher(null);
return;
}
var result = await FetchAsync(url, MaxLauncherBytes, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning("launcher-update.json non lu ({Error}) : {State}.", result.Error,
_store.Launcher is null ? "téléchargement indisponible" : "version précédente conservée");
return;
}
if (result.NotModified) return;
try
{
var snapshot = RemoteContentReader.ReadLauncher(result.Content!, uri, _keys, options.Launcher.Product,
options.Launcher.HiddenSections, DateTimeOffset.UtcNow);
var previous = _store.Launcher;
_store.SetLauncher(snapshot);
if (previous?.Version != snapshot.Version)
_logger.LogInformation("Launcher {Version} (produit « {Product} », signature valide) : {Url}",
snapshot.Version, snapshot.Product, snapshot.DownloadUrl);
}
catch (RemoteContentException ex)
{
ForgetValidators(url);
_store.SetLauncher(null);
_logger.LogError("launcher-update.json REFUSÉ, téléchargement retiré du site : {Reason}", ex.Message);
}
}
private async Task RefreshGameAsync(GameOptions game, SiteOptions options, CancellationToken cancellationToken)
{
var max = (long)Math.Clamp(options.Remote.MaxManifestMegabytes, 1, 256) * 1024 * 1024;
var result = await FetchAsync(game.ManifestUrl, max, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning("Manifest de « {Game} » non lu ({Error}).", game.Name, result.Error);
return;
}
if (result.NotModified) return;
try
{
var snapshot = RemoteContentReader.ReadGame(result.Content!, game.GameId, DateTimeOffset.UtcNow);
var previous = _store.GetGame(game.Slug);
_store.SetGame(game.Slug, snapshot);
if (previous?.Version != snapshot.Version)
_logger.LogInformation("{Game} : version {Version}, {Count} notes de version.", game.Name, snapshot.Version, snapshot.Changelog.Count);
}
catch (RemoteContentException ex)
{
ForgetValidators(game.ManifestUrl);
_store.SetGame(game.Slug, null);
_logger.LogError("Manifest de « {Game} » REFUSÉ : {Reason}", game.Name, ex.Message);
}
}
/// <summary>Lecture conditionnelle : ETag / Last-Modified de la dernière lecture réussie de cette adresse.</summary>
private async Task<FetchResult> FetchAsync(string url, long maxBytes, CancellationToken cancellationToken)
{
_validators.TryGetValue(url, out var validators);
var result = await _fetcher.FetchAsync(url, maxBytes, validators.ETag, validators.LastModified, cancellationToken);
if (result.Ok && !result.NotModified) _validators[url] = (result.ETag, result.LastModified);
return result;
}
private void ForgetValidators(string url) => _validators.Remove(url);
}
@@ -0,0 +1,134 @@
using System.Net.Http.Headers;
using System.Net;
using GameLauncher.Website.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Services;
/// <summary>Résultat d'une lecture : contenu, « inchangé » (304) ou erreur.</summary>
public sealed record FetchResult(byte[]? Content, bool NotModified, string? ETag, string? LastModified, string? Error)
{
public bool Ok => Error is null;
}
/// <summary>
/// Lecture des fichiers JSON publics (launcher-update.json, manifest.json) depuis le serveur du launcher.
///
/// • Uniquement les adresses configurées (pas d'adresse venant d'un visiteur : aucune requête sortante
/// n'est déclenchée par une page), en https (http://localhost seulement en développement).
/// • Aucune redirection suivie, taille bornée AVANT et PENDANT la lecture (y compris après décompression),
/// délai borné.
/// • Requête conditionnelle (ETag / Last-Modified) : un manifest inchangé n'est pas retéléchargé.
/// </summary>
public sealed class RemoteFetcher
{
public const string HttpClientName = "remote-json";
private readonly IHttpClientFactory _factory;
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly IHostEnvironment _environment;
public RemoteFetcher(IHttpClientFactory factory, IOptionsMonitor<SiteOptions> options, IHostEnvironment environment)
{
_factory = factory;
_options = options;
_environment = environment;
}
/// <summary>Adresse acceptée pour une lecture (https, ou http://localhost en développement).</summary>
public bool IsAllowedUrl(string? url, out Uri uri) =>
IsAllowedUrl(url, AllowLoopbackHttp, out uri);
private bool AllowLoopbackHttp => _environment.IsDevelopment() && _options.CurrentValue.Remote.AllowLoopbackHttp;
internal static bool IsAllowedUrl(string? url, bool allowLoopbackHttp, out Uri uri)
{
uri = null!;
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed)) return false;
if (!string.IsNullOrEmpty(parsed.UserInfo) || !string.IsNullOrEmpty(parsed.Fragment)) return false;
var ok = parsed.Scheme == Uri.UriSchemeHttps ||
(allowLoopbackHttp && parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback);
if (!ok) return false;
uri = parsed;
return true;
}
public async Task<FetchResult> FetchAsync(string url, long maxBytes, string? etag, string? lastModified, CancellationToken cancellationToken)
{
if (!IsAllowedUrl(url, out var uri))
return new FetchResult(null, false, null, null, "adresse refusée (https obligatoire)");
var timeout = TimeSpan.FromSeconds(Math.Clamp(_options.CurrentValue.Remote.TimeoutSeconds, 3, 120));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrEmpty(etag) && EntityTagHeaderValue.TryParse(etag, out var tag))
request.Headers.IfNoneMatch.Add(tag);
else if (!string.IsNullOrEmpty(lastModified) && DateTimeOffset.TryParse(lastModified, out var since))
request.Headers.IfModifiedSince = since;
try
{
var client = _factory.CreateClient(HttpClientName);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (response.StatusCode == HttpStatusCode.NotModified)
return new FetchResult(null, true, etag, lastModified, null);
if (!response.IsSuccessStatusCode)
return new FetchResult(null, false, null, null, $"réponse HTTP {(int)response.StatusCode}");
if (response.Content.Headers.ContentLength is { } announced && announced > maxBytes)
return new FetchResult(null, false, null, null, $"fichier trop volumineux ({announced} octets)");
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
var content = await ReadBoundedAsync(stream, maxBytes, cts.Token);
if (content is null)
return new FetchResult(null, false, null, null, $"fichier trop volumineux (plus de {maxBytes} octets)");
return new FetchResult(content, false, response.Headers.ETag?.ToString(),
response.Content.Headers.LastModified?.ToString("R"), null);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new FetchResult(null, false, null, null, $"délai dépassé ({timeout.TotalSeconds:0} s)");
}
catch (HttpRequestException ex)
{
return new FetchResult(null, false, null, null, Describe(ex));
}
}
/// <summary>
/// Cause lisible d'un échec réseau : message de chaque exception imbriquée (certificat refusé, nom
/// inconnu, connexion refusée…), au lieu du seul « The SSL connection could not be established ».
/// </summary>
internal static string Describe(HttpRequestException ex)
{
var parts = new List<string>();
for (Exception? e = ex; e is not null && parts.Count < 5; e = e.InnerException)
if (!string.IsNullOrWhiteSpace(e.Message) && !parts.Contains(e.Message)) parts.Add(e.Message);
var prefix = ex.InnerException is System.Security.Authentication.AuthenticationException
? "certificat HTTPS refusé (détail dans la ligne « Certificat … REFUSÉ » du journal)"
: "serveur injoignable";
return prefix + " : " + string.Join(" → ", parts);
}
/// <summary>Lit au plus <paramref name="maxBytes"/> octets ; null si le flux en contient davantage.</summary>
internal static async Task<byte[]?> ReadBoundedAsync(Stream stream, long maxBytes, CancellationToken cancellationToken)
{
using var buffer = new MemoryStream();
var chunk = new byte[81920];
long total = 0;
int read;
while ((read = await stream.ReadAsync(chunk, cancellationToken)) > 0)
{
total += read;
if (total > maxBytes) return null;
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
}
@@ -0,0 +1,325 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
namespace GameLauncher.Website.Services;
/// <summary>
/// Liseuse Markdown des actualités : sous-ensemble volontairement réduit, sans dépendance tierce.
///
/// Sécurité « par construction » : tout le texte est encodé en HTML ; seules les balises produites par
/// ce fichier existent dans le résultat. Le HTML brut écrit dans un article est affiché tel quel (encodé),
/// jamais interprété. Liens : http(s), mailto et chemins du site uniquement (javascript:, data:… refusés).
/// Images : fichiers du site uniquement (/img/…), comme l'impose aussi la politique de sécurité (CSP).
///
/// Syntaxe prise en charge :
/// ## Titre, ### Sous-titre, #### Petit titre (« # » devient un ## : le titre de l'article est déjà le h1)
/// paragraphes (lignes consécutives), ligne vide = nouveau paragraphe
/// - liste / * liste, 1. liste numérotée (un niveau, + sous-niveau indenté de 2 espaces ou plus)
/// > citation
/// ``` bloc de code ```
/// --- (ligne seule) : séparateur
/// ![texte alternatif](/img/…) seule sur sa ligne : image en pleine largeur (légende = texte alternatif)
/// **gras**, *italique* ou _italique_, `code`, [lien](https://…)
/// </summary>
public static partial class SafeMarkdown
{
private static readonly HtmlEncoder Encoder = HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All);
/// <summary>Taille maximale d'un article (caractères) : protège le démarrage contre un fichier anormal.</summary>
public const int MaxLength = 200_000;
public static string ToHtml(string? markdown)
{
if (string.IsNullOrEmpty(markdown)) return "";
if (markdown.Length > MaxLength) markdown = markdown[..MaxLength];
var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var html = new StringBuilder();
var paragraph = new List<string>();
var i = 0;
void FlushParagraph()
{
if (paragraph.Count == 0) return;
html.Append("<p>").Append(Inline(string.Join(" ", paragraph.Select(p => p.Trim())))).Append("</p>\n");
paragraph.Clear();
}
while (i < lines.Length)
{
var line = StripControl(lines[i]);
var trimmed = line.Trim();
if (trimmed.Length == 0)
{
FlushParagraph();
i++;
continue;
}
// Bloc de code
if (trimmed.StartsWith("```", StringComparison.Ordinal))
{
FlushParagraph();
var code = new List<string>();
i++;
while (i < lines.Length && !lines[i].Trim().StartsWith("```", StringComparison.Ordinal))
code.Add(StripControl(lines[i++]));
i++; // fermeture (ou fin du fichier)
html.Append("<pre><code>").Append(Encoder.Encode(string.Join("\n", code))).Append("</code></pre>\n");
continue;
}
// Titres
var heading = HeadingRegex().Match(trimmed);
if (heading.Success)
{
FlushParagraph();
var level = Math.Clamp(heading.Groups[1].Value.Length + (heading.Groups[1].Value.Length == 1 ? 1 : 0), 2, 4);
html.Append($"<h{level}>").Append(Inline(heading.Groups[2].Value.Trim().TrimEnd('#').TrimEnd())).Append($"</h{level}>\n");
i++;
continue;
}
// Séparateur
if (RuleRegex().IsMatch(trimmed))
{
FlushParagraph();
html.Append("<hr>\n");
i++;
continue;
}
// Image seule sur sa ligne
var image = ImageLineRegex().Match(trimmed);
if (image.Success)
{
FlushParagraph();
var alt = image.Groups[1].Value.Trim();
var src = image.Groups[2].Value.Trim();
if (IsSafeImage(src))
{
html.Append("<figure><img src=\"").Append(Encoder.Encode(src)).Append("\" alt=\"").Append(Encoder.Encode(alt))
.Append("\" loading=\"lazy\" decoding=\"async\">");
if (alt.Length > 0) html.Append("<figcaption>").Append(Encoder.Encode(alt)).Append("</figcaption>");
html.Append("</figure>\n");
}
else
{
html.Append("<p>").Append(Encoder.Encode(alt)).Append("</p>\n");
}
i++;
continue;
}
// Citation
if (trimmed.StartsWith('>'))
{
FlushParagraph();
var quote = new List<string>();
while (i < lines.Length && StripControl(lines[i]).TrimStart().StartsWith('>'))
quote.Add(StripControl(lines[i++]).TrimStart()[1..].Trim());
html.Append("<blockquote>");
foreach (var block in SplitBlocks(quote))
html.Append("<p>").Append(Inline(string.Join(" ", block))).Append("</p>");
html.Append("</blockquote>\n");
continue;
}
// Listes
if (ListItemRegex().IsMatch(line))
{
FlushParagraph();
i = RenderList(lines, i, html);
continue;
}
paragraph.Add(line);
i++;
}
FlushParagraph();
return html.ToString();
}
/// <summary>Texte brut (aperçu, description Open Graph) : marques Markdown retirées.</summary>
public static string ToPlainText(string? markdown, int maxLength)
{
if (string.IsNullOrWhiteSpace(markdown)) return "";
var text = LinkRegex().Replace(markdown, m => m.Groups[2].Value);
text = Regex.Replace(text, @"[*_`#>]+", "");
text = Regex.Replace(StripControl(text), @"\s+", " ").Trim();
return text.Length <= maxLength ? text : text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
}
private static int RenderList(string[] lines, int i, StringBuilder html)
{
var ordered = char.IsDigit(lines[i].TrimStart()[0]);
html.Append(ordered ? "<ol>" : "<ul>");
var open = false;
while (i < lines.Length)
{
var line = StripControl(lines[i]);
if (line.Trim().Length == 0)
{
// Une ligne vide termine la liste, sauf si la ligne suivante est un élément du même type de liste.
var next = i + 1 < lines.Length ? ListItemRegex().Match(StripControl(lines[i + 1])) : Match.Empty;
if (next.Success && char.IsDigit(next.Groups[2].Value[0]) == ordered) { i++; continue; }
break;
}
var match = ListItemRegex().Match(line);
if (!match.Success)
{
if (open && line.StartsWith(" ", StringComparison.Ordinal))
{
html.Append(' ').Append(Inline(line.Trim())); // suite de l'élément
i++;
continue;
}
break;
}
var indent = match.Groups[1].Value.Length;
if (indent < 2 && char.IsDigit(match.Groups[2].Value[0]) != ordered) break; // autre type de liste
if (indent >= 2 && open)
{
// Sous-liste : éléments indentés consécutifs
var subOrdered = char.IsDigit(match.Groups[2].Value[0]);
html.Append(subOrdered ? "<ol>" : "<ul>");
while (i < lines.Length)
{
var sub = ListItemRegex().Match(StripControl(lines[i]));
if (!sub.Success || sub.Groups[1].Value.Length < 2) break;
html.Append("<li>").Append(Inline(sub.Groups[3].Value.Trim())).Append("</li>");
i++;
}
html.Append(subOrdered ? "</ol>" : "</ul>");
continue;
}
if (open) html.Append("</li>");
html.Append("<li>").Append(Inline(match.Groups[3].Value.Trim()));
open = true;
i++;
}
if (open) html.Append("</li>");
html.Append(ordered ? "</ol>\n" : "</ul>\n");
return i;
}
private static IEnumerable<List<string>> SplitBlocks(List<string> lines)
{
var block = new List<string>();
foreach (var line in lines)
{
if (line.Length == 0)
{
if (block.Count > 0) yield return block;
block = new List<string>();
}
else block.Add(line);
}
if (block.Count > 0) yield return block;
}
/// <summary>Mise en forme en ligne. Chaque morceau de texte est encodé ; seules nos balises sont produites.</summary>
internal static string Inline(string text)
{
var sb = new StringBuilder();
var position = 0;
foreach (Match m in InlineRegex().Matches(text))
{
sb.Append(Encoder.Encode(text[position..m.Index]));
position = m.Index + m.Length;
if (m.Groups["code"].Success)
{
sb.Append("<code>").Append(Encoder.Encode(m.Groups["code"].Value)).Append("</code>");
}
else if (m.Groups["ltext"].Success)
{
var url = m.Groups["lurl"].Value.Trim();
var label = Inline(m.Groups["ltext"].Value);
if (TryClassifyLink(url, out var external))
{
sb.Append("<a href=\"").Append(Encoder.Encode(url)).Append('"');
if (external) sb.Append(" rel=\"noopener noreferrer\"");
sb.Append('>').Append(label).Append("</a>");
}
else
{
sb.Append(label); // lien refusé : le texte reste, sans lien
}
}
else if (m.Groups["bold"].Success)
{
sb.Append("<strong>").Append(Inline(m.Groups["bold"].Value)).Append("</strong>");
}
else if (m.Groups["em"].Success)
{
sb.Append("<em>").Append(Inline(m.Groups["em"].Value)).Append("</em>");
}
else if (m.Groups["em2"].Success)
{
sb.Append("<em>").Append(Inline(m.Groups["em2"].Value)).Append("</em>");
}
}
sb.Append(Encoder.Encode(text[position..]));
return sb.ToString();
}
/// <summary>Lien accepté : chemin du site (« /… » sans « // »), ancre, https, http ou mailto.</summary>
internal static bool TryClassifyLink(string url, out bool external)
{
external = false;
if (url.Length == 0 || url.Length > 2000 || url.Any(c => char.IsControl(c) || char.IsWhiteSpace(c))) return false;
if (url.StartsWith('#')) return true;
if (url.StartsWith('/')) return !url.StartsWith("//", StringComparison.Ordinal) && !url.Contains('\\');
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme is not ("https" or "http" or "mailto")) return false;
external = uri.Scheme != "mailto";
return true;
}
/// <summary>Image acceptée : fichier du site, sous /img/, extension d'image.</summary>
internal static bool IsSafeImage(string src) =>
src.StartsWith("/img/", StringComparison.Ordinal) && !src.Contains("..", StringComparison.Ordinal) &&
!src.Contains('\\') && !src.Contains("//", StringComparison.Ordinal) &&
SafeImageRegex().IsMatch(src);
private static string StripControl(string value)
{
if (!value.Any(c => (char.IsControl(c) && c != '\t') || IsBidi(c))) return value.Replace('\t', ' ');
var sb = new StringBuilder(value.Length);
foreach (var c in value)
{
if (c == '\t') sb.Append(' ');
else if (!char.IsControl(c) && !IsBidi(c)) sb.Append(c);
}
return sb.ToString();
}
private static bool IsBidi(char c) => c is '‎' or '‏' or (>= '‪' and <= '‮') or (>= '⁦' and <= '⁩');
[GeneratedRegex(@"^(#{1,4})\s+(.+)$")]
private static partial Regex HeadingRegex();
[GeneratedRegex(@"^(-{3,}|\*{3,}|_{3,})$")]
private static partial Regex RuleRegex();
[GeneratedRegex(@"^!\[([^\]]*)\]\(([^)\s]+)\)$")]
private static partial Regex ImageLineRegex();
[GeneratedRegex(@"^( *)([-*]|\d{1,3}\.)\s+(.*)$")]
private static partial Regex ListItemRegex();
[GeneratedRegex(@"\[([^\]]+)\]\(([^)\s]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"^/img/[A-Za-z0-9/_\-.]+\.(webp|png|jpe?g|svg|avif)$")]
private static partial Regex SafeImageRegex();
[GeneratedRegex(@"`(?<code>[^`]+)`|\[(?<ltext>[^\]]+)\]\((?<lurl>[^)\s]+)\)|\*\*(?<bold>.+?)\*\*|(?<![\w*])\*(?<em>[^\s*](?:.*?[^\s*])?)\*(?![\w*])|(?<!\w)_(?<em2>[^\s_](?:.*?[^\s_])?)_(?!\w)")]
private static partial Regex InlineRegex();
}
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Site": {
"PublicUrl": "http://127.0.0.1:5090",
"Remote": {
"RefreshMinutes": 2,
// Développement uniquement : autorise des fichiers de test servis en http://localhost.
"AllowLoopbackHttp": true
}
}
}
@@ -0,0 +1,84 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning",
// Clés de protection des données volontairement en mémoire (Program.cs) : avertissements sans objet.
"Microsoft.AspNetCore.DataProtection": "Error"
}
},
// Derrière nginx, sur la même machine (website\deploy\nginx-site.conf).
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://127.0.0.1:5090" }
}
},
// Réglages du site. Sur le VPS, les valeurs propres au serveur vont dans appsettings.Production.json
// (conservé par installer-site.sh d'une mise à jour à l'autre) : voir website\deploy\LISEZMOI.md.
"Site": {
"PublicUrl": "https://www.lanisys.fr",
"StudioName": "LanisysGames",
// Bouton « Nous contacter » : simple lien mailto (la messagerie du visiteur s'ouvre). Vide = masqué.
"ContactEmail": "contact@lanisys.fr",
"Launcher": {
"Name": "Launcher LanisysGames",
// Fichier SIGNÉ publié par tools\publish-launcher-update.bat : version, exe, notes de version.
"UpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
// « launcherId » attendu (ligne « Produit » de LauncherPublisher verify). Vide = non contrôlé :
// le produit lu est écrit dans le journal du site au premier démarrage, à recopier ici.
"Product": "",
// Historique complet des versions (page « Publications » de Gitea, dépôt public). Vide = lien masqué.
"ReleasesUrl": "https://git.lanisys.fr/lanisys/GamesLauncher-v2/releases",
// Sections des notes du launcher qui ne concernent pas les joueurs (non affichées sur le site).
"HiddenSections": [ "Serveur", "Développement", "Administration", "Menu d'administration" ]
},
"Games": [
{
"Slug": "exovival-zombie-reborn",
"Name": "Exovival Zombie Reborn",
"Theme": "exovival",
"Status": "available",
"HasDedicatedPage": true,
"GameId": "EZR",
"ManifestUrl": "https://launcher.lanisys.fr/exovivalzombie/manifest.json",
"Tagline": "La ville a été évacuée en pleine nuit. À l'appel, une élève manquait : Léa, votre fille. Vous retournez seul dans l'école.",
"CardImage": "/img/exovival/couloir.webp",
"CardImageAlt": "Couloir de l'école dans le noir, éclairé par le bloc de secours vert d'une sortie."
},
{
"Slug": "projet-multijoueur",
"Name": "Projet multijoueur",
"Theme": "multi",
"Status": "upcoming",
"Tagline": "Notre prochain jeu se jouera à plusieurs. Il est en préparation.",
"Teaser": "Nous travaillons sur un jeu multijoueur. Il n'a pas encore de nom, et nous préférons vous le montrer quand il sera jouable plutôt que de le promettre trop tôt. Les premières nouvelles arriveront dans les actualités du site et dans le launcher."
}
],
"Remote": {
"RefreshMinutes": 10,
"TimeoutSeconds": 20,
"MaxManifestMegabytes": 32
},
// Mentions légales (obligatoires en France, loi pour la confiance dans l'économie numérique).
// À COMPLÉTER dans appsettings.Production.json sur le VPS : voir website\deploy\LISEZMOI.md, étape 6.
"Legal": {
"Publisher": "LanisysGames",
"PublisherDetails": [],
"Director": "",
"Host": [
"IONOS SARL",
"7 place de la Gare, BP 70109, 57201 Sarreguemines Cedex, France",
"Téléphone : 09 70 80 89 11"
],
"LogRetentionDays": 14
}
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ThinItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraLight.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraLightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Light.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-LightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Regular.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Italic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Medium.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-MediumItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-SemiBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-SemiBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Bold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-BoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-ExtraBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-Black.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) BarlowCondensed-BlackItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ThinItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraLight.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraLightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Light.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-LightItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Regular.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Italic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Medium.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-MediumItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-SemiBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-SemiBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Bold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-BoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraBold.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-ExtraBoldItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-Black.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) Barlow-BlackItalic.ttf: Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2019 The Sora Project Authors (https://github.com/sora-xor/sora-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,80 @@
// Site vitrine LanisysGames : petits comportements, sans dépendance.
// Chargé depuis le site uniquement (politique de sécurité : aucun script en ligne ni externe).
(function () {
"use strict";
document.documentElement.classList.add("js");
// --- Menu sur mobile -----------------------------------------------------------------------------
var header = document.querySelector(".site-header");
var toggle = document.querySelector(".nav-toggle");
if (header && toggle) {
toggle.addEventListener("click", function () {
var open = header.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && header.classList.contains("is-open")) {
header.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.focus();
}
});
}
// --- Copier l'empreinte SHA-256 ------------------------------------------------------------------
document.querySelectorAll("[data-copy]").forEach(function (button) {
button.hidden = !(navigator.clipboard && window.isSecureContext);
button.addEventListener("click", function () {
var target = document.getElementById(button.getAttribute("data-copy"));
if (!target) return;
navigator.clipboard.writeText(target.textContent.trim()).then(function () {
var label = button.textContent;
button.textContent = "Copiée";
setTimeout(function () { button.textContent = label; }, 1600);
});
});
});
// --- Exovival : lampe UV qui révèle l'encre invisible ---------------------------------------------
var hero = document.querySelector("[data-uv-hero]");
var ink = hero && hero.querySelector(".exo-uv");
if (!hero || !ink) return;
var angle = (-4 * Math.PI) / 180; // rotation de l'encre (voir .exo-uv dans site.css)
var frame = 0;
var last = null;
function place(clientX, clientY) {
last = { x: clientX, y: clientY };
if (frame) return;
frame = requestAnimationFrame(function () {
frame = 0;
var h = hero.getBoundingClientRect();
hero.style.setProperty("--x", (last.x - h.left) + "px");
hero.style.setProperty("--y", (last.y - h.top) + "px");
// Coordonnées dans le repère de l'encre (élément tourné) pour le masque.
var r = ink.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
var dx = last.x - cx;
var dy = last.y - cy;
var lx = dx * Math.cos(-angle) - dy * Math.sin(-angle);
var ly = dx * Math.sin(-angle) + dy * Math.cos(-angle);
ink.style.setProperty("--uv-x", (lx + ink.offsetWidth / 2) + "px");
ink.style.setProperty("--uv-y", (ly + ink.offsetHeight / 2) + "px");
});
}
// Position de départ : le faisceau éclaire le début du message, pour montrer qu'il y a quelque chose.
function rest() {
var r = ink.getBoundingClientRect();
place(r.left + r.width * 0.28, r.top + r.height * 0.5);
}
hero.addEventListener("pointermove", function (e) { place(e.clientX, e.clientY); });
hero.addEventListener("pointerdown", function (e) { place(e.clientX, e.clientY); });
window.addEventListener("resize", rest);
if (document.readyState === "complete") rest(); else window.addEventListener("load", rest);
rest();
})();
+270
View File
@@ -0,0 +1,270 @@
# Site vitrine — installation sur le VPS
Le site vitrine (`website/GameLauncher.Website`, ASP.NET Core Razor Pages) présente les jeux et le
launcher, propose le téléchargement du launcher, les notes de version et les actualités.
* **Isolé de la Launcher API** : il ne l'appelle jamais. Il lit seulement, côté serveur, en arrière-plan
et toutes les 10 minutes, deux fichiers publics du serveur du launcher :
`launcher/launcher-update.json` (version, exécutable, notes du launcher) et le `manifest.json` de
chaque jeu (notes de version). Le navigateur des visiteurs ne contacte jamais le serveur du launcher,
sauf en cliquant sur « Télécharger le launcher ».
* **Le bouton de téléchargement n'apparaît que si `launcher-update.json` est signé par ta clé** : le
site vérifie la signature avec la même clé publique et le même code que le launcher
(`UpdateSignature`, clé `src\GameLauncher\Security\update-signing.pub` intégrée à l'exécutable du
site). L'adresse de l'exécutable doit être sur le même serveur que `launcher-update.json`. Fichier
refusé = bouton retiré (et erreur dans le journal), serveur injoignable = dernière version valide
conservée.
* **Rien à stocker** : ni base de données, ni formulaire, ni cookie, ni compte. Le service n'écrit rien
sur le disque, sauf les dossiers vides que .NET crée dans son dossier personnel `/var/lib/lanisys-site`.
Ce guide suppose un VPS Debian (11, 12 ou 13) préparé comme celui du launcher, avec le guide
« Serveur de téléchargement Exovival – VPS Debian 13 » (`docs\`) : **étapes 1 à 5** (mises à jour,
compte `admin` + clé SSH, SSH verrouillé, pare-feu + fail2ban, mises à jour de sécurité automatiques),
puis l'installation de nginx de l'**étape 7** (sans créer le site du jeu). Le certificat HTTPS est traité
ici (étape 4) ; le compte `deploy` (étape 9) est inutile. Compter 30 minutes la première fois. Les commandes `PS>` se tapent dans PowerShell
sur ton PC, les autres sur le VPS.
## 0. Pourquoi un VPS séparé
Recommandé, mais pas indispensable :
| | Site sur le VPS du launcher | Site sur l'autre VPS |
|---|---|---|
| Une faille dans le site (ou dans nginx pour le site) | atteint la machine qui héberge l'API d'administration, les clés publiques des administrateurs, le journal d'audit (adresses IP), les demandes des joueurs et les fichiers des jeux | n'atteint qu'une machine sans aucune donnée : rien à voler, rien à modifier chez les joueurs |
| Trafic du site (pic, robot, attaque par saturation) | ralentit les téléchargements et les mises à jour des joueurs | sans effet sur le launcher |
| Mises à jour, essais de configuration nginx | une erreur peut couper le launcher | indépendants |
| Entretien | une machine | deux machines à tenir à jour (automatique, étape 5 du guide) |
Le site est déjà durci (compte temporaire, système en lecture seule, connexions sortantes limitées au
seul serveur du launcher) : sur le même VPS, le risque reste faible. Mais tu as déjà le second VPS,
et le séparer coûte seulement un nom de domaine et un certificat : autant en profiter.
Rien ne change sur le VPS du launcher : les deux fichiers lus par le site y sont déjà publics.
## 1. Nom de domaine (registrar)
Fais pointer `www.lanisys.fr` et `lanisys.fr` vers l'adresse IP du VPS du site (enregistrements `A`,
et `AAAA` si le VPS a une IPv6). Vérifie depuis ton PC :
```
PS> Resolve-DnsName www.lanisys.fr
```
> Autre domaine ? Remplace `www.lanisys.fr` / `lanisys.fr` partout dans ce guide, dans
> `nginx-site.conf` et dans `Site:PublicUrl` (étape 5).
## 2. Publier le site (PC)
Double-clique `tools\publish-site.bat`. Il produit dans `Publish\Site\` :
* `lanisys-site.tar.gz` : le site pour Linux x64, **autonome** (aucun .NET à installer sur le VPS) ;
* `installer-site.sh` : le script d'installation ;
* `lanisys-site.tar.gz.sha256` : l'empreinte de l'archive, aussi affichée à la fin du script.
## 3. Envoyer et installer (PC puis VPS)
```
PS> scp "Publish\Site\lanisys-site.tar.gz" "Publish\Site\installer-site.sh" admin@<VPS du site>:/tmp/
```
```
sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz <empreinte affichée par publish-site.bat>
rm /tmp/lanisys-site.tar.gz /tmp/installer-site.sh
```
Le script :
* vérifie l'empreinte (archive altérée = refusée, rien n'est modifié) ;
* installe les binaires dans `/opt/lanisys-site` (propriétaire root) ;
* installe le service `lanisys-site` : compte **temporaire** créé par systemd (`DynamicUser`), système en
lecture seule (sauf son dossier `/var/lib/lanisys-site`), aucun privilège, écoute sur `127.0.0.1:5090` uniquement ;
* écrit `/etc/systemd/system/lanisys-site.service.d/sortie.conf` : les **seules** adresses que le site
peut joindre (celles de `launcher.lanisys.fr` et des serveurs DNS). Serveur du launcher différent :
ajoute son nom en 3e argument du script ;
* démarre le service, vérifie qu'il répond et affiche ce qu'il a lu sur le serveur du launcher. Attendu :
```
Launcher 3.4.0 (produit « EZRLauncher », signature valide) : https://launcher.lanisys.fr/launcher/GameLauncher-3.4.0.exe
Exovival Zombie Reborn : version 1.8, 12 notes de version.
```
`launcher-update.json REFUSÉ` : voir « Dépannage ».
> Le serveur du launcher change d'adresse IP (changement d'offre, migration) : relance simplement
> `installer-site.sh` avec la même archive, il recalcule `sortie.conf`.
## 4. Brancher nginx et HTTPS (VPS)
Copie d'abord `website\deploy\nginx-site.conf` et `nginx-site-zones.conf` sur le VPS :
```
PS> scp website\deploy\nginx-site.conf website\deploy\nginx-site-zones.conf admin@<VPS du site>:/tmp/
```
1. **Certificat** (une seule fois, nginx arrêté une dizaine de secondes) :
```
sudo apt install -y certbot
sudo mkdir -p /var/www/letsencrypt
sudo systemctl stop nginx
sudo certbot certonly --standalone -d www.lanisys.fr -d lanisys.fr
sudo systemctl start nginx
```
2. **Site** :
```
sudo cp /tmp/nginx-site-zones.conf /etc/nginx/conf.d/lanisys-site-zones.conf
sudo cp /tmp/nginx-site.conf /etc/nginx/sites-available/lanisys-site
sudo ln -s /etc/nginx/sites-available/lanisys-site /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
```
3. **Renouvellement automatique sans couper le site** : bascule certbot sur le dossier
`/var/www/letsencrypt` (servi par le bloc HTTP de `nginx-site.conf`), puis vérifie :
```
sudo certbot certonly --webroot -w /var/www/letsencrypt -d www.lanisys.fr -d lanisys.fr --force-renewal
sudo certbot renew --dry-run
```
4. **Test** depuis ton PC :
```
PS> curl.exe -sI https://www.lanisys.fr/
```
Attendu : `HTTP/2 200`, `content-security-policy: default-src 'none'; …`,
`strict-transport-security: max-age=31536000`, et **aucun** `set-cookie`.
`http://lanisys.fr` et `https://lanisys.fr` redirigent vers `https://www.lanisys.fr`.
## 5. Réglages propres au VPS : mentions légales, contact (VPS)
Les mentions légales sont **obligatoires** (loi pour la confiance dans l'économie numérique, art. 6) :
éditeur, directeur de la publication, hébergeur. Crée `/opt/lanisys-site/appsettings.Production.json`
avec uniquement les valeurs à changer :
```json
{
"Site": {
"ContactEmail": "contact@lanisys.fr",
"Launcher": { "Product": "EZRLauncher" },
"Legal": {
"Publisher": "LanisysGames",
"PublisherDetails": [ "Éditeur non professionnel", "Adresse communiquée à l'hébergeur" ],
"Director": "Prénom Nom"
}
}
}
```
```
sudo nano /opt/lanisys-site/appsettings.Production.json
sudo chmod 644 /opt/lanisys-site/appsettings.Production.json
sudo systemctl restart lanisys-site
```
* **Éditeur** : particulier sans activité commerciale, tu peux rester anonyme en publiant les
coordonnées de l'hébergeur et en donnant les tiennes à l'hébergeur (« éditeur non professionnel »).
Société ou auto-entreprise : raison sociale, adresse, SIRET, téléphone.
* **Hébergeur** : IONOS SARL est déjà renseigné dans `appsettings.json` (vérifie l'adresse sur la page
« Mentions légales » d'IONOS au moment de la mise en ligne).
* **Contact** : `ContactEmail` alimente le bouton « Nous contacter » (simple lien `mailto:`).
Utilise une adresse dédiée (alias), pas ton adresse personnelle : elle est visible des robots.
* **Produit** : `Launcher:Product` (facultatif) n'accepte que les `launcher-update.json` de ce produit.
Recopie la valeur affichée par l'installation (« produit « EZRLauncher » »).
* Ce fichier est conservé lors des mises à jour. Les listes (`Games`, `PublisherDetails`…) remplacent
les éléments de même position : pour modifier un jeu, recopie son bloc entier.
## 6. Journaux et confidentialité (VPS)
La page « Confidentialité » annonce des journaux nginx conservés **14 jours** (`Site:Legal:LogRetentionDays`).
C'est la rotation par défaut de nginx sous Debian (`/etc/logrotate.d/nginx` : `daily`, `rotate 14`) ;
vérifie-la :
```
grep -E "daily|rotate" /etc/logrotate.d/nginx
```
Le site lui-même n'enregistre aucune adresse IP (journal systemd : démarrage, versions lues, erreurs).
## 7. Mettre à jour le site
Après une modification (pages, styles, actualités, réglages livrés) : `tools\publish-site.bat`, puis
étape 3. Le contenu venant du serveur du launcher (versions, notes) se met à jour **tout seul** : rien à
republier quand tu publies une version d'un jeu ou du launcher (délai : 10 minutes au plus).
### Écrire une actualité
Un fichier Markdown par article dans `website\GameLauncher.Website\Content\News\`, nommé comme
l'adresse voulue (`mon-article.md` → `/actualites/mon-article` ; minuscules, chiffres, tirets) :
```
---
title: Exovival Zombie Reborn 1.9 : le parking
date: 2026-12-01
summary: Une ou deux phrases pour la liste des actualités.
topic: Exovival Zombie Reborn
theme: exovival
image: /img/exovival/abri-parking.webp
imageAlt: Le parking souterrain, éclairage de secours.
---
Texte de l'article. **Gras**, *italique*, [lien](https://…), listes « - », titres « ## »,
citations « > », image seule sur sa ligne : ![légende](/img/…).
```
* `theme` : `launcher` (par défaut), `exovival` ou `multi` (couleurs de la page).
* `draft: true` : brouillon, visible seulement en local (F5 dans Visual Studio).
* Images : dans `wwwroot\img\…` (WebP conseillé, 1280 px de large). Images externes refusées.
* Le HTML écrit dans un article est affiché tel quel, jamais interprété ; les liens `javascript:` sont
retirés (liseuse `Services\SafeMarkdown.cs`).
### Ajouter un jeu
Bloc `Site:Games` d'`appsettings.json` : `Slug` (adresse `/jeux/<slug>`), `Name`, `Theme`, `Status`
(`available` ou `upcoming`), `GameId` et `ManifestUrl` (notes de version), `Tagline`, `CardImage`.
Sans page dédiée, le jeu utilise la page générique (`Pages\Jeux\Game.cshtml`). Le projet multijoueur
est déjà déclaré (`upcoming`) : `"Visible": false` pour le masquer.
## 8. Tester en local (PC)
Visual Studio : projet de démarrage **GameLauncher.Website**, F5 → `http://127.0.0.1:5090/`.
Le site lit les vrais fichiers du serveur du launcher (https). Pour tester sans réseau, sers un dossier
de test en local et pointe `Site:Launcher:UpdateUrl` / `Site:Games:0:ManifestUrl` sur
`http://127.0.0.1:…` dans `appsettings.Development.json` (`AllowLoopbackHttp` n'est accepté qu'en
développement ; un `launcher-update.json` de test doit être signé par ta clé, sinon le bouton reste masqué).
## Dépannage
| Symptôme | Cause et remède |
|---|---|
| « Le téléchargement est momentanément indisponible » | `journalctl -u lanisys-site -n 50`. `non lu (serveur injoignable…)` : serveur du launcher arrêté, ou adresse IP changée (relance `installer-site.sh`). `REFUSÉ … SIGNATURE INVALIDE` : `launcher-update.json` n'est pas signé par la clé de `update-signing.pub` (vérifie avec `LauncherPublisher verify`, README §7). `adresse de l'exécutable refusée` : l'exe doit être sur le même serveur que `launcher-update.json`. |
| `The home directory of the current user could not be determined` | Service installé avec une version de `lanisys-site.service` antérieure au 10/11/2026 (sans `HOME`) : republie et réinstalle (étapes 2 et 3). .NET a besoin d'un dossier personnel pour vérifier un certificat HTTPS ; le compte temporaire de systemd n'en a pas sous Debian 11. |
| `certificat HTTPS refusé` / `Certificat de launcher.lanisys.fr REFUSÉ` | Le site exige un certificat valide, comme le launcher. **`PartialChain`** (cas le plus fréquent) : le serveur du launcher n'envoie pas le certificat intermédiaire. Windows et les navigateurs le retrouvent seuls, pas Linux (et le site n'a pas le droit d'aller le chercher ailleurs). Sur le **VPS du launcher**, dans le site nginx, `ssl_certificate` doit désigner `…/fullchain.pem` et non `…/cert.pem`, puis `sudo nginx -t && sudo systemctl reload nginx`. Vérification depuis le VPS du site : `openssl s_client -connect launcher.lanisys.fr:443 -servername launcher.lanisys.fr </dev/null 2>/dev/null \| grep -E "^ *[0-9] s:\|Verify return"` doit montrer au moins 2 certificats (`0 s:` et `1 s:`) et `Verify return code: 0 (ok)`. `UntrustedRoot` : paquet `ca-certificates` du VPS du site à jour (`sudo apt install --only-upgrade ca-certificates`). `NameMismatch` : le certificat ne couvre pas le nom utilisé dans `Site:Launcher:UpdateUrl`. Puis `sudo systemctl restart lanisys-site`. |
| Notes de version d'un jeu absentes | Même journal : `Manifest … non lu` ou `REFUSÉ` (manifest d'un autre jeu : vérifie `GameId`). |
| Le service ne démarre pas | `journalctl -u lanisys-site -n 50` : un réglage incorrect est nommé (`Site:PublicUrl`, `Site:Games`…). Corrige `appsettings.Production.json`. |
| Erreur 502 de nginx | Service arrêté : `systemctl status lanisys-site`. |
| Lien « Toutes les versions sur notre Gitea » en erreur | La page « Publications » n'est visible que si le dépôt Gitea est **public** (ou un dépôt public dédié aux publications). Sinon, vide `Site:Launcher:ReleasesUrl`. |
## Surveillance
| Pour… | Commande |
|---|---|
| Journal du site (versions lues, refus, erreurs) | `journalctl -u lanisys-site -f` |
| Niveau de durcissement du service | `systemd-analyze security lanisys-site` |
| Adresses autorisées en sortie | `cat /etc/systemd/system/lanisys-site.service.d/sortie.conf` |
| État | `systemctl status lanisys-site` |
## Fichiers sur le VPS
```
/opt/lanisys-site/ binaires (root, lecture seule pour le service)
├─ GameLauncher.Website site (exécutable autonome)
├─ appsettings.json réglages livrés (remplacés à chaque mise à jour)
├─ appsettings.Production.json TES réglages (mentions légales, contact ; conservé)
├─ Content/News/*.md actualités
└─ wwwroot/ styles, script, images, polices
/var/lib/lanisys-site/ dossier personnel du service (700, créé par systemd, presque vide)
/etc/systemd/system/lanisys-site.service
/etc/systemd/system/lanisys-site.service.d/sortie.conf destinations autorisées (installer-site.sh)
/etc/nginx/sites-available/lanisys-site nginx-site.conf
/etc/nginx/conf.d/lanisys-site-zones.conf nginx-site-zones.conf
```
+115
View File
@@ -0,0 +1,115 @@
#!/bin/sh
# =============================================================================
# Installe ou met à jour le site vitrine sur le VPS (compte admin, avec sudo) :
# sudo sh installer-site.sh /tmp/lanisys-site.tar.gz <empreinte SHA-256> [serveur du launcher]
# L'empreinte est affichée par tools\publish-site.bat (fichier lanisys-site.tar.gz.sha256) :
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
# Serveur du launcher : launcher.lanisys.fr par défaut (seule destination autorisée pour le site).
#
# • binaires dans /opt/lanisys-site (propriétaire root : le service ne peut pas les modifier) ;
# • service systemd « lanisys-site » (compte temporaire, voir lanisys-site.service) ;
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
# • adresses autorisées en sortie recalculées à chaque installation (sortie.conf) ;
# • en cas d'échec du démarrage, la version précédente est remise en place.
# =============================================================================
set -eu
ARCHIVE="${1:-}"
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
LAUNCHER_HOST="${3:-launcher.lanisys.fr}"
INSTALL_DIR=/opt/lanisys-site
SERVICE=lanisys-site
PORT=5090
fail() { echo "ERREUR : $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-site.sh /tmp/lanisys-site.tar.gz <empreinte SHA-256> [serveur du launcher]"
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir lanisys-site.tar.gz.sha256)."
printf '%s' "$LAUNCHER_HOST" | grep -Eq '^[A-Za-z0-9.-]+$' || fail "nom du serveur du launcher invalide : $LAUNCHER_HOST"
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
command -v getent >/dev/null 2>&1 || fail "getent est nécessaire."
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
WORK="$TMPROOT/files"
mkdir "$WORK"
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
[ -f "$WORK/GameLauncher.Website" ] || fail "archive invalide : GameLauncher.Website absent."
[ -f "$WORK/deploy/lanisys-site.service" ] || fail "archive invalide : deploy/lanisys-site.service absent."
echo "== Adresses autorisées en sortie (serveur du launcher et DNS)"
ALLOW=""
for ip in $(getent ahosts "$LAUNCHER_HOST" | awk '{print $1}' | sort -u); do ALLOW="$ALLOW $ip"; done
[ -n "$ALLOW" ] || fail "impossible de résoudre $LAUNCHER_HOST (DNS)."
for ip in $(awk '/^nameserver/ {print $2}' /etc/resolv.conf); do
case "$ip" in 127.*|::1|*%*) ;; *) ALLOW="$ALLOW $ip" ;; esac
done
echo " $LAUNCHER_HOST et DNS :$ALLOW"
echo "== Préparation de la nouvelle version"
rm -rf "$INSTALL_DIR.new"
mkdir -p "$INSTALL_DIR.new"
cp -a "$WORK/." "$INSTALL_DIR.new/"
rm -rf "$INSTALL_DIR.new/deploy"
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
fi
chown -R root:root "$INSTALL_DIR.new"
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
chmod 755 "$INSTALL_DIR.new/GameLauncher.Website"
echo "== Installation du service"
UNIT=/etc/systemd/system/$SERVICE.service
DROPIN_DIR=/etc/systemd/system/$SERVICE.service.d
rm -f "$UNIT.old"
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
systemctl stop $SERVICE 2>/dev/null || true
install -m 644 -o root -g root "$WORK/deploy/lanisys-site.service" "$UNIT"
mkdir -p "$DROPIN_DIR"
{
echo "# Écrit par installer-site.sh : seules destinations autorisées pour le site (en plus de localhost)."
echo "# Le serveur du launcher a changé d'adresse ? Relancez installer-site.sh."
echo "[Service]"
echo "IPAddressAllow=$ALLOW"
} > "$DROPIN_DIR/sortie.conf"
chmod 644 "$DROPIN_DIR/sortie.conf"
systemctl daemon-reload
rm -rf "$INSTALL_DIR.old"
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
systemctl enable --now $SERVICE
echo "== Vérification"
OK=0
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then OK=1; break; fi
sleep 1
done
if [ "$OK" -ne 1 ]; then
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
if [ -d "$INSTALL_DIR.old" ]; then
echo "Retour à la version précédente." >&2
systemctl stop $SERVICE || true
rm -rf "$INSTALL_DIR.failed"
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
systemctl daemon-reload
systemctl start $SERVICE || true
fi
exit 1
fi
echo "Site opérationnel sur http://127.0.0.1:$PORT (nginx devant)."
sleep 3
echo "Lecture des fichiers du launcher (journal) :"
journalctl -u $SERVICE -n 15 --no-pager | grep -E "Launcher|version|REFUSÉ|non lu" || echo " (rien encore : voir journalctl -u $SERVICE -f)"
+79
View File
@@ -0,0 +1,79 @@
# =============================================================================
# Site vitrine LanisysGames : service systemd (installé par installer-site.sh).
# /etc/systemd/system/lanisys-site.service
#
# Le service tourne sous un compte TEMPORAIRE créé par systemd à chaque démarrage (DynamicUser) :
# aucun compte à créer, aucun fichier à lui, système en lecture seule, aucun privilège.
# Ses binaires (/opt/lanisys-site) appartiennent à root : il ne peut pas se modifier lui-même.
# Le site n'écrit rien sur le disque (pas de base de données, clés de protection en mémoire). Seul
# /var/lib/lanisys-site (700, dossier « personnel » du compte temporaire) lui est ouvert en écriture.
#
# Réseau : il n'écoute que sur 127.0.0.1:5090 (nginx devant) et ne peut joindre QUE le serveur du
# launcher (et le résolveur DNS) : liste écrite par installer-site.sh dans
# /etc/systemd/system/lanisys-site.service.d/sortie.conf.
#
# Vérifier le durcissement : systemd-analyze security lanisys-site
# Journal : journalctl -u lanisys-site -f
# =============================================================================
[Unit]
Description=Site vitrine LanisysGames
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
DynamicUser=yes
WorkingDirectory=/opt/lanisys-site
ExecStart=/opt/lanisys-site/GameLauncher.Website
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Environment=DOTNET_NOLOGO=1
UMask=0077
# Dossier « personnel » du compte temporaire. .NET y cherche les magasins de certificats de l'utilisateur
# (vides) quand il vérifie le certificat HTTPS du serveur du launcher. Sans lui, toute connexion HTTPS
# échoue (« The home directory of the current user could not be determined »), par exemple sous Debian 11
# où le compte temporaire n'a pas de dossier personnel déclaré. Créé par systemd, réservé au service (700).
StateDirectory=lanisys-site
StateDirectoryMode=0700
Environment=HOME=/var/lib/lanisys-site
# --- Durcissement ------------------------------------------------------------
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Réseau sortant : tout est refusé, sauf la machine elle-même ; installer-site.sh ajoute le serveur du
# launcher et les serveurs DNS (sortie.conf). Même compromis, le site ne peut contacter rien d'autre.
IPAddressDeny=any
IPAddressAllow=localhost
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RemoveIPC=yes
SystemCallArchitectures=native
# MemoryDenyWriteExecute n'est PAS activé : le compilateur JIT de .NET a besoin de mémoire exécutable.
# --- Ressources (petit VPS) ------------------------------------------------------
MemoryMax=384M
TasksMax=128
LimitNOFILE=8192
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
# Site vitrine : zone de limitation des requêtes par adresse IP (bloc http de nginx).
# sudo cp nginx-site-zones.conf /etc/nginx/conf.d/lanisys-site-zones.conf
# 10 requêtes par seconde en moyenne (une page = 1 requête HTML + quelques fichiers en cache).
limit_req_zone $binary_remote_addr zone=lanisys_site:10m rate=10r/s;
+86
View File
@@ -0,0 +1,86 @@
# =============================================================================
# Site vitrine LanisysGames : configuration nginx (VPS du site).
# /etc/nginx/sites-available/lanisys-site puis lien dans sites-enabled
# Compatible nginx 1.18 (Debian 11) et suivants : « listen … http2 » (la directive « http2 on; » n'existe
# qu'à partir de nginx 1.25 ; sur ces versions, l'ancienne écriture fonctionne avec un simple avertissement).
# Remplacez www.lanisys.fr / lanisys.fr par vos noms de domaine. Le certificat HTTPS doit exister
# AVANT d'activer ce fichier : voir website\deploy\LISEZMOI.md, étape 4.
#
# Zone de limitation : à placer UNE seule fois dans le bloc http (ex. /etc/nginx/conf.d/lanisys-site-zones.conf,
# fichier fourni : nginx-site-zones.conf).
# =============================================================================
# HTTP : uniquement la redirection vers HTTPS (et le défi Let's Encrypt, géré par certbot).
server {
listen 80;
listen [::]:80;
server_name www.lanisys.fr lanisys.fr;
# Renouvellement des certificats Let's Encrypt (certbot --webroot), sans couper le site.
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
}
location / {
return 301 https://www.lanisys.fr$request_uri;
}
}
# Domaine nu → www (une seule adresse officielle).
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name lanisys.fr;
ssl_certificate /etc/letsencrypt/live/www.lanisys.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.lanisys.fr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:lanisys_site_tls:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
return 301 https://www.lanisys.fr$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.lanisys.fr;
ssl_certificate /etc/letsencrypt/live/www.lanisys.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.lanisys.fr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:lanisys_site_tls:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
server_tokens off;
client_max_body_size 1k; # site en lecture seule : aucun envoi de données
client_body_timeout 10s;
client_header_timeout 10s;
keepalive_timeout 30s;
# Uniquement des lectures (le site refuse aussi le reste).
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
# Fichiers cachés, sauvegardes d'éditeur : jamais servis.
location ~ /\. { return 404; }
location ~ ~$ { return 404; }
gzip on;
gzip_vary on;
gzip_types text/css application/javascript application/xml image/svg+xml text/plain;
location / {
limit_req zone=lanisys_site burst=40 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
}