Deux bugs UX rapportés sur v0.26.0 admin/versions.php :
1. Cliquer 'Aller' avec un nom contenant majuscules / espaces / accents
ne faisait RIEN. Cause : pattern HTML5 strict (`[a-z0-9_-]{1,64}`) qui
bloque la submission du form GET sans message visible. Selon le browser
le tooltip de validation est barely visible, donc le user a l'impression
que le bouton est cassé.
Fix : retire le pattern strict, normalise côté serveur :
« ASTERION VR » → « asterion-vr », « éàç!? » → « » (et on dit pourquoi).
Flash success qui annonce la normalisation, ou flash error si le nom
est inexploitable après nettoyage.
2. Après création d'un channel, le dropdown affichait encore '(default)'
parce que listExistingChannels() filtre sur les versions-*.json
existants et l'admin n'a pas encore ajouté de version donc le fichier
n'existe pas. Du coup l'admin pensait que la création n'avait pas
marché alors que la session était bien sur le nouveau channel.
Fix : injection forcée du channel actif dans le tableau des options du
dropdown avec label « X (vide — pas encore de versions) ».
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
622 lines
32 KiB
PHP
622 lines
32 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"
|
|
);
|
|
}
|
|
|
|
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}/.
|
|
// L'admin peut surcharger l'URL après-coup si la convention diffère.
|
|
$zipPathPrefix = $channel === '' ? 'builds' : "builds/{$channel}";
|
|
$entry = [
|
|
'version' => $version,
|
|
'releasedAt' => $releasedAtIso,
|
|
'executable' => 'PROSERVE_UE_5_5.exe',
|
|
'installFolderTemplate' => 'PROSERVE v{version}',
|
|
'download' => [
|
|
'url' => "{$base}/{$zipPathPrefix}/proserve-{$version}.zip",
|
|
'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'] ?? '');
|
|
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 === '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);
|
|
$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) : '';
|
|
}
|
|
|
|
$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 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">
|
|
<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>Release notes (Markdown)</label>
|
|
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." 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):
|
|
$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');
|
|
$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[$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 ($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;">
|
|
<?= 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>
|
|
<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
|
|
$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>
|
|
<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();
|