Files
PS_Launcher/server/admin/versions.php
j.foucher 0c69bb1c85 Fix : badge 'absent' faux positif sur ZIPs en sous-dossier de channel
Le commit 7ff5e56 avait changé $zips pour stocker des chemins relatifs
(asterion-vr/proserve-1.5.3.zip) afin de lister les ZIPs en sous-dossier,
mais le check par-row utilisait toujours basename(URL) → comparait
"proserve-1.5.3.zip" contre ["asterion-vr/proserve-1.5.3.zip"], donc
in_array() retournait toujours false → badge "absent" alors que le ZIP
était bien là.

Fix : on compute zipRel depuis l'URL de la même façon que $zips (extrait
tout après /builds/), pour que les deux côtés du in_array() utilisent le
même format. Le tooltip "attendu :" affiche aussi le chemin relatif
complet, c'est plus clair pour le SFTP.

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

713 lines
38 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__);
$manifestDir = "$root/manifest";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
// Channel actif pour cette session d'édition. ?channel=X bascule sur
// versions-X.json (créé à la volée si nécessaire), vide = manifest default.
//
// Normalisation tolérante : on lowercase, on remplace espaces et tirets-cadratins
// par des '-', on retire tout le reste. Comme ça l'admin peut taper "ASTERION VR"
// dans le champ et ça devient "asterion-vr" — pas de submit silencieusement
// bloqué par une regex stricte côté HTML (ce qui faisait rien quand on cliquait
// "Aller" en v0.26.0).
function ps_normalize_channel(string $raw): string
{
$s = strtolower(trim($raw));
// Espaces, tirets typo, tabs, etc. → tiret simple
$s = preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
// Retire tout caractère non whitelisté
$s = preg_replace('/[^a-z0-9_-]/', '', $s);
// Compresse les tirets multiples + trim de tirets en début/fin
$s = preg_replace('/-+/', '-', $s);
$s = trim($s, '-_');
return substr($s, 0, 64);
}
$channelRaw = trim((string)($_GET['channel'] ?? $_POST['__channel'] ?? ''));
$channelNormalized = ps_normalize_channel($channelRaw);
$channelWasNormalized = $channelRaw !== '' && $channelNormalized !== $channelRaw;
$channelEmptyAfterNorm = $channelRaw !== '' && $channelNormalized === '';
$channel = $channelNormalized;
$manifestFileName = $channel === '' ? 'versions.json' : "versions-{$channel}.json";
$manifestPath = "$manifestDir/$manifestFileName";
$message = null; $messageType = 'success';
if ($channelEmptyAfterNorm) {
$message = "Nom de channel inexploitable « {$channelRaw} » (que des caractères non autorisés). Tape un nom avec des lettres / chiffres, ex. asterion-vr.";
$messageType = 'error';
} elseif ($channelWasNormalized) {
$message = "Nom de channel normalisé : « {$channelRaw} » → « {$channelNormalized} ».";
$messageType = 'success';
}
/**
* Liste les channels existants (= versions-*.json présents) + ajoute toujours
* 'default'. L'admin peut taper n'importe quel nouveau nom dans le formulaire
* "Créer un channel" — le fichier sera créé au premier 'add'.
*/
function listExistingChannels(string $manifestDir): array
{
$channels = ['' => '(default)'];
if (is_dir($manifestDir)) {
foreach (glob($manifestDir . '/versions-*.json') as $file) {
$name = basename($file);
if (preg_match('/^versions-([a-z0-9_-]{1,64})\.json$/', $name, $m)) {
$channels[$m[1]] = $m[1];
}
}
}
return $channels;
}
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"
);
}
/**
* 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.');
}
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';
// Pour un channel non-default, le ZIP vit dans 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 = [
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => 'PROSERVE_UE_5_5.exe',
'installFolderTemplate' => 'PROSERVE v{version}',
'download' => [
'url' => "{$base}/{$zipPathPrefix}/{$zipFilename}",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}",
'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/{$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'] ?? '');
// 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');
}
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;
}
}
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_beta') {
$version = $_POST['version'] ?? '';
$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;
}
}
unset($v);
saveManifest($manifestPath, $manifest);
$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);
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 === 'sync' || $action === 'sync_versions') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$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') {
$version = $_POST['version'] ?? '';
if ($version === '') throw new Exception("Version manquante");
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root, $channel ?: null);
$force = !empty($_POST['force']);
$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'] ?? '';
$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;
}
}
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, $channel ?: null);
$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 : on inclut builds/ + builds/{channel}/ (1 niveau de
// sous-dossier) pour lister aussi les ZIPs d'un channel spécifique. Les
// chemins affichés sont relatifs à $buildsDir pour distinguer les ZIPs
// "racine" (default) des ZIPs en sous-dossier (asterion-vr/proserve-X.zip).
$zips = [];
$zipSizes = [];
if (is_dir($buildsDir)) {
foreach (array_merge(
glob("$buildsDir/*.zip") ?: [],
glob("$buildsDir/*/*.zip") ?: []
) as $z) {
$rel = ltrim(str_replace($buildsDir, '', $z), '/\\');
$rel = str_replace('\\', '/', $rel);
$zips[] = $rel;
$zipSizes[$rel] = filesize($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) : '';
}
$existingChannels = listExistingChannels($manifestDir);
// On force la présence du channel actif dans la liste, même si versions-X.json
// n'existe pas encore (cas où l'utilisateur vient de "créer" un channel et n'y
// a pas encore ajouté de version). Sinon le dropdown afficherait "(default)"
// alors que la session est sur le nouveau channel — confusion classique.
if ($channel !== '' && !isset($existingChannels[$channel])) {
$existingChannels[$channel] = $channel . ' (vide — pas encore de versions)';
}
$channelLabel = $channel === '' ? 'default' : $channel;
Layout::header('Versions', 'versions');
?>
<h1>Versions <span class="muted" style="font-size: 14px; font-weight: normal;">— channel actif : <code style="background: rgba(0,0,0,0.3); padding: 2px 8px; border-radius: 4px;"><?= htmlspecialchars($channelLabel) ?></code></span></h1>
<?php Layout::flash($message, $messageType); ?>
<!--
Channel switcher : bascule la session admin sur un autre fichier manifest.
Toutes les actions (add/edit/delete/sync) qui suivent opéreront sur ce channel.
Le hidden __channel propage la sélection à chaque POST pour ne pas perdre le
contexte au refresh.
-->
<div class="card" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<strong>Channel à éditer :</strong>
<form method="get" style="display: inline-flex; gap: 6px; margin: 0;">
<select name="channel" onchange="this.form.submit()">
<?php foreach ($existingChannels as $value => $label):
$selected = $value === $channel ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</form>
<span class="muted" style="font-size: 12px;">
Les licenses avec ce channel verront ces versions. Le default sert les licenses sans channel.
</span>
<form method="get" style="display: inline-flex; gap: 6px; margin-left: auto;">
<input type="text" name="channel" placeholder="Créer/utiliser un nouveau channel : asterion-vr"
required maxlength="80" style="width: 280px;"
title="Sera normalisé en lowercase + tirets côté serveur (ex. « ASTERION VR » → « asterion-vr »).">
<button class="btn btn-secondary" type="submit">Aller</button>
</form>
</div>
<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 en respectant le nom <code>proserve-{version}.zip</code>, dans le dossier qui correspond au channel actif :
<ul>
<?php if ($channel === ''): ?>
<li>Channel <strong>default</strong> → <code>www/PS_Launcher/builds/proserve-{version}.zip</code></li>
<?php else: ?>
<li>Channel <strong><?= htmlspecialchars($channel) ?></strong> → <code>www/PS_Launcher/builds/<?= htmlspecialchars($channel) ?>/proserve-{version}.zip</code> <span class="muted">(crée le sous-dossier en SFTP s'il n'existe pas)</span></li>
<?php endif; ?>
</ul>
</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">
<input type="hidden" name="__channel" value="<?= htmlspecialchars($channel) ?>">
<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>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">
<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="row">
<div class="col field">
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
</div>
<div class="col field">
<label><input type="checkbox" name="is_beta"> Version BÊTA <span class="muted" style="font-weight: normal; font-size: 11px;">(visible uniquement par les licenses « can_see_betas »)</span></label>
</div>
</div>
<div class="field">
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal; font-size: 11px;">(affichées en tooltip dans le launcher quand BÊTA est coché)</span></label>
<input type="text" name="beta_notes" placeholder="Tester en priorité : nouvelle UI scènes, intégration capteurs.">
</div>
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
</form>
</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_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule TOUS les SHA-256 même si les ZIPs n'ont pas changé. Lent. Cocher seulement en cas de doute sur le cache.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</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):
// Format relatif depuis builds/. Pour le default channel, ça donne juste
// "proserve-X.Y.Z.zip" ; pour les autres channels, "asterion-vr/proserve-X.Y.Z.zip".
// Doit matcher exactement ce que produit $zips (cf. plus haut, glob sur 1
// niveau de sous-dossier max + chemin relatif normalisé en slash forward).
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$zipPos = strpos($urlPath, '/builds/');
$zipRel = $zipPos !== false
? ltrim(substr($urlPath, $zipPos + strlen('/builds/')), '/\\')
: basename($urlPath);
$zipExists = in_array($zipRel, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
?>
<tr>
<td>
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
<?php if (!empty($v['isBeta'])): ?>
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
<?php endif; ?>
</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[$zipRel] ?? 0) ?></span>
<?php else: ?>
<span class="badge badge-warning">absent</span>
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipRel) ?></code></div>
<?php endif; ?>
</td>
<td>
<?php if ($hashSkipped): ?>
<span class="badge badge-warning" title="hashAlgorithm=none : la vérif SHA-256 est désactivée pour cette release">SKIP</span>
<?php elseif ($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;">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline" title="Recalcule le SHA-256 de cette version uniquement (utilise le cache si le ZIP n'a pas changé)">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">⚙ Hash</summary>
<div style="margin-top: 8px; min-width: 280px;">
<p class="muted" style="font-size: 12px; margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
La vérif SHA-256 est <strong>désactivée</strong> pour cette release.
Le manifest sert <code>hashAlgorithm: "none"</code>. La sécurité repose
uniquement sur la signature Ed25519 du manifest + HTTPS.
<?php else: ?>
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é).
<?php endif; ?>
</p>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button>
</form>
<?php if ($zipExists): ?>
<form method="post" style="margin-top: 8px;" title="Recalcule en ignorant le cache même si le ZIP n'a pas changé">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
<?php endif; ?>
</div>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Méta</summary>
<form method="post" style="margin-top: 8px; min-width: 360px;">
<?= 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>
<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>
</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>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn <?= !empty($v['isBeta']) ? 'btn-warning' : 'btn-secondary' ?>"><?= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?></summary>
<form method="post" style="margin-top: 8px; min-width: 360px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_beta">
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
<div class="field">
<label><input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
Cette version est une BÊTA
</label>
</div>
<div class="field">
<label>Notes pour les testeurs</label>
<input type="text" name="beta_notes" value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
placeholder="Tester en priorité…">
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
<p class="muted" style="font-size: 11px; margin: 8px 0 0;">⚠️ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
</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
// On référence avec le chemin relatif après /builds/ pour matcher
// les ZIPs en sous-dossier (channels). Ex. asterion-vr/proserve-1.4.6.zip.
$referencedRel = [];
foreach ($manifest['versions'] ?? [] as $v) {
$urlPath = parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '';
$pos = strpos($urlPath, '/builds/');
if ($pos !== false) {
$referencedRel[] = ltrim(substr($urlPath, $pos + strlen('/builds/')), '/\\');
}
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedRel, 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>
<script>
// Propage le channel actif à toutes les formes POST qui n'ont pas explicitement
// leur propre __channel — évite d'éditer chaque form individuellement et
// préserve le contexte channel après chaque submit (sinon on retomberait sur
// le default à la prochaine action).
(function() {
var ch = <?= json_encode($channel) ?>;
document.querySelectorAll('form[method="post"]').forEach(function(form) {
if (!form.querySelector('input[name="__channel"]')) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = '__channel';
inp.value = ch;
form.appendChild(inp);
}
});
})();
</script>
<?php Layout::footer();