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:
@@ -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<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'): 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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user