diff --git a/.gitignore b/.gitignore index 77132f8..6cd8af8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,13 @@ ASTERION_VR/ # Server-side secrets — never commit (config.example.php sert de template) server/api/config.php +# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on +# garde le code (.htaccess, gate.php, .gitkeep) sous version. server/builds/* !server/builds/.gitkeep +!server/builds/.htaccess +!server/builds/gate.php +!server/builds/launcher/.gitkeep # Plans .claude/ diff --git a/server/admin/launcher.php b/server/admin/launcher.php index a93e8b6..8c9a234 100644 --- a/server/admin/launcher.php +++ b/server/admin/launcher.php @@ -70,8 +70,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { elseif ($action === 'sync_launcher') { require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); - $result = $signer->run('launcher'); - $message = "Sortie du script (scope: launcher) :\n" . implode("\n", $result['log']); + $force = !empty($_POST['force']); + $result = $signer->run('launcher', $force); + $forceLabel = $force ? ' [FORCE: cache ignoré]' : ''; + $message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } } catch (Exception $e) { @@ -159,6 +161,9 @@ Layout::header('Launcher', 'launcher'); + diff --git a/server/admin/versions.php b/server/admin/versions.php index 13abe4a..f31318b 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -136,11 +136,56 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $scope = $action === 'sync_versions' ? 'versions' : 'all'; - $result = $signer->run($scope); + $force = !empty($_POST['force']); + $result = $signer->run($scope, $force); $label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet'; - $message = "Sortie du script (scope: {$label}) :\n" . implode("\n", $result['log']); + $forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]'; + $message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } + elseif ($action === 'sync_one') { + $version = $_POST['version'] ?? ''; + if ($version === '') throw new Exception("Version manquante"); + require_once "$root/tools/SignManifest.php"; + $signer = new \PSLauncher\Tools\SignManifest($root); + $force = !empty($_POST['force']); + $result = $signer->run('versions', $force, $version); + $forceLabel = $force ? ' [FORCE]' : ''; + $message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']); + if (!$result['ok']) $messageType = 'error'; + } + elseif ($action === 'set_skip_hash') { + $version = $_POST['version'] ?? ''; + $skipHash = !empty($_POST['skip']); + foreach ($manifest['versions'] as &$v) { + if ($v['version'] === $version) { + if ($skipHash) { + $v['download']['hashAlgorithm'] = 'none'; + $v['download']['sha256'] = ''; + } else { + unset($v['download']['hashAlgorithm']); + if (empty($v['download']['sha256'])) { + $v['download']['sha256'] = 'REPLACE_AFTER_BUILD'; + } + } + break; + } + } + unset($v); + saveManifest($manifestPath, $manifest); + + // Re-signature auto pour garder le manifest valide après le changement. + // En mode skip, on n'a rien à hasher : on appelle run() qui se contentera + // de détecter hashAlgorithm=none et re-signera. Pas de calcul lourd. + require_once "$root/tools/SignManifest.php"; + $signer = new \PSLauncher\Tools\SignManifest($root); + $resign = $signer->run('versions', false); + $message = ($skipHash + ? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé." + : "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.") + . "\n" . implode("\n", $resign['log']); + if (!$resign['ok']) $messageType = 'error'; + } } catch (Exception $e) { $message = $e->getMessage(); $messageType = 'error'; @@ -221,6 +266,9 @@ Layout::header('Versions', 'versions'); + @@ -238,9 +286,10 @@ Layout::header('Versions', 'versions'); v @@ -256,7 +305,9 @@ Layout::header('Versions', 'versions'); - + + SKIP + à calculer @@ -273,6 +324,47 @@ Layout::header('Versions', 'versions'); + +
+ + + + +
+ +
+ ⚙ Hash +
+

+ + La vérif SHA-256 est désactivée pour cette release. + Le manifest sert hashAlgorithm: "none". La sécurité repose + uniquement sur la signature Ed25519 du manifest + HTTPS. + + Désactive la vérif SHA-256 chez les clients pour cette release + (utile sur très gros builds où on accepte le compromis perf/sécurité). + +

+
+ + + + + +
+ +
+ + + + + +
+ +
+
Méta
diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php index b9163ef..f4bde57 100644 --- a/server/api/routes/DownloadUrl.php +++ b/server/api/routes/DownloadUrl.php @@ -96,9 +96,14 @@ final class DownloadUrl } // Génère l'URL HMAC-signée + // TTL : 6 h. Compromis entre : + // - sécurité (limite la fenêtre de replay si une URL fuit) + // - utilisabilité (un user en ADSL 8 Mbps mettra ~4 h pour DL 14 Go) + // Pour une connexion plus lente, le client sait auto-refresher l'URL + // pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL). $baseUrl = rtrim($config['base_url'], '/'); $relPath = '/builds/proserve-' . $version . '.zip'; - $exp = time() + 3600; // 1 h + $exp = time() + 21600; // 6 h $secret = $config['hmac_secret'] ?? ''; if ($secret === '') { Response::error('config_error', 'hmac_secret non configuré', 500); diff --git a/server/builds/.htaccess b/server/builds/.htaccess new file mode 100644 index 0000000..535a0a9 --- /dev/null +++ b/server/builds/.htaccess @@ -0,0 +1,9 @@ +# Tout accès direct à un .zip passe d'abord par le gardien gate.php qui vérifie +# la signature HMAC + l'expiration. Si OK, le gardien sert le fichier (avec +# support Range pour la reprise). Sinon, 403. +RewriteEngine On +RewriteBase /PS_Launcher/builds/ + +RewriteCond %{REQUEST_FILENAME} -f +RewriteCond %{REQUEST_URI} \.zip$ +RewriteRule ^(.*)$ gate.php?file=$1 [QSA,L] diff --git a/server/builds/gate.php b/server/builds/gate.php new file mode 100644 index 0000000..1a6c48f --- /dev/null +++ b/server/builds/gate.php @@ -0,0 +1,93 @@ + $end || $start >= $size) { + http_response_code(416); + header("Content-Range: bytes */$size"); + exit; + } + http_response_code(206); + header("Content-Range: bytes $start-$end/$size"); +} + +$length = $end - $start + 1; +header('Content-Length: ' . $length); + +$fp = fopen($path, 'rb'); +fseek($fp, $start); +$bufSize = 1 << 20; // 1 MiB +$remaining = $length; +while ($remaining > 0 && !feof($fp) && !connection_aborted()) { + $chunk = fread($fp, (int)min($bufSize, $remaining)); + if ($chunk === false) break; + echo $chunk; + @ob_flush(); + @flush(); + $remaining -= strlen($chunk); +} +fclose($fp); diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php index 2bb7e92..cc302dd 100644 --- a/server/tools/SignManifest.php +++ b/server/tools/SignManifest.php @@ -13,25 +13,96 @@ 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'; + $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 + */ + 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. + * + * @param array $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'): array + public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array { if (!is_file($this->manifestPath)) { $this->out("Manifest introuvable : {$this->manifestPath}"); @@ -46,10 +117,23 @@ final class SignManifest $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"); @@ -74,12 +158,45 @@ final class SignManifest } } - $size = filesize($zip); - $sha = hash_file('sha256', $zip); - $this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}"); - + $size = filesize($zip) ?: 0; $v['download']['sizeBytes'] = $size; - $v['download']['sha256'] = $sha; + + // 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); @@ -99,11 +216,27 @@ final class SignManifest if (count($candidates) === 1) $lpath = $candidates[0]; } if (is_file($lpath)) { - $lsize = filesize($lpath); - $lsha = hash_file('sha256', $lpath); + $lsize = filesize($lpath) ?: 0; $launcher['download']['sizeBytes'] = $lsize; - $launcher['download']['sha256'] = $lsha; - $this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$lsha}"); + + // 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/"); } @@ -111,14 +244,25 @@ final class SignManifest unset($launcher); } - // Bump auto de `latest` sur la plus haute version effectivement uploadée - // (uniquement si on a touché aux versions) - if ($doVersions && !empty($hashedVersions)) { - usort($hashedVersions, 'version_compare'); - $newLatest = end($hashedVersions); - if (($manifest['latest'] ?? null) !== $newLatest) { - $this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}"); - $manifest['latest'] = $newLatest; + // 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; + } } } @@ -148,6 +292,15 @@ final class SignManifest 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]; } diff --git a/server/tools/sign-manifest.php b/server/tools/sign-manifest.php index 927aa7b..9e71f1f 100644 --- a/server/tools/sign-manifest.php +++ b/server/tools/sign-manifest.php @@ -2,7 +2,11 @@ /** * Wrapper CLI pour PSLauncher\Tools\SignManifest. * - * Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php + * Usage : + * cd ~/www/PS_Launcher && php tools/sign-manifest.php + * php tools/sign-manifest.php --scope=launcher # ne re-signe que la section launcher + * php tools/sign-manifest.php --scope=versions # ne re-signe que les builds Proserve + * php tools/sign-manifest.php --force # ignore le cache, recalcule tous les SHA-256 * * Le backoffice admin appelle directement la classe (pas d'exec). */ @@ -10,7 +14,15 @@ declare(strict_types=1); require __DIR__ . '/SignManifest.php'; +$scope = 'all'; +$force = false; +foreach (array_slice($argv, 1) as $arg) { + if ($arg === '--force' || $arg === '-f') $force = true; + elseif (str_starts_with($arg, '--scope=')) $scope = substr($arg, 8); + elseif (in_array($arg, ['versions', 'launcher', 'all'], true)) $scope = $arg; +} + $signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__)); -$result = $signer->run(); +$result = $signer->run($scope, $force); foreach ($result['log'] as $line) echo $line . "\n"; exit($result['ok'] ? 0 : 1);