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'); +?> +
| Nom | +Label | +Description | +Actions | +
|---|---|---|---|
+ = htmlspecialchars($c['name'] ?? '') ?>
+
+ implicite
+
+ |
+ = htmlspecialchars($c['label'] ?? $c['name'] ?? '') ?> | += htmlspecialchars($c['description'] ?? '') ?> | +
+
+
+
+
+
+ Éditer+ + |
+
$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{$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');
?>
-= htmlspecialchars($channelLabel) ?>proserve-{version}.zip, dans le dossier qui correspond au channel actif :
- www/PS_Launcher/builds/proserve-{version}.zipwww/PS_Launcher/builds/= htmlspecialchars($channel) ?>/proserve-{version}.zip (crée le sous-dossier en SFTP s'il n'existe pas)www/PS_Launcher/builds/ (le nom doit matcher celui que tu as saisi, par défaut proserve-{version}.zip).sizeBytes, bumper latest, et signer le manifest avec Ed25519.+ 💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels. +