Changement majeur du modèle de channels en réponse au feedback :
« c'est trop compliqué. Il faudrait juste pouvoir tout laisser dans
builds, et taguer une version dans un canal ou un autre voir plusieurs
canaux. ». Le précédent design (1 fichier manifest par channel +
sous-dossier builds/{channel}/) imposait une duplication de structure
et empêchait une version d'être visible sur plusieurs channels à la fois.
NOUVEAU MODÈLE :
- 1 seul fichier manifest/versions.json
- 1 seul dossier builds/ flat (les ZIPs ont des noms libres pour éviter
les collisions homonymes — proserve-1.5.3.zip, proserve-1.5.3-police.zip…)
- Chaque entry de version a un champ `channels: ["default", "police"]`
qui dit qui peut la voir
- Sémantique additive : un client sur channel "police" voit les versions
taggées "default" + celles taggées "police". "default" = public.
- Une version peut être taggée sur plusieurs channels en une fois.
NOUVEAU REGISTRE channels.json :
Le registre des channels (avec name + label + description) vit dans
manifest/channels.json. Le channel "default" est implicite et ne peut
pas être supprimé. Nouvelle page admin Channels (CRUD) pour gérer la
liste, avec garde-fou anti-suppression : compte les versions taggées
et les licenses attribuées avant d'autoriser la suppression.
SERVEUR :
- api/routes/Manifest.php : reçoit ?channel=X depuis la license,
filtre versions où channels[] contient X ou "default", re-signe avec
la clé privée Ed25519 à la volée. Coût ~1ms par requête.
- admin/versions.php : refactor — plus de session "channel actif", plus
de switcher en haut, plus de prefix builds/{channel}/. Add form a
des checkboxes channels[]. Nouveau bouton "Channels" par-row pour
retager.
- admin/licenses.php : dropdown channel alimenté depuis channels.json
au lieu de scanner les fichiers manifest.
- tools/SignManifest.php : revert du constructeur channel-aware,
toujours sur versions.json + builds/ flat.
CLIENT :
- VersionManifest.Channels (List<string>) ajouté pour debug, mais le
filtrage est server-side donc le client n'a rien à faire de ce champ
côté UX.
- L'envoi du ?channel=X depuis la license signée fonctionne déjà
depuis v0.26.0, pas de changement nécessaire.
MIGRATION :
Les fichiers manifest/versions-{X}.json créés en v0.26.0 deviennent
inutiles. Si tu en avais (test "police" par exemple), copie les
versions concernées dans versions.json et tague-les avec les bons
channels via la nouvelle UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
15 KiB
PHP
308 lines
15 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;
|
||
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';
|
||
// 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', bool $force = false, ?string $onlyVersion = null): 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');
|
||
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");
|
||
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;
|
||
}
|
||
}
|
||
|
||
$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);
|
||
|
||
// 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);
|
||
$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];
|
||
}
|
||
}
|