v1.0.8 — Multi-channels : afficher plusieurs entries au même numéro + install guard anti-collision

Contexte : après le fix v1.0.5 (/download-url disambigué par filename), un
opérateur peut avoir un manifest avec deux entries partageant un numéro de
version sur des channels différents (ex : proserve-firefighter-1.5.4.32 vs
proserve-full-1.5.4.32). Deux problèmes restants :

1. Le client n'affichait qu'UNE row : `remote.ToDictionary(v.Version)` dans
   RebuildList crashait sur duplicate key.
2. À l'install, les deux entries résolvaient au même dossier via le default
   `installFolderTemplate = "PROSERVE v{version}"` → l'install le plus récent
   écrasait silencieusement le précédent (ZipInstaller rename en .bak-{ts}
   puis delete en background).

Solution end-to-end :

── Client ─────────────────────────────────────────────────────────────
• RebuildList refactor : index par folder name (résolu via GetInstallFolder-
  Name()) au lieu de par version. Deux entries au même numéro deviennent
  visibles dès qu'elles ont des templates distincts. Warning log si deux
  entries résolvent au même folder.
• VersionRowViewModel : nouveau RowKey (basename du folder ou fallback
  Version), ChannelBadge (premier channel non-default). Sites de lookup
  (DL-in-flight preservation, 404 retry) migrés sur RowKey.
• MainWindow.xaml : badge bleu channel affiché à côté du badge BÊTA, dans
  la row compact ET dans FeaturedVersion.
• Install guard : refuse une install si le dossier cible contient déjà un
  .proserve-meta.json avec un entryId différent. Le meta stocke maintenant
  l'entryId à chaque WriteInstallMetadataAsync. Message clair localisé
  (FR/EN/CN/TH/AR/ES/DE) qui pointe l'opérateur vers le backoffice.
• VersionManifest client model : nouveau champ optionnel `Id` (mappé sur
  le champ serveur existant), utilisé pour identifier l'entrée source.
• Registry regex broadened : accepte `PROSERVE(-<channel>)? v...` en plus
  du `PROSERVE v...` legacy. Les folders custom par channel sont scannés.

── Serveur admin (versions.php) ──────────────────────────────────────
• Nouveau champ éditable `install_folder_template` dans le formulaire
  d'ajout ET dans edit_meta. Validation regex (contient {version}, charset
  whitelisted).
• Default intelligent à la création : si un seul channel non-default est
  coché, pré-remplit avec "PROSERVE-<channel> v{version}". Sinon garde
  "PROSERVE v{version}" (legacy).
• Validation croisée : refuse la save si deux entries résolvent au même
  dossier, avec un message clair qui suggère un template alternatif.

── Rétro-compat ──────────────────────────────────────────────────────
• Vieux installs (sans entryId dans meta) : install guard fail-open, se
  laisse écraser à la ré-install et retrofit l'entryId.
• Vieux manifests (sans `id` sur les entries) : `Id` est null côté client,
  l'install guard reste passif, comportement identique à v1.0.7.
• Vieux serveurs (sans `install_folder_template` éditable) : le manifest
  reste avec le default généré par generate_entry_id, aucune breaking
  change. Le badge channel s'affiche quand même si `channels` est renseigné.
• Setups mono-channel (99 % des cas) : aucun changement visible, sort et
  matching identiques.

Bump : 1.0.7 → 1.0.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:54:44 +02:00
parent f9820620dd
commit 28c5ca5877
10 changed files with 340 additions and 35 deletions

View File

@@ -450,6 +450,26 @@ HTML;
* - assure le suffixe .zip
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable
*/
/**
* Valide un installFolderTemplate côté admin. Contraintes :
* - doit contenir {version} (sinon deux entries au même template résolvent
* au même dossier une fois substitué, cassant l'anti-collision).
* - charset filename-safe : [A-Za-z0-9_.-] + espaces. Pas de séparateur de
* chemin (/, \) ni de caractères spéciaux qui casseraient un basename
* côté client (Path.GetFileName).
* - non vide, longueur raisonnable (backoffice UI n'accepte pas les URLs).
*/
function ps_is_valid_install_folder_template(string $tpl): bool
{
$tpl = trim($tpl);
if ($tpl === '' || strlen($tpl) > 120) return false;
if (!str_contains($tpl, '{version}')) return false;
// On check le "reste" du template (partie non-{version}) — le placeholder
// {version} lui-même contient `{` et `}` qui ne sont pas dans notre whitelist.
$stripped = str_replace('{version}', '', $tpl);
return (bool)preg_match('/^[A-Za-z0-9 _.\-]*$/', $stripped);
}
function ps_normalize_zip_filename(string $raw, string $version): string
{
$default = "proserve-{$version}.zip";
@@ -521,12 +541,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
$exeName = 'PROSERVE_UE_5_7.exe';
}
// installFolderTemplate — nom du sous-dossier créé côté client par le
// launcher. Doit contenir {version} et être unique par (version, channel)
// pour éviter l'écrasement silencieux à l'install quand deux entries
// partagent un numéro. Default intelligent : si un channel non-default
// est sélectionné, on préfixe avec "PROSERVE-<channel>". Sinon on garde
// le legacy "PROSERVE v{version}".
$installFolderTemplateInput = trim((string)($_POST['install_folder_template'] ?? ''));
if ($installFolderTemplateInput === '') {
$nonDefaultChannels = array_values(array_filter($channels, fn($c) => $c !== 'default'));
$installFolderTemplateInput = count($nonDefaultChannels) === 1
? "PROSERVE-{$nonDefaultChannels[0]} v{version}"
: "PROSERVE v{version}";
}
if (!ps_is_valid_install_folder_template($installFolderTemplateInput)) {
throw new Exception(
"InstallFolderTemplate invalide « {$installFolderTemplateInput} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
// Validation anti-collision cross-entries : résous le template avec la
// version courante, refuse si une autre entrée résoud au même dossier.
$installFolderResolved = str_replace('{version}', $version, $installFolderTemplateInput);
foreach ($manifest['versions'] as $vExisting) {
$existingTpl = (string)($vExisting['installFolderTemplate'] ?? 'PROSERVE v{version}');
$existingResolved = str_replace('{version}', $vExisting['version'] ?? '', $existingTpl);
if ($existingResolved === $installFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($vExisting['version'] ?? '?')
. " utilise déjà le dossier « {$installFolderResolved} ». Choisis un installFolderTemplate distinct (ex : « PROSERVE-{$version}-<channel> v{version} » ou en dur « PROSERVE-<channel> v{version} »)."
);
}
}
$entry = [
'id' => $entryId,
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => $exeName,
'installFolderTemplate' => 'PROSERVE v{version}',
'installFolderTemplate' => $installFolderTemplateInput,
'channels' => $channels,
'download' => [
'url' => "{$base}/builds/{$zipFilename}",
@@ -594,6 +645,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
}
// installFolderTemplate — édition optionnelle. Laisser vide = ne pas
// changer. Valide + anti-collision cross-entries obligatoire.
$newFolderTpl = trim((string)($_POST['install_folder_template'] ?? ''));
if ($newFolderTpl !== '') {
if (!ps_is_valid_install_folder_template($newFolderTpl)) {
throw new Exception(
"InstallFolderTemplate invalide « {$newFolderTpl} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
$newFolderResolved = str_replace('{version}', $version, $newFolderTpl);
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherTpl = (string)($other['installFolderTemplate'] ?? 'PROSERVE v{version}');
$otherResolved = str_replace('{version}', $other['version'] ?? '', $otherTpl);
if ($otherResolved === $newFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($other['version'] ?? '?')
. " utilise déjà le dossier « {$newFolderResolved} ». Choisis un installFolderTemplate distinct."
);
}
}
$manifest['versions'][$idx]['installFolderTemplate'] = $newFolderTpl;
}
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
}
@@ -931,6 +1005,11 @@ Layout::header('Versions', 'versions');
<label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120">
</div>
<div class="field">
<label>Dossier d'install côté client <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, defaut <code>PROSERVE v{version}</code> ; DOIT contenir <code>{version}</code>. Utilise un nom distinct par channel si tu as plusieurs entrées au même numéro de version — ex. <code>PROSERVE-firefighter v{version}</code>)</span></label>
<input type="text" name="install_folder_template" placeholder="PROSERVE v{version}" maxlength="120" pattern="[A-Za-z0-9 _.\-\{\}]+"
title="Nom du sous-dossier créé par le launcher dans le InstallRoot. DOIT contenir {version}. Sur un channel non-default, préfixe avec le nom du channel pour éviter d'écraser une install d'un autre channel au même numéro de version.">
</div>
<div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
@@ -1161,6 +1240,15 @@ Layout::header('Versions', 'versions');
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div>
<div class="field">
<label>Dossier d'install côté client
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars($v['installFolderTemplate'] ?? 'PROSERVE v{version}') ?></code>)
</span>
</label>
<input type="text" name="install_folder_template" placeholder="laisse vide pour ne rien changer" maxlength="120">
<p class="muted" style="font-size: 11px;">DOIT contenir <code>{version}</code>. Change-le si deux entries au même numéro se retrouvent à cibler le même dossier (ex : <code>PROSERVE-firefighter v{version}</code>).</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>