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 */ 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 $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 $force 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 $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]; } }