From 72f99e0c56ff0dfaab5e3b53cbbd9a5d31787d4b Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 5 May 2026 09:02:00 +0200 Subject: [PATCH] =?UTF-8?q?v0.27.1=20=E2=80=94=20Versions=20:=20permettre?= =?UTF-8?q?=20num=C3=A9ro=20identique=20sur=20channels=20diff=C3=A9rents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cas d'usage rapporté : « je peux avoir une version 1.5.3 pour les pompiers et une version 1.5.3 pour la police ». Le check anti-doublon sur le numéro de version bloquait la création — c'était un faux check puisque la duplication SUR DES CHANNELS DIFFÉRENTS est légitime. Changements : 1. ID stable interne : chaque entry de version reçoit un `id` immutable généré à la création (`v` + 8 hex aléatoires). Ce id devient l'identifiant primaire dans les forms et URLs admin. Le numéro de version reste un libellé human-readable, possiblement dupliqué. 2. Migration douce : loadManifest() ajoute un id aux entrées qui n'en ont pas, et auto-save pour persister. Stable aux reloads suivants. Les manifests legacy fonctionnent sans aucune action manuelle. 3. Check anti-doublon déplacé sur le NOM DE ZIP au lieu du numéro de version. Deux entries v1.5.3 sont autorisées si elles pointent vers des ZIPs différents (proserve-1.5.3-police.zip + proserve-1.5.3-pompier.zip). Une vraie ambiguïté sur disque (deux entries → même ZIP) est rejetée. 4. Toutes les actions row-level (edit_meta, edit_notes, set_beta, set_channels, toggle_available, delete, set_skip_hash, sync_one) identifient l'entry par `id` au lieu de `version`. Forms HTML mis à jour en conséquence. 5. Release notes : addressables par id stable. Stockées en releasenotes/{id}.md. Compat ascendante : si un fichier {id}.md n'existe pas, l'admin lit le legacy {version}.md (pour les notes créées avant v0.27.1). L'endpoint API releasenotes/{key} accepte les deux formats. 6. Suppression d'une entry : nettoie le fichier {id}.md correspondant. 7. SignManifest::run filtre toujours par version string : si plusieurs entries partagent un numéro de version, toutes sont re-hashées via « 🔁 Hash » sur une row. Comportement correct (chaque entry a sa propre URL/ZIP, donc son hash spécifique), juste moins efficient. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- server/admin/versions.php | 270 ++++++++++++++--------- server/api/routes/Releasenotes.php | 21 +- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- 4 files changed, 187 insertions(+), 112 deletions(-) diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index d47be20..c7b934c 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.27.0" +#define MyAppVersion "0.27.1" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/admin/versions.php b/server/admin/versions.php index 5db4243..8d0685c 100644 --- a/server/admin/versions.php +++ b/server/admin/versions.php @@ -41,22 +41,62 @@ function sanitizeChannels(array $posted, array $allowedNames): array 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' => []]; - return json_decode(file_get_contents($path), true) ?? []; + $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 - usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version'])); + // 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) @@ -99,8 +139,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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) { - if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest."); + $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 !== '' @@ -108,14 +162,12 @@ 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'; - // 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']. + // 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', @@ -126,7 +178,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'sizeBytes' => 0, 'sha256' => 'REPLACE_AFTER_BUILD', ], - 'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}", + // 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, ]; @@ -140,106 +194,105 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($notes !== '') { if (!is_dir($notesDir)) mkdir($notesDir, 0755, true); - file_put_contents("$notesDir/{$version}.md", $notes); + file_put_contents("$notesDir/{$entryId}.md", $notes); } - $message = "v{$version} ajoutée. Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; + $message = "v{$version} ajoutée (id {$entryId}). Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) »."; } elseif ($action === 'edit_notes') { - $version = $_POST['version'] ?? ''; + $entryId = (string)($_POST['id'] ?? ''); $notes = $_POST['notes'] ?? ''; - if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.'); + $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/{$version}.md", $notes); - $message = "Release notes de v{$version} mises à jour."; + file_put_contents("$notesDir/{$entryId}.md", $notes); + $message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour."; } elseif ($action === 'edit_meta') { - $version = $_POST['version'] ?? ''; + $entryId = (string)($_POST['id'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? ''); $releasedAt = trim($_POST['released_at'] ?? ''); - // Renommer le ZIP : on accepte un nouveau filename qui remplace - // celui en bout d'URL (le préfixe builds/{channel}/ est conservé). - // Vide = pas de changement. $newZipName = trim((string)($_POST['zip_filename'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) { throw new Exception('min_license_date invalide.'); } - foreach ($manifest['versions'] as &$v) { - if ($v['version'] === $version) { - $v['minLicenseDate'] = $minLicDate; - if ($releasedAt !== '') { - $v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); + $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."); } - if ($newZipName !== '') { - $normalized = ps_normalize_zip_filename($newZipName, $version); - // 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; } + $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; } - unset($v); 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'] ?? ''; + $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); - foreach ($manifest['versions'] as &$v) { - if ($v['version'] === $version) { - $v['channels'] = $channels; - break; - } - } - unset($v); + $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') { - $version = $_POST['version'] ?? ''; + $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; - foreach ($manifest['versions'] as &$v) { - if ($v['version'] === $version) { - if ($isBeta) { - $v['isBeta'] = true; - if ($betaNotes !== null) { - $v['betaNotes'] = $betaNotes; - } else { - unset($v['betaNotes']); - } - } else { - unset($v['isBeta']); - unset($v['betaNotes']); - } - break; + 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']); } - unset($v); 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') { - $version = $_POST['version'] ?? ''; - foreach ($manifest['versions'] as &$v) { - if ($v['version'] === $version) { - $v['availableForDownload'] = !($v['availableForDownload'] ?? true); - break; - } - } - unset($v); + $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') { - $version = $_POST['version'] ?? ''; + $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['version'] !== $version + 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)."; @@ -256,39 +309,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$result['ok']) $messageType = 'error'; } elseif ($action === 'sync_one') { - $version = $_POST['version'] ?? ''; - if ($version === '') throw new Exception("Version manquante"); + $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') { - $version = $_POST['version'] ?? ''; + $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']); - foreach ($manifest['versions'] as &$v) { - if ($v['version'] === $version) { - if ($skipHash) { - $v['download']['hashAlgorithm'] = 'none'; - $v['download']['sha256'] = ''; - } else { - unset($v['download']['hashAlgorithm']); - if (empty($v['download']['sha256'])) { - $v['download']['sha256'] = 'REPLACE_AFTER_BUILD'; - } - } - break; + $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'; } } - unset($v); saveManifest($manifestPath, $manifest); // Re-signature auto pour garder le manifest valide après le changement. - // 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); $resign = $signer->run('versions', false); @@ -310,11 +364,21 @@ $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 +// 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) { - $f = "$notesDir/{$v['version']}.md"; - $existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : ''; + $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'); @@ -478,7 +542,7 @@ Layout::header('Versions', 'versions');
- + @@ -489,7 +553,7 @@ Layout::header('Versions', 'versions'); - +
@@ -509,7 +573,7 @@ Layout::header('Versions', 'versions');
- +
@@ -531,7 +595,7 @@ Layout::header('Versions', 'versions');
- +
@@ -553,8 +617,8 @@ Layout::header('Versions', 'versions'); - - + +

@@ -564,7 +628,7 @@ Layout::header('Versions', 'versions');
- +