From 49a3b855af7e5f0961571cb8e70857c4ceeb12e6 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 5 May 2026 08:51:10 +0200 Subject: [PATCH] =?UTF-8?q?v0.27.0=20=E2=80=94=20Channels=20comme=20TAGS?= =?UTF-8?q?=20sur=20versions=20(refactor=20du=20mod=C3=A8le)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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) --- installer/PSLauncher.iss | 2 +- server/admin/channels.php | 231 +++++++++++++++++ server/admin/lib/Channels.php | 96 +++++++ server/admin/lib/Layout.php | 1 + server/admin/licenses.php | 27 +- server/admin/versions.php | 304 +++++++++-------------- server/api/routes/Manifest.php | 93 +++++-- server/tools/SignManifest.php | 60 +---- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- src/PSLauncher.Models/RemoteManifest.cs | 15 ++ 10 files changed, 559 insertions(+), 276 deletions(-) create mode 100644 server/admin/channels.php create mode 100644 server/admin/lib/Channels.php diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 3414565..d47be20 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.26.0" +#define MyAppVersion "0.27.0" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/admin/channels.php b/server/admin/channels.php new file mode 100644 index 0000000..c1589b6 --- /dev/null +++ b/server/admin/channels.php @@ -0,0 +1,231 @@ + $name, 'label' => $label]; + if ($desc !== null) $entry['description'] = $desc; + $registry['channels'][] = $entry; + Channels::save($manifestDir, $registry); + $message = "Channel « {$name} » créé."; + if ($name !== $rawName) { + $message .= " (Nom normalisé depuis « {$rawName} ».)"; + } + } + + elseif ($action === 'edit') { + $name = trim((string)($_POST['name'] ?? '')); + $label = trim((string)($_POST['label'] ?? '')) ?: $name; + $desc = trim((string)($_POST['description'] ?? '')) ?: null; + $found = false; + foreach ($registry['channels'] as &$c) { + if (($c['name'] ?? '') === $name) { + $c['label'] = $label; + if ($desc === null) unset($c['description']); + else $c['description'] = $desc; + $found = true; + break; + } + } + unset($c); + if (!$found) throw new \RuntimeException("Channel « {$name} » introuvable."); + Channels::save($manifestDir, $registry); + $message = "Channel « {$name} » mis à jour."; + } + + elseif ($action === 'delete') { + $name = trim((string)($_POST['name'] ?? '')); + if ($name === 'default') throw new \RuntimeException("Le channel « default » ne peut pas être supprimé."); + + // Sécurité : empêche la suppression si des versions ou licenses + // référencent encore ce channel — sinon ces refs deviennent silencieusement + // pendantes et l'admin oublie pourquoi tel client ne voit rien. + $usage = checkChannelUsage($manifestDir, $name); + if ($usage['versions'] > 0 || $usage['licenses'] > 0) { + throw new \RuntimeException( + "Impossible de supprimer « {$name} » : " + . "{$usage['versions']} version(s) taggée(s) et {$usage['licenses']} license(s) attribuée(s) à ce channel. " + . "Détague d'abord les versions (page Versions) et change les licenses concernées (page Licenses)." + ); + } + + $registry['channels'] = array_values(array_filter( + $registry['channels'], + fn($c) => ($c['name'] ?? '') !== $name + )); + Channels::save($manifestDir, $registry); + $message = "Channel « {$name} » supprimé."; + } + } catch (\Throwable $e) { + $message = $e->getMessage(); + $messageType = 'error'; + } +} + +/** + * Compte les versions et licenses qui pointent encore sur un channel. + * Sert de garde-fou avant suppression. + */ +function checkChannelUsage(string $manifestDir, string $channelName): array +{ + $usage = ['versions' => 0, 'licenses' => 0]; + + $manifestPath = $manifestDir . '/versions.json'; + if (is_file($manifestPath)) { + $m = json_decode(file_get_contents($manifestPath), true); + if (is_array($m) && isset($m['versions'])) { + foreach ($m['versions'] as $v) { + $channels = $v['channels'] ?? []; + if (in_array($channelName, $channels, true)) $usage['versions']++; + } + } + } + + try { + $config = require __DIR__ . '/../api/config.php'; + $db = \PSLauncher\Db::get($config); + $stmt = $db->prepare('SELECT COUNT(*) FROM licenses WHERE channel = ?'); + $stmt->execute([$channelName]); + $usage['licenses'] = (int)$stmt->fetchColumn(); + } catch (\Throwable) { /* DB not reachable from this context, best effort */ } + + return $usage; +} + +require_once __DIR__ . '/../api/lib/Db.php'; + +$registry = Channels::load($manifestDir); + +Layout::header('Channels', 'channels'); +?> +

Channels de distribution

+ + + +
+

Comment ça marche

+
    +
  • default = channel public implicite. Toutes les versions taggées « default » (ou sans tag) sont visibles par tous les clients, peu importe leur license.
  • +
  • Les autres channels (asterion-vr, police, …) sont des canaux privés. Une version taggée uniquement sur un channel privé n'est vue QUE par les licenses attribuées à ce channel.
  • +
  • Une version peut être taggée sur plusieurs channels en même temps (multi-channel). Une license n'est attribuée qu'à un seul channel à la fois.
  • +
  • Sémantique additive : un client sur le channel « police » voit les versions « default » + les versions « police ». Pour rendre une version invisible au public, ne la tague PAS « default ».
  • +
+
+ +
+

Créer un channel

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+

Channels existants

+ + + + + + + + + + + + + + + + + + + +
NomLabelDescriptionActions
+ + + implicite + + +
+ Éditer +
+ + + +
+ + +
+
+ + +
+ +
+
+ +
+ + + + +
+ +
+
+ +} */ + public static function load(string $manifestDir): array + { + $path = $manifestDir . '/channels.json'; + $base = ['channels' => []]; + if (is_file($path)) { + $raw = file_get_contents($path); + $json = json_decode((string)$raw, true); + if (is_array($json) && isset($json['channels']) && is_array($json['channels'])) { + $base['channels'] = $json['channels']; + } + } + + // Garantit que "default" est toujours présent en tête (immutable depuis la UI). + $hasDefault = false; + foreach ($base['channels'] as $c) { + if (($c['name'] ?? '') === 'default') { $hasDefault = true; break; } + } + if (!$hasDefault) { + array_unshift($base['channels'], [ + 'name' => 'default', + 'label' => 'Public', + 'description' => 'Versions publiques visibles par tous les clients', + ]); + } + + return $base; + } + + public static function save(string $manifestDir, array $registry): void + { + $path = $manifestDir . '/channels.json'; + if (!is_dir($manifestDir)) mkdir($manifestDir, 0755, true); + file_put_contents( + $path, + json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + ); + } + + /** + * Retourne true si $name est un identifiant de channel valide : + * lowercase, chiffres, tirets et underscores, 1 à 64 caractères. + */ + public static function isValidName(string $name): bool + { + return (bool)preg_match('/^[a-z0-9_-]{1,64}$/', $name); + } + + /** + * Normalise un nom saisi par l'admin : lowercase, espaces → tirets, retire + * les chars hors whitelist, compresse les tirets multiples, trim. + * Identique à ps_normalize_channel() dans versions.php — centralisé ici + * pour les futurs ajouts. + */ + public static function normalizeName(string $raw): string + { + $s = strtolower(trim($raw)); + $s = (string)preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s); + $s = (string)preg_replace('/[^a-z0-9_-]/', '', $s); + $s = (string)preg_replace('/-+/', '-', $s); + $s = trim($s, '-_'); + return substr($s, 0, 64); + } + + /** + * @return list Liste des noms de channel pour les dropdowns. + */ + public static function listNames(string $manifestDir): array + { + $reg = self::load($manifestDir); + return array_map(fn($c) => (string)($c['name'] ?? ''), $reg['channels']); + } +} diff --git a/server/admin/lib/Layout.php b/server/admin/lib/Layout.php index c837e3d..2030a80 100644 --- a/server/admin/lib/Layout.php +++ b/server/admin/lib/Layout.php @@ -38,6 +38,7 @@ HTML; 'dashboard' => ['index.php', 'Dashboard'], 'licenses' => ['licenses.php', 'Licenses'], 'versions' => ['versions.php', 'Versions'], + 'channels' => ['channels.php', 'Channels'], 'launcher' => ['launcher.php', 'Launcher'], 'audit' => ['audit.php', 'Audit'], ]; diff --git a/server/admin/licenses.php b/server/admin/licenses.php index 60eaf4a..7397304 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -2,10 +2,12 @@ declare(strict_types=1); require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/Layout.php'; +require __DIR__ . '/lib/Channels.php'; require __DIR__ . '/../api/lib/Db.php'; use PSLauncher\Admin\Auth; use PSLauncher\Admin\Layout; +use PSLauncher\Admin\Channels; use PSLauncher\Db; Auth::requireLogin(); @@ -28,23 +30,22 @@ function generateLicenseKey(): string } /** - * Liste les channels disponibles en scannant manifest/versions-*.json. - * Le channel "default" est toujours présent (= manifest/versions.json). - * Whitelist [a-z0-9_-]{1,64} pour cohérence avec PHP\Routes\Manifest. + * Liste les channels disponibles depuis le registre channels.json. Le channel + * « default » apparaît avec une clé vide (= NULL en DB → comportement legacy + * « pas de tag, voit que default »). */ function listAvailableChannels(): array { - $dir = dirname(__DIR__) . '/manifest'; - $channels = ['' => '(default)']; // clé vide = NULL en DB - if (is_dir($dir)) { - foreach (glob($dir . '/versions-*.json') as $file) { - $name = basename($file); - if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) { - $channels[$m[1]] = $m[1]; - } - } + $manifestDir = dirname(__DIR__) . '/manifest'; + $registry = Channels::load($manifestDir); + $out = ['' => '(aucun — voit uniquement « default »)']; + foreach ($registry['channels'] as $c) { + $name = (string)($c['name'] ?? ''); + if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide) + $label = $c['label'] ?? $name; + $out[$name] = "{$name} — {$label}"; } - return $channels; + return $out; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { diff --git a/server/admin/versions.php b/server/admin/versions.php index f71f1e3..5db4243 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -2,9 +2,11 @@ declare(strict_types=1); require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/Layout.php'; +require __DIR__ . '/lib/Channels.php'; use PSLauncher\Admin\Auth; use PSLauncher\Admin\Layout; +use PSLauncher\Admin\Channels; Auth::requireLogin(); $config = require __DIR__ . '/../api/config.php'; @@ -13,63 +15,30 @@ $root = dirname(__DIR__); $manifestDir = "$root/manifest"; $buildsDir = "$root/builds"; $notesDir = "$root/releasenotes"; - -// Channel actif pour cette session d'édition. ?channel=X bascule sur -// versions-X.json (créé à la volée si nécessaire), vide = manifest default. -// -// Normalisation tolérante : on lowercase, on remplace espaces et tirets-cadratins -// par des '-', on retire tout le reste. Comme ça l'admin peut taper "ASTERION VR" -// dans le champ et ça devient "asterion-vr" — pas de submit silencieusement -// bloqué par une regex stricte côté HTML (ce qui faisait rien quand on cliquait -// "Aller" en v0.26.0). -function ps_normalize_channel(string $raw): string -{ - $s = strtolower(trim($raw)); - // Espaces, tirets typo, tabs, etc. → tiret simple - $s = preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s); - // Retire tout caractère non whitelisté - $s = preg_replace('/[^a-z0-9_-]/', '', $s); - // Compresse les tirets multiples + trim de tirets en début/fin - $s = preg_replace('/-+/', '-', $s); - $s = trim($s, '-_'); - return substr($s, 0, 64); -} - -$channelRaw = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? '')); -$channelNormalized = ps_normalize_channel($channelRaw); -$channelWasNormalized = $channelRaw !== '' && $channelNormalized !== $channelRaw; -$channelEmptyAfterNorm = $channelRaw !== '' && $channelNormalized === ''; - -$channel = $channelNormalized; -$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json"; -$manifestPath = "$manifestDir/$manifestFileName"; +$manifestPath = "$manifestDir/versions.json"; $message = null; $messageType = 'success'; -if ($channelEmptyAfterNorm) { - $message = "Nom de channel inexploitable « {$channelRaw} » (que des caractères non autorisés). Tape un nom avec des lettres / chiffres, ex. asterion-vr."; - $messageType = 'error'; -} elseif ($channelWasNormalized) { - $message = "Nom de channel normalisé : « {$channelRaw} » → « {$channelNormalized} »."; - $messageType = 'success'; -} + +// Liste des channels déclarés dans channels.json (incluant "default" implicite) +$channelRegistry = Channels::load($manifestDir); +$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']); /** - * Liste les channels existants (= versions-*.json présents) + ajoute toujours - * 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire - * "Créer un channel" — le fichier sera créé au premier 'add'. + * Sanitize la liste de channels postée. On garde uniquement ceux qui existent + * dans le registre — un attaquant ne peut pas tagger une version sur un channel + * non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public). */ -function listExistingChannels(string $manifestDir): array +function sanitizeChannels(array $posted, array $allowedNames): array { - $channels = ['' => '(default)']; - if (is_dir($manifestDir)) { - foreach (glob($manifestDir . '/versions-*.json') as $file) { - $name = basename($file); - if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) { - $channels[$m[1]] = $m[1]; - } + $out = []; + foreach ($posted as $c) { + $c = (string)$c; + if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) { + $out[] = $c; } } - return $channels; + if (empty($out)) $out = ['default']; + return $out; } function loadManifest(string $path): array @@ -139,20 +108,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { : (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; - // Pour un channel non-default, le ZIP vit dans builds/{channel}/. - $zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}"; - // Le nom du fichier ZIP est libre : par défaut proserve-{version}.zip, - // mais l'admin peut surcharger pour distinguer des builds homonymes - // entre channels (ex. proserve-1.4.6-asterion.zip vs proserve-1.4.6-foo.zip) - // ou pour un naming custom du fournisseur. + // Tous les ZIPs vivent dans builds/ flat. Le nom du fichier est libre : + // par défaut proserve-{version}.zip, l'admin peut surcharger pour des + // builds homonymes (proserve-1.4.6-police.zip, proserve-1.4.6-asterion.zip…). $zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version); + // Tags : liste des channels qui voient cette version. Sanitized contre + // le registre — un channel inconnu est silencieusement filtré. Vide → ['default']. + $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $entry = [ 'version' => $version, 'releasedAt' => $releasedAtIso, 'executable' => 'PROSERVE_UE_5_5.exe', 'installFolderTemplate' => 'PROSERVE v{version}', + 'channels' => $channels, 'download' => [ - 'url' => "{$base}/{$zipPathPrefix}/{$zipFilename}", + 'url' => "{$base}/builds/{$zipFilename}", 'sizeBytes' => 0, 'sha256' => 'REPLACE_AFTER_BUILD', ], @@ -172,7 +142,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); file_put_contents("$notesDir/{$version}.md", $notes); } - $message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; + $message = "v{$version} ajoutée. Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; } elseif ($action === 'edit_notes') { $version = $_POST['version'] ?? ''; @@ -201,20 +171,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if ($newZipName !== '') { $normalized = ps_normalize_zip_filename($newZipName, $version); - // Reconstruit l'URL en remplaçant juste le filename, pas - // le path. Permet de garder un éventuel sous-dossier custom - // tout en renommant le ZIP. Force aussi la re-vérif du sha256 - // au prochain Sync (le ZIP cherché change physiquement). - $oldUrl = $v['download']['url'] ?? ''; - $parsed = parse_url($oldUrl); - if ($parsed !== false && isset($parsed['path'])) { - $newPath = rtrim(dirname($parsed['path']), '/') . '/' . $normalized; - $newUrl = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? 'asterionvr.com') . $newPath; - $v['download']['url'] = $newUrl; - // Invalide l'ancien hash : un nouveau ZIP est attendu - $v['download']['sha256'] = 'REPLACE_AFTER_BUILD'; - $v['download']['sizeBytes'] = 0; - } + // Reconstruit l'URL : tous les ZIPs sont à la racine de builds/ + // donc on remplace juste le filename, sans sous-dossier. + $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; + $v['download']['url'] = "{$base}/builds/{$normalized}"; + // Invalide l'ancien hash : un nouveau ZIP est attendu + $v['download']['sha256'] = 'REPLACE_AFTER_BUILD'; + $v['download']['sizeBytes'] = 0; } break; } @@ -223,6 +186,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { saveManifest($manifestPath, $manifest); $message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.'); } + elseif ($action === 'set_channels') { + $version = $_POST['version'] ?? ''; + $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); + foreach ($manifest['versions'] as &$v) { + if ($v['version'] === $version) { + $v['channels'] = $channels; + break; + } + } + unset($v); + saveManifest($manifestPath, $manifest); + $message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest."; + } elseif ($action === 'set_beta') { $version = $_POST['version'] ?? ''; $isBeta = !empty($_POST['is_beta']); @@ -270,7 +246,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($action === 'sync' || $action === 'sync_versions') { require_once "$root/tools/SignManifest.php"; - $signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null); + $signer = new \PSLauncher\Tools\SignManifest($root); $scope = $action === 'sync_versions' ? 'versions' : 'all'; $force = !empty($_POST['force']); $result = $signer->run($scope, $force); @@ -283,7 +259,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $version = $_POST['version'] ?? ''; if ($version === '') throw new Exception("Version manquante"); require_once "$root/tools/SignManifest.php"; - $signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null); + $signer = new \PSLauncher\Tools\SignManifest($root); $force = !empty($_POST['force']); $result = $signer->run('versions', $force, $version); $forceLabel = $force ? ' [FORCE]' : ''; @@ -314,7 +290,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // 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, $channel ?: null); + $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é." @@ -329,23 +305,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $manifest = loadManifest($manifestPath); -// Liste des ZIPs : on inclut builds/ + builds/{channel}/ (1 niveau de -// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les -// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs -// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip). -$zips = []; +// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple. +$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : []; $zipSizes = []; -if (is_dir($buildsDir)) { - foreach (array_merge( - glob("$buildsDir/*.zip") ?: [], - glob("$buildsDir/*/*.zip") ?: [] - ) as $z) { - $rel = ltrim(str_replace($buildsDir, '', $z), '/\\'); - $rel = str_replace('\\', '/', $rel); - $zips[] = $rel; - $zipSizes[$rel] = filesize($z); - } -} +foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z"); // Pour l'édition : pré-charge les release notes existantes $existingNotes = []; @@ -354,66 +317,23 @@ foreach ($manifest['versions'] ?? [] as $v) { $existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : ''; } -$existingChannels = listExistingChannels($manifestDir); -// On force la présence du channel actif dans la liste, même si versions-X.json -// n'existe pas encore (cas où l'utilisateur vient de "créer" un channel et n'y -// a pas encore ajouté de version). Sinon le dropdown afficherait "(default)" -// alors que la session est sur le nouveau channel — confusion classique. -if ($channel !== '' && !isset($existingChannels[$channel])) { - $existingChannels[$channel] = $channel . ' (vide — pas encore de versions)'; -} -$channelLabel = $channel === '' ? 'default' : $channel; - Layout::header('Versions', 'versions'); ?> -

Versions — channel actif :

+

Versions

- -
- Channel à éditer : -
- -
- - Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel. - -
- - -
-
-

Workflow d'une nouvelle release

    -
  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).
  2. -
  3. Upload le ZIP correspondant via SFTP en respectant le nom proserve-{version}.zip, dans le dossier qui correspond au channel actif : -
      - -
    • Channel defaultwww/PS_Launcher/builds/proserve-{version}.zip
    • - -
    • Channel www/PS_Launcher/builds//proserve-{version}.zip (crée le sous-dossier en SFTP s'il n'existe pas)
    • - -
    -
  4. +
  5. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).
  6. +
  7. Upload le ZIP en SFTP dans www/PS_Launcher/builds/ (le nom doit matcher celui que tu as saisi, par défaut proserve-{version}.zip).
  8. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  9. -
  10. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».
  11. +
  12. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.
+

+ 💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels. +

@@ -421,7 +341,6 @@ Layout::header('Versions', 'versions');
-
@@ -437,8 +356,23 @@ Layout::header('Versions', 'versions');
- - + + +
+
+ +
+ + + +
@@ -486,6 +420,7 @@ Layout::header('Versions', 'versions'); Version + Channels Release min_license ZIP @@ -496,19 +431,17 @@ Layout::header('Versions', 'versions'); + v @@ -516,15 +449,20 @@ Layout::header('Versions', 'versions'); BÊTA + + + + + présent - + absent -
attendu :
+
attendu :
@@ -641,6 +579,28 @@ Layout::header('Versions', 'versions');

⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.

+
+ Channels +
+ + + +
+ + + +
+ +

⚠️ Re-signe le manifest après modif.

+
+
@@ -651,7 +611,7 @@ Layout::header('Versions', 'versions'); - Aucune version dans le manifest. + Aucune version dans le manifest. @@ -666,18 +626,12 @@ Layout::header('Versions', 'versions'); FichierTailleRéférencé ? @@ -691,22 +645,4 @@ Layout::header('Versions', 'versions');
- > $versions + * @return list> + */ + private static function filterVersions(array $versions, ?string $clientChannel): array + { + $filtered = []; + foreach ($versions as $v) { + // Si pas de channels sur l'entry → implicitement "default" + $channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])) + ? $v['channels'] + : ['default']; + + $isPublic = in_array('default', $channels, true); + $matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true); + + if ($isPublic || $matchesUser) { + $filtered[] = $v; + } + } + return array_values($filtered); } } diff --git a/server/tools/SignManifest.php b/server/tools/SignManifest.php index 32f07a9..cc302dd 100644 --- a/server/tools/SignManifest.php +++ b/server/tools/SignManifest.php @@ -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; } diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 89211a0..acdd1ab 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.26.0 - 0.26.0.0 - 0.26.0.0 + 0.27.0 + 0.27.0.0 + 0.27.0.0 true diff --git a/src/PSLauncher.Models/RemoteManifest.cs b/src/PSLauncher.Models/RemoteManifest.cs index 2caaa79..4177113 100644 --- a/src/PSLauncher.Models/RemoteManifest.cs +++ b/src/PSLauncher.Models/RemoteManifest.cs @@ -70,6 +70,21 @@ public sealed class VersionManifest [JsonPropertyName("availableForDownload")] public bool AvailableForDownload { get; set; } = true; + /// + /// Channels qui voient cette version. Liste vide ou champ absent ⇒ + /// implicitement ["default"] (= visible par tous les clients, + /// peu importe leur channel). + /// + /// La sémantique est additive côté serveur : si une license est sur + /// channel "police", elle reçoit (déjà filtrées par le serveur) les + /// versions taggées "default" + celles taggées "police". Le client n'a + /// pas besoin de re-filtrer côté UI — Manifest.php a déjà fait le tri, + /// signé le résultat. On expose quand même la liste ici pour debug et + /// pour un éventuel affichage admin du tag dans l'UI. + /// + [JsonPropertyName("channels")] + public List Channels { get; set; } = new(); + /// /// Si true, version taggée BÊTA — affichage d'un badge orange dans le /// launcher + filtrage : seules les licenses avec canSeeBetas=true