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>
354 lines
17 KiB
PHP
354 lines
17 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace PSLauncher\Tools;
|
||
|
||
/**
|
||
* Logique de re-signature du manifest, sans dépendance shell.
|
||
* Utilisable depuis le CLI (tools/sign-manifest.php) et depuis le backoffice
|
||
* web (admin/versions.php → action 'sync'). Évite tout appel à exec().
|
||
*/
|
||
final class SignManifest
|
||
{
|
||
public string $manifestPath;
|
||
public string $buildsDir;
|
||
public ?string $configPath;
|
||
public string $hashCachePath;
|
||
/** @var string[] */
|
||
public array $log = [];
|
||
|
||
/**
|
||
* @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/).
|
||
* @param string|null $channel Channel à signer. NULL/'' = manifest default
|
||
* (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}.
|
||
* Le hashcache est partagé entre tous les channels (les ZIPs peuvent être
|
||
* référencés par plusieurs manifests, économise les recalculs).
|
||
*/
|
||
public function __construct(string $rootDir, ?string $channel = null)
|
||
{
|
||
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
|
||
? "versions-{$channel}.json"
|
||
: 'versions.json';
|
||
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
|
||
$this->buildsDir = $rootDir . '/builds';
|
||
$this->configPath = $rootDir . '/api/config.php';
|
||
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
||
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
|
||
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
|
||
// de pouvoir lire le cache).
|
||
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
|
||
}
|
||
|
||
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]].
|
||
* @return array<string, array{size:int,mtime:int,sha256:string}>
|
||
*/
|
||
private function loadHashCache(): array
|
||
{
|
||
if (!is_file($this->hashCachePath)) return [];
|
||
$raw = @file_get_contents($this->hashCachePath);
|
||
if ($raw === false) return [];
|
||
$j = json_decode($raw, true);
|
||
return is_array($j) ? $j : [];
|
||
}
|
||
|
||
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
|
||
private function saveHashCache(array $cache): void
|
||
{
|
||
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||
}
|
||
|
||
/**
|
||
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
|
||
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||
*
|
||
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||
*/
|
||
private function getOrComputeSha256(string $path, array &$cache): array
|
||
{
|
||
$size = filesize($path) ?: 0;
|
||
$mtime = filemtime($path) ?: 0;
|
||
$key = realpath($path) ?: $path;
|
||
|
||
if (isset($cache[$key])
|
||
&& ($cache[$key]['size'] ?? -1) === $size
|
||
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||
&& !empty($cache[$key]['sha256'])) {
|
||
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
|
||
}
|
||
|
||
$start = microtime(true);
|
||
$sha = hash_file('sha256', $path);
|
||
$duration = (int)((microtime(true) - $start) * 1000);
|
||
|
||
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
|
||
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
|
||
}
|
||
|
||
/**
|
||
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
|
||
*
|
||
* Stratégie de hash :
|
||
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
|
||
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
|
||
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
|
||
* on évite carrément l'appel à hash_file()
|
||
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
|
||
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
|
||
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
|
||
*
|
||
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
|
||
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
|
||
*
|
||
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
|
||
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
|
||
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
|
||
* versions[] (les autres restent inchangées). Le scope 'launcher'
|
||
* est ignoré dans ce cas.
|
||
* @return array{ok:bool, log:string[]}
|
||
*/
|
||
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
|
||
{
|
||
if (!is_file($this->manifestPath)) {
|
||
$this->out("Manifest introuvable : {$this->manifestPath}");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
$manifest = json_decode(file_get_contents($this->manifestPath), true);
|
||
if (!is_array($manifest)) {
|
||
$this->out("Manifest illisible (JSON invalide)");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||
if ($onlyVersion !== null) {
|
||
// Mode "hash une seule version" : on ne touche pas au launcher,
|
||
// et on ne hash que la version demandée dans la section versions[].
|
||
$doLauncher = false;
|
||
$doVersions = true;
|
||
}
|
||
|
||
// Charge le cache des hashs (indexé par chemin réel du ZIP)
|
||
$cache = $this->loadHashCache();
|
||
$cacheChanged = false;
|
||
|
||
$hashedVersions = [];
|
||
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
||
$version = $v['version'] ?? '?';
|
||
if ($onlyVersion !== null && $version !== $onlyVersion) {
|
||
continue; // skip silently les autres versions
|
||
}
|
||
$url = $v['download']['url'] ?? '';
|
||
if ($url === '') {
|
||
$this->out(" [skip] $version : pas d'URL dans le manifest");
|
||
continue;
|
||
}
|
||
|
||
// 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)) {
|
||
// 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é '" . 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(fn($c) => str_replace($this->buildsDir . '/', '', $c), $candidates)));
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$size = filesize($zip) ?: 0;
|
||
$v['download']['sizeBytes'] = $size;
|
||
|
||
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
|
||
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
|
||
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
|
||
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
|
||
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
|
||
$skipFlag = !empty($v['download']['skipHash']);
|
||
if ($algo === 'none' || $skipFlag) {
|
||
$v['download']['sha256'] = '';
|
||
$v['download']['hashAlgorithm'] = 'none';
|
||
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
|
||
$hashedVersions[] = $version;
|
||
continue;
|
||
}
|
||
|
||
// Cache lookup (sauf si --force)
|
||
if (!$force) {
|
||
$key = realpath($zip) ?: $zip;
|
||
$existingSha = (string)($v['download']['sha256'] ?? '');
|
||
$hasValidSha = $existingSha !== ''
|
||
&& !str_starts_with($existingSha, 'REPLACE');
|
||
if ($hasValidSha
|
||
&& isset($cache[$key])
|
||
&& ($cache[$key]['size'] ?? -1) === $size
|
||
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
|
||
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
|
||
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
|
||
$hashedVersions[] = $version;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$r = $this->getOrComputeSha256($zip, $cache);
|
||
$cacheChanged = true;
|
||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||
$v['download']['sha256'] = $r['sha256'];
|
||
$hashedVersions[] = $version;
|
||
}
|
||
unset($v);
|
||
|
||
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
|
||
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
|
||
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
||
$launcher = &$manifest['launcher'];
|
||
$lver = $launcher['version'] ?? '';
|
||
$lurl = $launcher['download']['url'] ?? '';
|
||
if ($lver !== '' && $lurl !== '') {
|
||
$lfile = basename(parse_url($lurl, PHP_URL_PATH) ?: '');
|
||
$lpath = "{$this->buildsDir}/launcher/{$lfile}";
|
||
if (!is_file($lpath)) {
|
||
// Fallback : tolérant sur le nom (PSLauncher-X.Y.Z.exe, etc.)
|
||
$candidates = glob("{$this->buildsDir}/launcher/*{$lver}*.exe", GLOB_NOSORT) ?: [];
|
||
if (count($candidates) === 1) $lpath = $candidates[0];
|
||
}
|
||
if (is_file($lpath)) {
|
||
$lsize = filesize($lpath) ?: 0;
|
||
$launcher['download']['sizeBytes'] = $lsize;
|
||
|
||
// Cache lookup pour le launcher exe aussi
|
||
$lkey = realpath($lpath) ?: $lpath;
|
||
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
|
||
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
|
||
if (!$force
|
||
&& $hasValidLsha
|
||
&& isset($cache[$lkey])
|
||
&& ($cache[$lkey]['size'] ?? -1) === $lsize
|
||
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
|
||
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||
} else {
|
||
$r = $this->getOrComputeSha256($lpath, $cache);
|
||
$cacheChanged = true;
|
||
$launcher['download']['sha256'] = $r['sha256'];
|
||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
|
||
}
|
||
} else {
|
||
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
|
||
}
|
||
}
|
||
unset($launcher);
|
||
}
|
||
|
||
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
|
||
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
|
||
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
|
||
// ancienne version isolée via $onlyVersion.
|
||
if ($doVersions) {
|
||
$eligible = [];
|
||
foreach ($manifest['versions'] as $vEntry) {
|
||
$sha = (string)($vEntry['download']['sha256'] ?? '');
|
||
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
|
||
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
|
||
if ($hasValid) $eligible[] = $vEntry['version'];
|
||
}
|
||
if (!empty($eligible)) {
|
||
usort($eligible, 'version_compare');
|
||
$newLatest = end($eligible);
|
||
if (($manifest['latest'] ?? null) !== $newLatest) {
|
||
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
|
||
$manifest['latest'] = $newLatest;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Signature Ed25519
|
||
if ($this->configPath && is_file($this->configPath)) {
|
||
$config = require $this->configPath;
|
||
$sk = $config['ed25519']['private_key_hex'] ?? '';
|
||
if ($sk !== '' && strlen($sk) === 128) {
|
||
$manifest['signature'] = null;
|
||
$cryptoPath = dirname($this->configPath) . '/lib/Crypto.php';
|
||
if (is_file($cryptoPath)) require_once $cryptoPath;
|
||
if (class_exists('\PSLauncher\Crypto')) {
|
||
$payload = \PSLauncher\Crypto::canonicalJson($manifest);
|
||
$manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk);
|
||
$this->out(" [sign] manifest signé (Ed25519)");
|
||
} else {
|
||
$this->out(" [warn] Classe \\PSLauncher\\Crypto introuvable, manifest non signé");
|
||
}
|
||
} else {
|
||
$this->out(" [warn] ed25519.private_key_hex non configuré, manifest non signé");
|
||
}
|
||
}
|
||
|
||
$jsonOut = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||
if (file_put_contents($this->manifestPath, $jsonOut) === false) {
|
||
$this->out("Échec d'écriture du manifest : {$this->manifestPath}");
|
||
return ['ok' => false, 'log' => $this->log];
|
||
}
|
||
|
||
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
|
||
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
|
||
if ($cacheChanged) {
|
||
foreach (array_keys($cache) as $cachedPath) {
|
||
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
|
||
}
|
||
$this->saveHashCache($cache);
|
||
}
|
||
|
||
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
|
||
return ['ok' => true, 'log' => $this->log];
|
||
}
|
||
}
|