Files
PS_Launcher/server/tools/SignManifest.php
j.foucher b0ca082b52 sync_one : hash uniquement l'entrée cliquée, pas toutes celles du même numéro
Bug : sur un manifest avec deux entrées partageant le même numéro de version
(channels multiples — ex proserve-firefighter-1.5.4.32.zip ET proserve-full-
1.5.4.32.zip, deux builds distincts pour firefighter vs full), cliquer le
bouton « 🔁 Hash » d'une ligne déclenchait le hash des DEUX ZIPs dans la
même requête HTTP. Résultat 2 × 14 Go = 28 Go dans une seule requête, ce
qui dépasse le timeout front d'OVH mutualisé (hard-limit invisible côté
PHP même avec set_time_limit(0)) → 500 Internal Server Error.

C'est pour ça que « des hashs de la même taille passaient sans problème
avant » : c'est le cas 28 Go des DEUX ZIPs en une requête qui est nouveau
(depuis l'introduction des channels), pas la taille du single 14 Go qui
passait déjà.

Fix : nouveau paramètre $onlyEntryId sur SignManifest::run(). Prend le pas
sur $onlyVersion. L'admin action sync_one passe l'entryId (unique par
ligne, généré par generate_entry_id()) au lieu du numéro de version. Une
ligne cliquée = un ZIP hashé, point. Le bouton « 🔁 Sync (all versions) »
(action sync_versions) continue de tout hasher — c'est ce que l'opérateur
demande explicitement.

Rétro-compat : si $onlyEntryId est null (CLI, cron, anciens callers),
$onlyVersion filtre comme avant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:42:39 +02:00

421 lines
22 KiB
PHP
Raw Permalink 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.
*
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
* supplantait silencieusement le `--force` du caller — bug subtil parce
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
* ou force=true), mais ce helper retombait sur sa propre cache lookup
* (clé = realpath) → retournait instantanément l'ancien hash sans
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
* SFTP avec mtime préservé.
*
* @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, bool $force = false): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (!$force
&& 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);
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
// au lieu de faire un unique set_time_limit(0) à la caller.
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
$fp = @fopen($path, 'rb');
if ($fp === false) {
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
}
try {
$ctx = hash_init('sha256');
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
$chunkBytes = 16 * 1024 * 1024;
while (!feof($fp)) {
hash_update_stream($ctx, $fp, $chunkBytes);
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
@set_time_limit(300);
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
// timeout sur les grosses requêtes. Silencieux si output buffering
// est actif (pas fatal).
@ob_flush();
@flush();
}
$sha = hash_final($ctx);
} finally {
fclose($fp);
}
$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. ATTENTION : si plusieurs entrées
* partagent le même numéro de version (channels firefighter
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
* $onlyEntryId ci-dessous pour cibler une seule ligne.
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
* generate_entry_id() au backoffice). Prend le pas sur
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
* ligne isolée pour éviter de re-hasher les autres channels
* qui partagent le même numéro de version (2 × 14 Go dans une
* requête HTTP → risque de timeout front OVH).
* @return array{ok:bool, log:string[]}
*/
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
{
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
// → Apache retourne 500 Internal Server Error avec le boilerplate
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
@set_time_limit(0);
@ignore_user_abort(true);
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
// les logs Apache ne sont accessibles que via le manager web — pas pratique
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
// grep post-clic sans passer par le manager.
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
@ini_set('log_errors', '1');
@ini_set('error_log', $errorLogPath);
@error_reporting(E_ALL);
$ts = date('Y-m-d H:i:s');
@file_put_contents($errorLogPath,
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
. ", onlyVersion=" . ($onlyVersion ?? 'null')
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
FILE_APPEND);
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
// juste 500 sans qu'on sache ce qui a claqué.
register_shutdown_function(function () use ($errorLogPath) {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
FILE_APPEND);
}
});
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 || $onlyEntryId !== null) {
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
// et on ne hash que ce qui correspond au filtre demandé dans 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'] ?? '?';
$entryId = (string)($v['id'] ?? '');
// Filtre par id d'entrée prioritaire sur filtre par version (cas
// channels multiples partageant un numéro de version identique —
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
// Sinon, filtre par numéro de version (rétro-compat).
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
continue;
}
if ($onlyEntryId === null && $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;
}
}
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
// planter le process si on retombe sur le 500. Sans ça, l'error log
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] START hash $version$zip ($size octets)\n",
FILE_APPEND);
try {
$r = $this->getOrComputeSha256($zip, $cache, $force);
} catch (\Throwable $e) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
. $e->getTraceAsString() . "\n",
FILE_APPEND);
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
continue;
}
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
FILE_APPEND);
$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, $force);
$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];
}
}