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>
This commit is contained in:
2026-05-02 19:14:32 +02:00
parent 0d4f126e3a
commit 962f5a8ce0
8 changed files with 406 additions and 32 deletions

5
.gitignore vendored
View File

@@ -30,8 +30,13 @@ ASTERION_VR/
# Server-side secrets — never commit (config.example.php sert de template) # Server-side secrets — never commit (config.example.php sert de template)
server/api/config.php 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/*
!server/builds/.gitkeep !server/builds/.gitkeep
!server/builds/.htaccess
!server/builds/gate.php
!server/builds/launcher/.gitkeep
# Plans # Plans
.claude/ .claude/

View File

@@ -70,8 +70,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
elseif ($action === 'sync_launcher') { elseif ($action === 'sync_launcher') {
require_once "$root/tools/SignManifest.php"; require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root); $signer = new \PSLauncher\Tools\SignManifest($root);
$result = $signer->run('launcher'); $force = !empty($_POST['force']);
$message = "Sortie du script (scope: launcher) :\n" . implode("\n", $result['log']); $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'; if (!$result['ok']) $messageType = 'error';
} }
} catch (Exception $e) { } catch (Exception $e) {
@@ -159,6 +161,9 @@ Layout::header('Launcher', 'launcher');
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_launcher"> <input type="hidden" name="action" value="sync_launcher">
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button> <button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule le SHA-256 même si l'exe n'a pas changé.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form> </form>
<?php if ($launcher): ?> <?php if ($launcher): ?>

View File

@@ -136,11 +136,56 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once "$root/tools/SignManifest.php"; require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root); $signer = new \PSLauncher\Tools\SignManifest($root);
$scope = $action === 'sync_versions' ? 'versions' : 'all'; $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'; $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'; 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) { } catch (Exception $e) {
$message = $e->getMessage(); $message = $e->getMessage();
$messageType = 'error'; $messageType = 'error';
@@ -221,6 +266,9 @@ Layout::header('Versions', 'versions');
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_versions"> <input type="hidden" name="action" value="sync_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button> <button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule TOUS les SHA-256 même si les ZIPs n'ont pas changé. Lent. Cocher seulement en cas de doute sur le cache.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form> </form>
</div> </div>
@@ -241,6 +289,7 @@ Layout::header('Versions', 'versions');
$zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? ''); $zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
$zipExists = in_array($zipBase, $zips, true); $zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE'); $hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
?> ?>
<tr> <tr>
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td> <td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
@@ -256,7 +305,9 @@ Layout::header('Versions', 'versions');
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($hashed): ?> <?php if ($hashSkipped): ?>
<span class="badge badge-warning" title="hashAlgorithm=none : la vérif SHA-256 est désactivée pour cette release">SKIP</span>
<?php elseif ($hashed): ?>
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code> <code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning">à calculer</span> <span class="badge badge-warning">à calculer</span>
@@ -273,6 +324,47 @@ Layout::header('Versions', 'versions');
</form> </form>
</td> </td>
<td style="text-align: right; white-space: nowrap;"> <td style="text-align: right; white-space: nowrap;">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">⚙ Hash</summary>
<div style="margin-top: 8px; min-width: 280px;">
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
uniquement sur la signature Ed25519 du manifest + HTTPS.
<?php else: ?>
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é).
<?php endif; ?>
</p>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button>
</form>
<?php if ($zipExists): ?>
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
<?php endif; ?>
</div>
</details>
<details style="display: inline-block; margin: 0 4px;"> <details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary> <summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px;"> <form method="post" style="margin-top: 8px;">

View File

@@ -96,9 +96,14 @@ final class DownloadUrl
} }
// Génère l'URL HMAC-signée // 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'], '/'); $baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip'; $relPath = '/builds/proserve-' . $version . '.zip';
$exp = time() + 3600; // 1 h $exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? ''; $secret = $config['hmac_secret'] ?? '';
if ($secret === '') { if ($secret === '') {
Response::error('config_error', 'hmac_secret non configuré', 500); Response::error('config_error', 'hmac_secret non configuré', 500);

9
server/builds/.htaccess Normal file
View File

@@ -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]

93
server/builds/gate.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
/**
* Gardien des téléchargements de builds. Sert un .zip seulement si l'URL est
* accompagnée d'une signature HMAC valide non expirée, signée par la même
* clé que /api/download-url/{version}. Supporte les Range: requests pour la
* reprise (dégradé : on utilise readfile + fseek manuellement).
*
* Pour les hébergements où on veut désactiver la protection (tests internes), il
* suffit de retirer le RewriteRule du .htaccess voisin — Apache servira alors
* directement le fichier statique avec son support natif des Range.
*/
declare(strict_types=1);
ini_set('display_errors', '0');
set_time_limit(0);
ignore_user_abort(true);
require __DIR__ . '/../api/lib/Crypto.php';
$config = require __DIR__ . '/../api/config.php';
$secret = $config['hmac_secret'] ?? '';
function deny(string $reason, int $code = 403): never
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden: $reason\n";
exit;
}
$file = trim((string)($_GET['file'] ?? ''), '/');
if ($file === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $file)) deny('invalid file');
$path = __DIR__ . '/' . $file;
if (!is_file($path)) deny('not found', 404);
$exp = (int)($_GET['exp'] ?? 0);
$lic = (string)($_GET['lic'] ?? '');
$sig = (string)($_GET['sig'] ?? '');
if ($exp <= 0 || $lic === '' || $sig === '') deny('missing params');
if ($exp < time()) deny('expired');
if ($secret === '') deny('server config error', 500);
$relPath = '/builds/' . $file;
$expected = \PSLauncher\Crypto::hmacHex($relPath . '|' . $exp . '|' . $lic, $secret);
if (!hash_equals($expected, $sig)) deny('bad signature');
// Sert le fichier avec support Range
$size = filesize($path);
$mtime = filemtime($path) ?: 0;
$start = 0;
$end = $size - 1;
header('Content-Type: application/zip');
header('Accept-Ranges: bytes');
// ⚠️ NE PAS utiliser md5_file($path) ici : sur un fichier 14 Go, ça relit
// l'intégralité du ZIP à chaque requête (~30 s-5 min selon disque). Avec
// 8 segments parallèles + HEAD probe, ça multipliait par 9 le temps avant
// le premier byte. On utilise un ETag dérivé size+mtime, équivalent à ce
// qu'Apache fait nativement pour les fichiers statiques. Change quand on
// upload un nouveau ZIP, donc cache invalidation correcte.
header('ETag: "' . dechex($size) . '-' . dechex($mtime) . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
header('Cache-Control: private, max-age=3600');
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
if ($m[2] !== '') $end = min((int)$m[2], $size - 1);
if ($start > $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);

View File

@@ -13,6 +13,7 @@ final class SignManifest
public string $manifestPath; public string $manifestPath;
public string $buildsDir; public string $buildsDir;
public ?string $configPath; public ?string $configPath;
public string $hashCachePath;
/** @var string[] */ /** @var string[] */
public array $log = []; public array $log = [];
@@ -21,17 +22,87 @@ final class SignManifest
$this->manifestPath = $rootDir . '/manifest/versions.json'; $this->manifestPath = $rootDir . '/manifest/versions.json';
$this->buildsDir = $rootDir . '/builds'; $this->buildsDir = $rootDir . '/builds';
$this->configPath = $rootDir . '/api/config.php'; $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; } 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. * 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 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[]} * @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)) { if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}"); $this->out("Manifest introuvable : {$this->manifestPath}");
@@ -46,10 +117,23 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions'); $doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher'); $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 = []; $hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) { if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?'; $version = $v['version'] ?? '?';
if ($onlyVersion !== null && $version !== $onlyVersion) {
continue; // skip silently les autres versions
}
$url = $v['download']['url'] ?? ''; $url = $v['download']['url'] ?? '';
if ($url === '') { if ($url === '') {
$this->out(" [skip] $version : pas d'URL dans le manifest"); $this->out(" [skip] $version : pas d'URL dans le manifest");
@@ -74,12 +158,45 @@ final class SignManifest
} }
} }
$size = filesize($zip); $size = filesize($zip) ?: 0;
$sha = hash_file('sha256', $zip);
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}");
$v['download']['sizeBytes'] = $size; $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; $hashedVersions[] = $version;
} }
unset($v); unset($v);
@@ -99,11 +216,27 @@ final class SignManifest
if (count($candidates) === 1) $lpath = $candidates[0]; if (count($candidates) === 1) $lpath = $candidates[0];
} }
if (is_file($lpath)) { if (is_file($lpath)) {
$lsize = filesize($lpath); $lsize = filesize($lpath) ?: 0;
$lsha = hash_file('sha256', $lpath);
$launcher['download']['sizeBytes'] = $lsize; $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 { } else {
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/"); $this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
} }
@@ -111,16 +244,27 @@ final class SignManifest
unset($launcher); unset($launcher);
} }
// Bump auto de `latest` sur la plus haute version effectivement uploadée // Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
// (uniquement si on a touché aux versions) // hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
if ($doVersions && !empty($hashedVersions)) { // qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
usort($hashedVersions, 'version_compare'); // ancienne version isolée via $onlyVersion.
$newLatest = end($hashedVersions); 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) { if (($manifest['latest'] ?? null) !== $newLatest) {
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}"); $this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
$manifest['latest'] = $newLatest; $manifest['latest'] = $newLatest;
} }
} }
}
// Signature Ed25519 // Signature Ed25519
if ($this->configPath && is_file($this->configPath)) { if ($this->configPath && is_file($this->configPath)) {
@@ -148,6 +292,15 @@ final class SignManifest
return ['ok' => false, 'log' => $this->log]; 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))."); $this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
return ['ok' => true, 'log' => $this->log]; return ['ok' => true, 'log' => $this->log];
} }

View File

@@ -2,7 +2,11 @@
/** /**
* Wrapper CLI pour PSLauncher\Tools\SignManifest. * 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). * Le backoffice admin appelle directement la classe (pas d'exec).
*/ */
@@ -10,7 +14,15 @@ declare(strict_types=1);
require __DIR__ . '/SignManifest.php'; 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__)); $signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
$result = $signer->run(); $result = $signer->run($scope, $force);
foreach ($result['log'] as $line) echo $line . "\n"; foreach ($result['log'] as $line) echo $line . "\n";
exit($result['ok'] ? 0 : 1); exit($result['ok'] ? 0 : 1);