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

+ +
+ +
+

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