Fix : SignManifest et listing ZIPs supportent les sous-dossiers de channel

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 08:14:02 +02:00
parent 95c70903e7
commit 7ff5e56102
2 changed files with 75 additions and 11 deletions

View File

@@ -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');
<h2>Workflow d'une nouvelle release</h2>
<ol class="muted">
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</li>
<li>Upload le ZIP correspondant via SFTP dans <code>www/PS_Launcher/builds/</code> en respectant le nom <code>proserve-{version}.zip</code>.</li>
<li>Upload le ZIP correspondant via SFTP en respectant le nom <code>proserve-{version}.zip</code>, dans le dossier qui correspond au channel actif :
<ul>
<?php if ($channel === ''): ?>
<li>Channel <strong>default</strong> → <code>www/PS_Launcher/builds/proserve-{version}.zip</code></li>
<?php else: ?>
<li>Channel <strong><?= htmlspecialchars($channel) ?></strong> → <code>www/PS_Launcher/builds/<?= htmlspecialchars($channel) ?>/proserve-{version}.zip</code> <span class="muted">(crée le sous-dossier en SFTP s'il n'existe pas)</span></li>
<?php endif; ?>
</ul>
</li>
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
</ol>
@@ -581,12 +603,18 @@ Layout::header('Versions', 'versions');
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
<tbody>
<?php
$referencedNames = [];
// On référence avec le chemin relatif après /builds/ pour matcher
// les ZIPs en sous-dossier (channels). Ex. asterion-vr/proserve-1.4.6.zip.
$referencedRel = [];
foreach ($manifest['versions'] ?? [] as $v) {
$referencedNames[] = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$pos = strpos($urlPath, '/builds/');
if ($pos !== false) {
$referencedRel[] = ltrim(substr($urlPath, $pos + strlen('/builds/')), '/\\');
}
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedNames, true);
$referenced = in_array($z, $referencedRel, true);
?>
<tr>
<td><code><?= htmlspecialchars($z) ?></code></td>

View File

@@ -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;
}