(string)($c['name'] ?? ''), $channelRegistry['channels']); /** * 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 sanitizeChannels(array $posted, array $allowedNames): array { $out = []; foreach ($posted as $c) { $c = (string)$c; if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) { $out[] = $c; } } if (empty($out)) $out = ['default']; return $out; } /** * ID stable interne pour identifier une entrée du manifest. Permet d'avoir * plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police + * v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas * un identifiant unique. Le tag de channels distingue qui voit quoi. * * Format : 'v' + 8 hex chars random. Immutable une fois généré. */ function generate_entry_id(): string { try { return 'v' . bin2hex(random_bytes(4)); } catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); } } function loadManifest(string $path): array { if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []]; $manifest = json_decode(file_get_contents($path), true) ?? []; // Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy // pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change // plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids // jetables et les actions tomberaient à plat au refresh. $dirty = false; foreach ($manifest['versions'] ?? [] as &$v) { if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; } } unset($v); if ($dirty) saveManifest($path, $manifest); return $manifest; } function saveManifest(string $path, array $manifest): void { // Re-tri par SemVer descendant. Pour des entrées de même version, // tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter // sur un ordre déterministe. usort($manifest['versions'], function($a, $b) { $cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0'); return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? ''); }); file_put_contents( $path, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" ); } function find_entry_index_by_id(array $manifest, string $id): ?int { foreach ($manifest['versions'] ?? [] as $i => $v) { if (($v['id'] ?? '') === $id) return $i; } return null; } /** * Normalise un nom de fichier ZIP saisi par l'admin : * - strip tout chemin (path traversal défense, basename) * - whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets, underscores) * - assure le suffixe .zip * - retombe sur la valeur par défaut si l'input est vide ou inexploitable */ function ps_normalize_zip_filename(string $raw, string $version): string { $default = "proserve-{$version}.zip"; $name = trim($raw); if ($name === '') return $default; // Strip tout chemin → garde juste le filename (sécurité) $name = basename($name); // Whitelist : on garde un set de chars filename-safe sur Windows + Linux $name = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name); if ($name === '' || $name === '.zip' || $name === '.') return $default; if (!str_ends_with(strtolower($name), '.zip')) $name .= '.zip'; return $name; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { Auth::checkCsrf(); $action = $_POST['action'] ?? ''; $manifest = loadManifest($manifestPath); try { if ($action === 'add') { $version = trim($_POST['version'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? ''); $releasedAt = trim($_POST['released_at'] ?? ''); $notes = $_POST['notes'] ?? ''; $available = isset($_POST['available']); $isBeta = isset($_POST['is_beta']); $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) { throw new Exception('Numéro de version invalide (format X.Y.Z attendu).'); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { throw new Exception('Date min_license_date invalide.'); } // Le numéro de version peut être DUPLIQUÉ entre channels — c'est // exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour // pompier coexistent). On rejette uniquement la collision sur le // nom de ZIP, parce que là on aurait une vraie ambiguïté physique // : deux entries pointant sur le même fichier sur disque. $zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version); foreach ($manifest['versions'] as $v) { $existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: ''); if ($existingZip === $zipFilenameProposed) { throw new Exception( "Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v" . htmlspecialchars($v['version'] ?? '?') . ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)." ); } } $releasedAtIso = $releasedAt !== '' ? (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z') : (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; // Le ZIP a déjà été normalisé pour le check anti-collision plus haut. $zipFilename = $zipFilenameProposed; $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $entryId = generate_entry_id(); $entry = [ 'id' => $entryId, 'version' => $version, 'releasedAt' => $releasedAtIso, 'executable' => 'PROSERVE_UE_5_5.exe', 'installFolderTemplate' => 'PROSERVE v{version}', 'channels' => $channels, 'download' => [ 'url' => "{$base}/builds/{$zipFilename}", 'sizeBytes' => 0, 'sha256' => 'REPLACE_AFTER_BUILD', ], // Release notes adressables par id stable — supporte plusieurs entries // de même version sans collision (chaque release a son propre .md). 'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}", 'minLicenseDate' => $minLicDate, 'availableForDownload' => $available, ]; if ($isBeta) { $entry['isBeta'] = true; if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes; } $manifest['versions'][] = $entry; saveManifest($manifestPath, $manifest); if ($notes !== '') { if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); file_put_contents("$notesDir/{$entryId}.md", $notes); } $message = "v{$version} ajoutée (id {$entryId}). Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; } elseif ($action === 'edit_notes') { $entryId = (string)($_POST['id'] ?? ''); $notes = $_POST['notes'] ?? ''; $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); file_put_contents("$notesDir/{$entryId}.md", $notes); $message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour."; } elseif ($action === 'edit_meta') { $entryId = (string)($_POST['id'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? ''); $releasedAt = trim($_POST['released_at'] ?? ''); $newZipName = trim((string)($_POST['zip_filename'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { throw new Exception('min_license_date invalide.'); } $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? ''; $manifest['versions'][$idx]['minLicenseDate'] = $minLicDate; if ($releasedAt !== '') { $manifest['versions'][$idx]['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); } if ($newZipName !== '') { $normalized = ps_normalize_zip_filename($newZipName, $version); // Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry foreach ($manifest['versions'] as $other) { if (($other['id'] ?? '') === $entryId) continue; $otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: ''); if ($otherZip === $normalized) { throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom."); } } $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; $manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}"; $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; $manifest['versions'][$idx]['download']['sizeBytes'] = 0; } 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') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames); $manifest['versions'][$idx]['channels'] = $channels; saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest."; } elseif ($action === 'set_beta') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $isBeta = !empty($_POST['is_beta']); $betaNotes = trim($_POST['beta_notes'] ?? '') ?: null; if ($isBeta) { $manifest['versions'][$idx]['isBeta'] = true; if ($betaNotes !== null) { $manifest['versions'][$idx]['betaNotes'] = $betaNotes; } else { unset($manifest['versions'][$idx]['betaNotes']); } } else { unset($manifest['versions'][$idx]['isBeta']); unset($manifest['versions'][$idx]['betaNotes']); } saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »)."; } elseif ($action === 'toggle_available') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true); saveManifest($manifestPath, $manifest); $version = $manifest['versions'][$idx]['version'] ?? '?'; $message = "Disponibilité de v{$version} mise à jour."; } elseif ($action === 'delete') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? '?'; // Cleanup release notes file (best effort) $notesFile = "$notesDir/{$entryId}.md"; if (is_file($notesFile)) @unlink($notesFile); $manifest['versions'] = array_values(array_filter( $manifest['versions'], fn($v) => ($v['id'] ?? '') !== $entryId )); saveManifest($manifestPath, $manifest); $message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu)."; } elseif ($action === 'sync' || $action === 'sync_versions') { require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $scope = $action === 'sync_versions' ? 'versions' : 'all'; $force = !empty($_POST['force']); $result = $signer->run($scope, $force); $label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet'; $forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]'; $message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } elseif ($action === 'sync_one') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $version = $manifest['versions'][$idx]['version'] ?? '?'; require_once "$root/tools/SignManifest.php"; $signer = new \PSLauncher\Tools\SignManifest($root); $force = !empty($_POST['force']); // SignManifest::run filtre par numéro de version ; en cas d'entries // de même version sur des channels différents, le hash sera recalculé // pour toutes celles qui matchent (chacune pointe sur son propre ZIP, // donc le résultat est correct, juste un peu plus de boulot). $result = $signer->run('versions', $force, $version); $forceLabel = $force ? ' [FORCE]' : ''; $message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } elseif ($action === 'set_skip_hash') { $entryId = (string)($_POST['id'] ?? ''); $idx = find_entry_index_by_id($manifest, $entryId); if ($idx === null) throw new Exception("Entry id={$entryId} introuvable."); $skipHash = !empty($_POST['skip']); $version = $manifest['versions'][$idx]['version'] ?? '?'; if ($skipHash) { $manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none'; $manifest['versions'][$idx]['download']['sha256'] = ''; } else { unset($manifest['versions'][$idx]['download']['hashAlgorithm']); if (empty($manifest['versions'][$idx]['download']['sha256'])) { $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; } } saveManifest($manifestPath, $manifest); // Re-signature auto pour garder le manifest valide après le changement. require_once "$root/tools/SignManifest.php"; $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é." : "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.") . "\n" . implode("\n", $resign['log']); if (!$resign['ok']) $messageType = 'error'; } } catch (Exception $e) { $message = $e->getMessage(); $messageType = 'error'; } } $manifest = loadManifest($manifestPath); // Liste des ZIPs : tout est flat dans builds/, indexé par filename simple. $zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : []; $zipSizes = []; foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z"); // Pour l'édition : pré-charge les release notes existantes (indexées par // id stable maintenant). Fallback sur l'ancien format {version}.md pour les // notes créées avant v0.27.1, qu'on n'a pas encore migrées. $existingNotes = []; foreach ($manifest['versions'] ?? [] as $v) { $id = $v['id'] ?? ''; $byId = $id !== '' ? "$notesDir/{$id}.md" : null; $byVersion = "$notesDir/{$v['version']}.md"; if ($byId !== null && is_file($byId)) { $existingNotes[$id] = file_get_contents($byId); } elseif (is_file($byVersion)) { $existingNotes[$id] = file_get_contents($byVersion); } else { $existingNotes[$id] = ''; } } Layout::header('Versions', 'versions'); ?>

Versions

Workflow d'une nouvelle release

  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).
  2. 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).
  3. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  4. 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.

Ajouter une version

Manifest actuel

Signature : NON SIGNÉ" : "SIGNÉ Ed25519" ?> • latest : v
Version Channels Release min_license ZIP Hash Visible Actions
v BÊTA présent absent
attendu :
SKIP à calculer
⚙ Hash

La vérif SHA-256 est désactivée pour cette release. Le manifest sert hashAlgorithm: "none". La sécurité repose uniquement sur la signature Ed25519 du manifest + HTTPS. Désactive la vérif SHA-256 chez les clients pour cette release (utile sur très gros builds où on accepte le compromis perf/sécurité).

Méta
⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.
Notes


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

Channels

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

Aucune version dans le manifest.

ZIPs présents dans builds/

Aucun ZIP. Upload tes fichiers via SFTP dans www/PS_Launcher/builds/, puis clique « Sync » ci-dessus.

FichierTailleRéférencé ?
oui" : "orphelin" ?>