Files
PS_Launcher/server/tools/SignManifest.php
j.foucher 962f5a8ce0 Server: faster releases (cache hashes), per-version skip, longer signed-URL TTL
- gate.php : ETag now derived from size+mtime instead of md5_file($zip).
  Previously the gate hashed the entire 14 GB ZIP on every HEAD/GET call
  (including each of 8 parallel segments) → 30s-5min preparation lag for
  clients before the first byte. Now <1ms per request.
- SignManifest : disk-backed cache for SHA-256 keyed by (path,size,mtime).
  Re-signing 5×14 GB versions used to take ~25 min, now ~1s when nothing
  changed. New "Force re-hash" toggle in admin to ignore the cache.
- versions.php : per-row "🔁 Hash" button to sign a single version, plus
  a "⚙ Hash" dropdown to toggle hashAlgorithm:none for builds where the
  user accepts skipping client-side verification (manifest stays signed
  Ed25519, only the per-ZIP SHA-256 verification is bypassed).
- DownloadUrl.php : signed-URL TTL bumped 1h → 6h to cover slow ADSL users
  who need >1h to finish a 14 GB download.
- .gitignore : track server/builds/.htaccess + gate.php (still ignore the
  actual ZIP/exe binaries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:32 +02:00

308 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 = [];
public function __construct(string $rootDir)
{
$this->manifestPath = $rootDir . '/manifest/versions.json';
$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; }
/**
* 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;
}
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
$zip = "{$this->buildsDir}/$filename";
if (!is_file($zip)) {
$candidates = 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");
} else {
$this->out(" [skip] $version : ZIP introuvable pour $url");
if (count($candidates) > 1) {
$this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $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];
}
}