v0.27.0 — Channels comme TAGS sur versions (refactor du modèle)
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>
This commit is contained in:
@@ -17,19 +17,9 @@ final class SignManifest
|
||||
/** @var string[] */
|
||||
public array $log = [];
|
||||
|
||||
/**
|
||||
* @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/).
|
||||
* @param string|null $channel Channel à signer. NULL/'' = manifest default
|
||||
* (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}.
|
||||
* Le hashcache est partagé entre tous les channels (les ZIPs peuvent être
|
||||
* référencés par plusieurs manifests, économise les recalculs).
|
||||
*/
|
||||
public function __construct(string $rootDir, ?string $channel = null)
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
|
||||
? "versions-{$channel}.json"
|
||||
: 'versions.json';
|
||||
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
|
||||
$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
|
||||
@@ -41,32 +31,6 @@ final class SignManifest
|
||||
|
||||
private function out(string $line): void { $this->log[] = $line; }
|
||||
|
||||
/**
|
||||
* Mappe le chemin URL d'un ZIP (ex. /PS_Launcher/builds/asterion-vr/proserve-1.4.6.zip)
|
||||
* vers son chemin local sur disque (ex. {$buildsDir}/asterion-vr/proserve-1.4.6.zip).
|
||||
*
|
||||
* Stratégie : on cherche le segment '/builds/' dans le URL path et on prend
|
||||
* tout ce qui vient après comme chemin relatif depuis $buildsDir. Sécurité :
|
||||
* on neutralise '..' pour empêcher path traversal, même si l'URL est déjà
|
||||
* sous notre contrôle (manifest signé Ed25519).
|
||||
*
|
||||
* Si le pattern '/builds/' n'est pas trouvé, fallback sur basename comme avant
|
||||
* (compat ascendante avec d'éventuelles URLs custom).
|
||||
*/
|
||||
private function resolveZipPath(string $urlPath): string
|
||||
{
|
||||
$marker = '/builds/';
|
||||
$pos = strpos($urlPath, $marker);
|
||||
if ($pos === false) {
|
||||
return "{$this->buildsDir}/" . basename($urlPath);
|
||||
}
|
||||
$relative = substr($urlPath, $pos + strlen($marker));
|
||||
// Sécu : strip '..' segments pour éviter path traversal hors de buildsDir
|
||||
$relative = str_replace(['..\\', '../', '..'], '', $relative);
|
||||
$relative = ltrim($relative, '/\\');
|
||||
return "{$this->buildsDir}/$relative";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
||||
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
||||
@@ -176,29 +140,19 @@ final class SignManifest
|
||||
continue;
|
||||
}
|
||||
|
||||
// Le ZIP peut vivre dans builds/ directement (channel default) OU dans
|
||||
// builds/{channel}/ pour les autres channels. On extrait donc TOUT le
|
||||
// chemin après /builds/ dans l'URL plutôt qu'un simple basename().
|
||||
$urlPath = parse_url($url, PHP_URL_PATH) ?: '';
|
||||
$filename = basename($urlPath);
|
||||
$zip = $this->resolveZipPath($urlPath);
|
||||
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
|
||||
$zip = "{$this->buildsDir}/$filename";
|
||||
|
||||
if (!is_file($zip)) {
|
||||
// Fallback : recherche fuzzy sur la version dans builds/ + sous-dossiers
|
||||
// immédiats (1 niveau) au cas où l'admin aurait renommé le ZIP.
|
||||
$candidates = array_merge(
|
||||
glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [],
|
||||
glob("{$this->buildsDir}/*/*{$version}*.zip", GLOB_NOSORT) ?: []
|
||||
);
|
||||
$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é '" . str_replace($this->buildsDir . '/', '', $zip) . "' à la place");
|
||||
$this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place");
|
||||
} else {
|
||||
$this->out(" [skip] $version : ZIP introuvable pour $url");
|
||||
$this->out(" Cherché : " . str_replace($this->buildsDir . '/', '', $zip));
|
||||
if (count($candidates) > 1) {
|
||||
$this->out(" Plusieurs candidats : " . implode(', ', array_map(fn($c) => str_replace($this->buildsDir . '/', '', $c), $candidates)));
|
||||
$this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user