Files
PS_Launcher/server/admin/versions.php
j.foucher 5f1057d118 backoffice: separate launcher Set / Remove forms (nested form bug)
The previous markup had a <form action=remove_launcher> nested inside a
<form action=set_launcher>. HTML doesn't allow nested forms — browsers
flatten the structure unpredictably, and clicking the inner "Définir"
submit could end up posting the outer (or vice versa), causing the
section to be removed when the user actually wanted to set it.

Two sibling forms now, with a 12px gap between them. Same UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:42:31 +02:00

452 lines
22 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
$message = null; $messageType = 'success';
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) ?? [];
}
function saveManifest(string $path, array $manifest): void
{
// Re-tri par SemVer descendant
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
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']);
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.');
}
foreach ($manifest['versions'] as $v) {
if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest.");
}
$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';
$manifest['versions'][] = [
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'Proserve v{version}',
'download' => [
'url' => "{$base}/builds/proserve-{$version}.zip",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}",
'minLicenseDate' => $minLicDate,
'availableForDownload' => $available,
];
saveManifest($manifestPath, $manifest);
if ($notes !== '') {
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
}
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
}
elseif ($action === 'edit_notes') {
$version = $_POST['version'] ?? '';
$notes = $_POST['notes'] ?? '';
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.');
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
$message = "Release notes de v{$version} mises à jour.";
}
elseif ($action === 'edit_meta') {
$version = $_POST['version'] ?? '';
$minLicDate = trim($_POST['min_license_date'] ?? '');
$releasedAt = trim($_POST['released_at'] ?? '');
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');
}
break;
}
}
unset($v);
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour.";
}
elseif ($action === 'toggle_available') {
$version = $_POST['version'] ?? '';
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
$v['availableForDownload'] = !($v['availableForDownload'] ?? true);
break;
}
}
unset($v);
saveManifest($manifestPath, $manifest);
$message = "Disponibilité de v{$version} mise à jour.";
}
elseif ($action === 'delete') {
$version = $_POST['version'] ?? '';
$manifest['versions'] = array_values(array_filter(
$manifest['versions'],
fn($v) => $v['version'] !== $version
));
saveManifest($manifestPath, $manifest);
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
}
elseif ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? '');
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
}
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
throw new Exception('Numéro de version "minRequired" invalide.');
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$manifest['launcher'] = [
'version' => $lver,
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
'download' => [
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => null,
];
saveManifest($manifestPath, $manifest);
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Sync ».";
}
elseif ($action === 'remove_launcher') {
unset($manifest['launcher']);
saveManifest($manifestPath, $manifest);
$message = "Section launcher retirée du manifest (auto-update désactivé).";
}
elseif ($action === 'sync') {
// Appel direct à la classe — pas d'exec(), fonctionne même quand la fonction
// est désactivée par le mutualisé OVH.
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$result = $signer->run();
$message = "Sortie du script :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$manifest = loadManifest($manifestPath);
$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
$existingNotes = [];
foreach ($manifest['versions'] ?? [] as $v) {
$f = "$notesDir/{$v['version']}.md";
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
}
Layout::header('Versions', 'versions');
?>
<h1>Versions</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>Workflow d'une nouvelle release</h2>
<ol class="muted">
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</li>
<li>Upload le ZIP correspondant via SFTP dans <code>www/PS_Launcher/builds/</code> en respectant le nom <code>proserve-{version}.zip</code>.</li>
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
</ol>
</div>
<div class="card">
<h2>Ajouter une version</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="add">
<div class="row">
<div class="col field">
<label>Version (X.Y.Z)</label>
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
</div>
<div class="col field">
<label>Date de release</label>
<input type="datetime-local" name="released_at">
</div>
<div class="col field">
<label>min_license_date (license requise)</label>
<input type="date" name="min_license_date" required>
</div>
</div>
<div class="field">
<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>
</div>
<div class="field">
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
</div>
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
</form>
</div>
<!-- Card LAUNCHER (auto-update du PSLauncher.exe lui-même) -->
<div class="card">
<h2>Auto-update du launcher</h2>
<?php
$launcher = $manifest['launcher'] ?? null;
$launcherFiles = is_dir("$buildsDir/launcher") ? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: []) : [];
$launcherZipPresent = false;
$launcherHashed = false;
if ($launcher) {
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
$launcherHashed = !empty($launcher['download']['sha256']) && !str_starts_with($launcher['download']['sha256'], 'REPLACE');
}
?>
<?php if ($launcher): ?>
<p>
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
• Exe : <?= $launcherZipPresent
? "<span class='badge badge-success'>présent</span>"
: "<span class='badge badge-warning'>absent</span>" ?>
• Hash : <?= $launcherHashed
? "<span class='badge badge-success'>OK</span>"
: "<span class='badge badge-warning'>à calculer</span>" ?>
</p>
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
<?php else: ?>
<p class="muted">Section launcher absente du manifest — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
<?php endif; ?>
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Définir / mettre à jour</h3>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_launcher">
<div class="row">
<div class="col field">
<label>Version launcher (X.Y.Z)</label>
<input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
</div>
<div class="col field">
<label>Version minimale requise (X.Y.Z)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
</div>
</div>
<button class="btn btn-success" type="submit">Définir</button>
</form>
<?php if ($launcher): ?>
<form method="post" style="margin-top: 12px;"
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_launcher">
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
</form>
<?php endif; ?>
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Workflow</h3>
<ol class="muted">
<li>Définis la version ci-dessus (ex : <code>0.6.0</code>).</li>
<li>Upload <code>PSLauncher-X.Y.Z.exe</code> via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
<li>Clique le bouton <strong>🔁 Sync (sign-manifest)</strong> du tableau du dessous — il hashera le launcher en plus des versions Proserve.</li>
</ol>
<?php if (!empty($launcherFiles)): ?>
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Exes présents dans builds/launcher/</h3>
<ul class="muted">
<?php foreach ($launcherFiles as $f): ?>
<li><code><?= htmlspecialchars($f) ?></code> — <?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="card">
<div class="toolbar">
<h2 style="margin: 0; flex: 1;">Manifest actuel</h2>
<span class="muted">
Signature :
<?= empty($manifest['signature'])
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
<?php if (!empty($manifest['latest'])): ?>
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
<?php endif; ?>
</span>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync">
<button class="btn btn-primary" type="submit">🔁 Sync (sign-manifest)</button>
</form>
</div>
<table>
<thead>
<tr>
<th>Version</th>
<th>Release</th>
<th>min_license</th>
<th>ZIP</th>
<th>Hash</th>
<th>Visible</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($manifest['versions'] ?? [] as $v):
$zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
$zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
?>
<tr>
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
<td>
<?php if ($zipExists): ?>
<span class="badge badge-success">présent</span>
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
<?php else: ?>
<span class="badge badge-warning">absent</span>
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
<?php endif; ?>
</td>
<td>
<?php if ($hashed): ?>
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
<?php else: ?>
<span class="badge badge-warning">à calculer</span>
<?php endif; ?>
</td>
<td>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_available">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<button class="btn btn-secondary" type="submit">
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
</button>
</form>
</td>
<td style="text-align: right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<div class="field">
<label>min_license_date</label>
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
</div>
<div class="field">
<label>Date de release (UTC)</label>
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Notes</summary>
<form method="post" style="margin-top: 8px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['version']] ?? '') ?></textarea>
<br><br>
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
</form>
</details>
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<button class="btn btn-danger" type="submit">Retirer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($manifest['versions'])): ?>
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="card">
<h2>ZIPs présents dans builds/</h2>
<?php if (empty($zips)): ?>
<p class="muted">Aucun ZIP. Upload tes fichiers via SFTP dans <code>www/PS_Launcher/builds/</code>, puis clique « Sync » ci-dessus.</p>
<?php else: ?>
<table>
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
<tbody>
<?php
$referencedNames = [];
foreach ($manifest['versions'] ?? [] as $v) {
$referencedNames[] = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedNames, true);
?>
<tr>
<td><code><?= htmlspecialchars($z) ?></code></td>
<td class="muted"><?= Layout::formatBytes($zipSizes[$z]) ?></td>
<td><?= $referenced
? "<span class='badge badge-success'>oui</span>"
: "<span class='badge badge-warning'>orphelin</span>" ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php Layout::footer();