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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user