The launcher and the game versions are different release cadences managed by the same operator. Mixing them on one page muddied the workflow. Split them into two top-level nav entries. SignManifest::run(string $scope) -------------------------------- - 'all' (default, used by CLI tools/sign-manifest.php) — unchanged. - 'versions' — only re-hashes the Proserve ZIPs and bumps `latest`. - 'launcher' — only re-hashes the launcher EXE. The Ed25519 sign step always runs at the end so the manifest stays verifiable. Selectively hashing avoids unrelated noise (e.g. mass-hash 14 GB ZIPs when all you wanted was to update the launcher exe). admin/launcher.php (new page) ----------------------------- Self-contained page with the launcher state, Set/Remove forms, the blue "🔁 Hasher le launcher + signer" button, and a list of the .exe files present in builds/launcher/. Workflow doc inline. admin/versions.php ------------------ Cleaned up: launcher card and its set_launcher / remove_launcher / sync_launcher actions removed. The remaining global Sync button is relabeled and now triggers scope='versions' (only Proserve ZIPs). Layout::navHtml gains a "Launcher" item between Versions and Audit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
6.7 KiB
PHP
155 lines
6.7 KiB
PHP
<?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;
|
|
/** @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';
|
|
}
|
|
|
|
private function out(string $line): void { $this->log[] = $line; }
|
|
|
|
/**
|
|
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
|
|
*
|
|
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
|
|
* @return array{ok:bool, log:string[]}
|
|
*/
|
|
public function run(string $scope = 'all'): array
|
|
{
|
|
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');
|
|
|
|
$hashedVersions = [];
|
|
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
|
$version = $v['version'] ?? '?';
|
|
$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);
|
|
$sha = hash_file('sha256', $zip);
|
|
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}");
|
|
|
|
$v['download']['sizeBytes'] = $size;
|
|
$v['download']['sha256'] = $sha;
|
|
$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);
|
|
$lsha = hash_file('sha256', $lpath);
|
|
$launcher['download']['sizeBytes'] = $lsize;
|
|
$launcher['download']['sha256'] = $lsha;
|
|
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$lsha}");
|
|
} else {
|
|
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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];
|
|
}
|
|
|
|
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
|
|
return ['ok' => true, 'log' => $this->log];
|
|
}
|
|
}
|