v1.0.5 — Fix /download-url sur manifests multi-channels (same-version)
Bug rapporté : sur un manifest avec deux entrées partageant le numéro de
version 1.5.4.32 (channels proserve-firefighter vs proserve-full), le
client se prenait au démarrage de l'install le garde-fou :
« Incohérence serveur : l'endpoint /download-url retourne un nom de
fichier différent du manifest. Manifest : proserve-full-1.5.4.32.zip.
Signé : proserve-firefighter-1.5.4.32.zip. → DownloadUrl.php côté
serveur doit lire le filename depuis manifest.download.url, pas via
un template hardcodé. »
Root cause : DownloadUrl.php faisait un `foreach ... if version match {
break; }` — il retournait donc TOUJOURS la première entrée matchant le
numéro, quel que soit le channel réellement cliqué côté client. Pareil
que le bug de sync_one (même famille de problèmes), mais côté endpoint
runtime du client.
Fix côté serveur : /download-url accepte maintenant un query param optionnel
`?filename=proserve-full-1.5.4.32.zip`. Si présent, le foreach filtre sur
(numéro version AND basename(download.url) == filename attendu). Whitelist
défensive sur le filename (path traversal). Rétro-compat : sans param, le
1er match par numéro gagne comme avant.
Fix côté client : le client extrait le filename attendu de `row.Remote.
Download.Url` (déjà connu, signé Ed25519) et le passe à l'endpoint. Deux
sites d'appel modifiés : le call initial dans InstallVersionAsync + le
callback RefreshUrlAsync (utilisé quand un segment reçoit 403/410 mid-DL
et qu'il faut re-signer). Sans le refresh à jour, un DL long sur ADSL
tomberait au 1er refresh forcé.
Extension d'interface : ILicenseService.GetSignedDownloadUrlAsync prend
maintenant un `string? expectedFilename` en 2e param. Callers qui passent
null continuent de fonctionner comme avant (utile pour les tests).
Bump : 1.0.4 → 1.0.5 (bug fix ciblé sur les setups multi-channel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "1.0.4"
|
#define MyAppVersion "1.0.5"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
|
|||||||
@@ -75,12 +75,36 @@ final class DownloadUrl
|
|||||||
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
||||||
}
|
}
|
||||||
$manifest = json_decode(file_get_contents($manifestPath), true);
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
|
||||||
|
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
|
||||||
|
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
|
||||||
|
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
|
||||||
|
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
|
||||||
|
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
|
||||||
|
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
|
||||||
|
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
|
||||||
|
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
|
||||||
|
// dans ce cas la 1re entrée gagne, comme avant.
|
||||||
|
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
|
||||||
|
// Whitelist défensive — même règle que la validation du filename lu du manifest
|
||||||
|
// plus bas dans cette route. Bloque path traversal via query param.
|
||||||
|
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
|
||||||
|
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
|
||||||
|
}
|
||||||
|
|
||||||
$entry = null;
|
$entry = null;
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
|
if (($v['version'] ?? '') !== $version) continue;
|
||||||
|
if ($expectedFilename !== '') {
|
||||||
|
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
|
||||||
|
if ($entryFilename !== $expectedFilename) continue;
|
||||||
|
}
|
||||||
|
$entry = $v;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
|
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
|
||||||
|
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif droits téléchargement
|
// Vérif droits téléchargement
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>1.0.4</Version>
|
<Version>1.0.5</Version>
|
||||||
<AssemblyVersion>1.0.4.0</AssemblyVersion>
|
<AssemblyVersion>1.0.5.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.4.0</FileVersion>
|
<FileVersion>1.0.5.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -1531,7 +1531,15 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_currentPeerHost = null;
|
_currentPeerHost = null;
|
||||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
// Extrait le filename attendu depuis l'URL du manifest signé pour le
|
||||||
|
// passer à /download-url. Nécessaire sur les manifestes multi-channels
|
||||||
|
// où plusieurs entrées partagent un numéro de version (firefighter vs
|
||||||
|
// full sur v1.5.4.32) : sans ça le serveur retourne l'URL du 1er match
|
||||||
|
// par numéro → mismatch filename → abort avec l'erreur InvalidOperation
|
||||||
|
// ci-dessous. Rétro-compat : un serveur qui ignore ?filename= retombe
|
||||||
|
// sur son comportement historique.
|
||||||
|
var expectedFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
|
||||||
|
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, expectedFilename, ct);
|
||||||
var urlString = signed ?? row.Remote.Download.Url;
|
var urlString = signed ?? row.Remote.Download.Url;
|
||||||
url = new Uri(urlString);
|
url = new Uri(urlString);
|
||||||
|
|
||||||
@@ -1573,7 +1581,10 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
|
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
|
||||||
{
|
{
|
||||||
if (peerSrc is not null) return peerSrc.ZipUrl;
|
if (peerSrc is not null) return peerSrc.ZipUrl;
|
||||||
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
|
// Passe le filename attendu — le refresh doit re-signer la même
|
||||||
|
// ligne channel qu'à l'origine, pas la 1re entrée par version.
|
||||||
|
var refreshFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
|
||||||
|
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, refreshFilename, c);
|
||||||
return fresh is null ? null : new Uri(fresh);
|
return fresh is null ? null : new Uri(fresh);
|
||||||
}
|
}
|
||||||
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
|
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
|
||||||
|
|||||||
@@ -16,8 +16,14 @@ public interface ILicenseService
|
|||||||
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
||||||
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
||||||
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
||||||
|
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
|
||||||
|
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
|
||||||
|
/// entrées quand plusieurs channels partagent le même numéro de version
|
||||||
|
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
|
||||||
|
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
|
||||||
|
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
|
Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
|
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
return license.CanDownload(version);
|
return license.CanDownload(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
|
public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var key = GetDecryptedKey();
|
var key = GetDecryptedKey();
|
||||||
if (string.IsNullOrEmpty(key)) return null;
|
if (string.IsNullOrEmpty(key)) return null;
|
||||||
@@ -276,6 +276,13 @@ public sealed class LicenseService : ILicenseService
|
|||||||
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
||||||
var encodedKey = Uri.EscapeDataString(key);
|
var encodedKey = Uri.EscapeDataString(key);
|
||||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
||||||
|
// Passe le filename attendu pour disambiguer les channels sur les manifests
|
||||||
|
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
|
||||||
|
// matchant le numéro" (rétro-compat avec les vieux serveurs).
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedFilename))
|
||||||
|
{
|
||||||
|
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
||||||
|
|||||||
Reference in New Issue
Block a user