From 3a00b0e6770e6a339777761df82bf1f15a3589e0 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 1 May 2026 09:26:06 +0200 Subject: [PATCH] Don't trust manifest.latest, no-cache the manifest fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness fixes after a real-world miss where v1.4.7 was uploaded but the launcher kept reporting v1.4.6 as latest: 1. UpdateChecker: ignore the `latest` field of the manifest entirely. Always pick the highest SemVer in the versions[] array (filtered by availableForDownload). Removes a class of "I forgot to bump latest" bugs at the server. 2. ManifestService: send Cache-Control: no-cache, no-store + Pragma: no-cache when fetching. The user explicitly clicked "Check for updates", they want fresh data — bypass any intermediate cache (browser-style HTTP cache, OVH static handler default 2-day expires). 3. sign-manifest.php: after hashing the uploaded ZIPs, auto-update `manifest.latest` to the highest version that actually has a ZIP on the server. Prevents the same drift the client now ignores, but keeps the field meaningful for any consumer that reads it. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/tools/sign-manifest.php | 15 +++++++++++++++ src/PSLauncher.Core/Manifests/ManifestService.cs | 10 +++++++++- src/PSLauncher.Core/Updates/UpdateChecker.cs | 11 +++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/server/tools/sign-manifest.php b/server/tools/sign-manifest.php index 5370e83..7608476 100644 --- a/server/tools/sign-manifest.php +++ b/server/tools/sign-manifest.php @@ -24,6 +24,7 @@ if (!is_file($manifestPath)) { $manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR); $updated = 0; +$hashedVersions = []; foreach ($manifest['versions'] as &$v) { $version = $v['version'] ?? '?'; $url = $v['download']['url'] ?? ''; @@ -60,9 +61,23 @@ foreach ($manifest['versions'] as &$v) { $v['download']['sizeBytes'] = $size; $v['download']['sha256'] = $sha; $updated++; + $hashedVersions[] = $version; } unset($v); +// Met à jour automatiquement le champ `latest` avec la plus haute version +// effectivement uploadée (celle pour laquelle on a calculé un hash). +if (!empty($hashedVersions)) { + usort($hashedVersions, function ($a, $b) { + return version_compare($a, $b); + }); + $newLatest = end($hashedVersions); + if (($manifest['latest'] ?? null) !== $newLatest) { + echo " [latest] {$manifest['latest']} -> {$newLatest}\n"; + $manifest['latest'] = $newLatest; + } +} + // (v0.4) Signature Ed25519 — pour l'instant on laisse 'signature' = null. // $config = require __DIR__ . '/../api/config.php'; // $sk = sodium_hex2bin($config['ed25519']['private_key_hex']); diff --git a/src/PSLauncher.Core/Manifests/ManifestService.cs b/src/PSLauncher.Core/Manifests/ManifestService.cs index ba25c6e..1e8105b 100644 --- a/src/PSLauncher.Core/Manifests/ManifestService.cs +++ b/src/PSLauncher.Core/Manifests/ManifestService.cs @@ -27,7 +27,15 @@ public sealed class ManifestService : IManifestService { var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest"; _logger.LogInformation("Fetching manifest: {Url}", url); - using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false); + using var req = new HttpRequestMessage(HttpMethod.Get, url); + // Bypass des caches intermédiaires — l'utilisateur a explicitement cliqué « Vérifier ». + req.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue + { + NoCache = true, + NoStore = true, + }; + req.Headers.Pragma.ParseAdd("no-cache"); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); var manifest = await resp.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Empty manifest"); diff --git a/src/PSLauncher.Core/Updates/UpdateChecker.cs b/src/PSLauncher.Core/Updates/UpdateChecker.cs index aa9ee2e..4591cc2 100644 --- a/src/PSLauncher.Core/Updates/UpdateChecker.cs +++ b/src/PSLauncher.Core/Updates/UpdateChecker.cs @@ -26,10 +26,13 @@ public sealed class UpdateChecker : IUpdateChecker try { var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false); - var latest = manifest.Versions.FirstOrDefault(v => v.Version == manifest.Latest) - ?? manifest.Versions - .OrderByDescending(v => SemVer.Parse(v.Version)) - .FirstOrDefault(); + + // On ignore le champ `manifest.Latest` (trop facile à oublier au serveur). + // On prend toujours la plus haute version SemVer disponible et téléchargeable. + var latest = manifest.Versions + .Where(v => v.AvailableForDownload) + .OrderByDescending(v => SemVer.Parse(v.Version)) + .FirstOrDefault(); var installed = _registry.Scan(); var latestInstalled = installed