Admin versions : nom du ZIP libre + renommage post-création

À l'ajout d'une version, nouveau champ "Nom du fichier ZIP (optionnel)" :
par défaut proserve-{version}.zip mais l'admin peut surcharger pour
distinguer des builds homonymes entre channels :
  builds/asterion-vr/proserve-1.4.6-asterion.zip
  builds/client-foo/proserve-1.4.6-foo.zip

Sur les versions existantes, la dialog "Méta" gagne un champ "Renommer
le ZIP" qui :
- ne touche que le filename, garde le préfixe builds/{channel}/
- invalide le sha256 (REPLACE_AFTER_BUILD) + sizeBytes pour forcer le
  recalcul au prochain Sync — le ZIP physique change, le manifest
  refléterait sinon l'ancien hash et le client ferait fail la vérif.

Normalisation côté serveur via ps_normalize_zip_filename() :
- basename() pour empêcher tout path traversal (../)
- whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets)
- auto-suffix .zip si manquant
- fallback sur proserve-{version}.zip si l'input devient vide après nettoyage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 08:19:24 +02:00
parent 7ff5e56102
commit 7638a7a25c

View File

@@ -88,6 +88,27 @@ function saveManifest(string $path, array $manifest): void
); );
} }
/**
* 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') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf(); Auth::checkCsrf();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@@ -119,15 +140,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
// Pour un channel non-default, le ZIP vit dans builds/{channel}/. // Pour un channel non-default, le ZIP vit dans builds/{channel}/.
// L'admin peut surcharger l'URL après-coup si la convention diffère.
$zipPathPrefix = $channel === '' ? 'builds' : "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.
$zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
$entry = [ $entry = [
'version' => $version, 'version' => $version,
'releasedAt' => $releasedAtIso, 'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe', 'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'PROSERVE v{version}', 'installFolderTemplate' => 'PROSERVE v{version}',
'download' => [ 'download' => [
'url' => "{$base}/{$zipPathPrefix}/proserve-{$version}.zip", 'url' => "{$base}/{$zipPathPrefix}/{$zipFilename}",
'sizeBytes' => 0, 'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD', 'sha256' => 'REPLACE_AFTER_BUILD',
], ],
@@ -161,6 +186,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$version = $_POST['version'] ?? ''; $version = $_POST['version'] ?? '';
$minLicDate = trim($_POST['min_license_date'] ?? ''); $minLicDate = trim($_POST['min_license_date'] ?? '');
$releasedAt = trim($_POST['released_at'] ?? ''); $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)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('min_license_date invalide.'); throw new Exception('min_license_date invalide.');
} }
@@ -170,12 +199,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($releasedAt !== '') { if ($releasedAt !== '') {
$v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); $v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
} }
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;
}
}
break; break;
} }
} }
unset($v); unset($v);
saveManifest($manifestPath, $manifest); saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour."; $message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
} }
elseif ($action === 'set_beta') { elseif ($action === 'set_beta') {
$version = $_POST['version'] ?? ''; $version = $_POST['version'] ?? '';
@@ -390,6 +436,10 @@ Layout::header('Versions', 'versions');
<input type="date" name="min_license_date" required> <input type="date" name="min_license_date" required>
</div> </div>
</div> </div>
<div class="field">
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code> ; utile pour distinguer un build homonyme entre channels)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-asterion.zip" maxlength="120">
</div>
<div class="field"> <div class="field">
<label>Release notes (Markdown)</label> <label>Release notes (Markdown)</label>
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8&#10;&#10;## Nouveautés&#10;- ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea> <textarea name="notes" rows="8" placeholder="# Proserve v1.4.8&#10;&#10;## Nouveautés&#10;- ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
@@ -532,7 +582,7 @@ Layout::header('Versions', 'versions');
</details> </details>
<details style="display: inline-block; margin: 0 4px;"> <details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary> <summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px;"> <form method="post" style="margin-top: 8px; min-width: 360px;">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta"> <input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>"> <input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
@@ -544,6 +594,11 @@ Layout::header('Versions', 'versions');
<label>Date de release (UTC)</label> <label>Date de release (UTC)</label>
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>"> <input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
</div> </div>
<div class="field">
<label>Renommer le ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)</span></label>
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<span class="muted" style="font-size: 11px;">⚠️ Renommer = invalide le sha256, il faudra re-uploader le nouveau ZIP en SFTP puis re-Sync.</span>
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button> <button class="btn btn-primary" type="submit">Enregistrer</button>
</form> </form>
</details> </details>