From 7ff5e561028946136ffe4000b87ace47190ccbfb Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 5 May 2026 08:14:02 +0200 Subject: [PATCH] Fix : SignManifest et listing ZIPs supportent les sous-dossiers de channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.26.0 générait des URLs builds/{channel}/proserve-X.Y.Z.zip dans le manifest pour les channels non-default, mais SignManifest faisait juste basename(url) → cherchait builds/proserve-X.Y.Z.zip à plat → ZIP introuvable au moment du Sync. Fixes : 1. SignManifest::resolveZipPath() : extrait tout le chemin après /builds/ dans l'URL et le mappe sur {$buildsDir}/. Strip ../ pour anti-traversal même si le manifest est de toute façon signé Ed25519. 2. SignManifest fallback fuzzy étendu à 1 niveau de sous-dossier (glob */*.zip) pour le cas "admin a renommé le ZIP". 3. admin/versions.php $zips inclut maintenant builds/ + builds/*/ et stocke les chemins relatifs (asterion-vr/proserve-X.Y.Z.zip) au lieu du basename. Le check "référencé ?" matche aussi sur le chemin relatif. 4. Workflow text en haut de la page indique le bon chemin SFTP selon le channel actif (avec rappel "crée le sous-dossier si absent"). Co-Authored-By: Claude Opus 4.7 (1M context) --- server/admin/versions.php | 40 +++++++++++++++++++++++++----- server/tools/SignManifest.php | 46 +++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/server/admin/versions.php b/server/admin/versions.php index 607db18..c42def6 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -283,9 +283,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $manifest = loadManifest($manifestPath); -$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : []; +// Liste des ZIPs : on inclut builds/ + builds/{channel}/ (1 niveau de +// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les +// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs +// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip). +$zips = []; $zipSizes = []; -foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z"); +if (is_dir($buildsDir)) { + foreach (array_merge( + glob("$buildsDir/*.zip") ?: [], + glob("$buildsDir/*/*.zip") ?: [] + ) as $z) { + $rel = ltrim(str_replace($buildsDir, '', $z), '/\\'); + $rel = str_replace('\\', '/', $rel); + $zips[] = $rel; + $zipSizes[$rel] = filesize($z); + } +} // Pour l'édition : pré-charge les release notes existantes $existingNotes = []; @@ -342,7 +356,15 @@ Layout::header('Versions', 'versions');

Workflow d'une nouvelle release

  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).
  2. -
  3. Upload le ZIP correspondant via SFTP dans www/PS_Launcher/builds/ en respectant le nom proserve-{version}.zip.
  4. +
  5. Upload le ZIP correspondant via SFTP en respectant le nom proserve-{version}.zip, dans le dossier qui correspond au channel actif : +
      + +
    • Channel defaultwww/PS_Launcher/builds/proserve-{version}.zip
    • + +
    • Channel www/PS_Launcher/builds//proserve-{version}.zip (crée le sous-dossier en SFTP s'il n'existe pas)
    • + +
    +
  6. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  7. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».
@@ -581,12 +603,18 @@ Layout::header('Versions', 'versions'); FichierTailleRéférencé ? diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php index fa6fe62..32f07a9 100644 --- a/server/tools/SignManifest.php +++ b/server/tools/SignManifest.php @@ -41,6 +41,32 @@ final class SignManifest private function out(string $line): void { $this->log[] = $line; } + /** + * Mappe le chemin URL d'un ZIP (ex. /PS_Launcher/builds/asterion-vr/proserve-1.4.6.zip) + * vers son chemin local sur disque (ex. {$buildsDir}/asterion-vr/proserve-1.4.6.zip). + * + * Stratégie : on cherche le segment '/builds/' dans le URL path et on prend + * tout ce qui vient après comme chemin relatif depuis $buildsDir. Sécurité : + * on neutralise '..' pour empêcher path traversal, même si l'URL est déjà + * sous notre contrôle (manifest signé Ed25519). + * + * Si le pattern '/builds/' n'est pas trouvé, fallback sur basename comme avant + * (compat ascendante avec d'éventuelles URLs custom). + */ + private function resolveZipPath(string $urlPath): string + { + $marker = '/builds/'; + $pos = strpos($urlPath, $marker); + if ($pos === false) { + return "{$this->buildsDir}/" . basename($urlPath); + } + $relative = substr($urlPath, $pos + strlen($marker)); + // Sécu : strip '..' segments pour éviter path traversal hors de buildsDir + $relative = str_replace(['..\\', '../', '..'], '', $relative); + $relative = ltrim($relative, '/\\'); + return "{$this->buildsDir}/$relative"; + } + /** * Lit le cache des hashs précalculés. Retourne un dictionnaire * [path => ['size' => int, 'mtime' => int, 'sha256' => string]]. @@ -150,19 +176,29 @@ final class SignManifest continue; } - $filename = basename(parse_url($url, PHP_URL_PATH) ?: ''); - $zip = "{$this->buildsDir}/$filename"; + // Le ZIP peut vivre dans builds/ directement (channel default) OU dans + // builds/{channel}/ pour les autres channels. On extrait donc TOUT le + // chemin après /builds/ dans l'URL plutôt qu'un simple basename(). + $urlPath = parse_url($url, PHP_URL_PATH) ?: ''; + $filename = basename($urlPath); + $zip = $this->resolveZipPath($urlPath); if (!is_file($zip)) { - $candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: []; + // Fallback : recherche fuzzy sur la version dans builds/ + sous-dossiers + // immédiats (1 niveau) au cas où l'admin aurait renommé le ZIP. + $candidates = array_merge( + glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [], + glob("{$this->buildsDir}/*/*{$version}*.zip", GLOB_NOSORT) ?: [] + ); $candidates = array_values(array_filter($candidates, 'is_file')); if (count($candidates) === 1) { $zip = $candidates[0]; - $this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place"); + $this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . str_replace($this->buildsDir . '/', '', $zip) . "' à la place"); } else { $this->out(" [skip] $version : ZIP introuvable pour $url"); + $this->out(" Cherché : " . str_replace($this->buildsDir . '/', '', $zip)); if (count($candidates) > 1) { - $this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates))); + $this->out(" Plusieurs candidats : " . implode(', ', array_map(fn($c) => str_replace($this->buildsDir . '/', '', $c), $candidates))); } continue; }