v0.26.0 générait des URLs builds/{channel}/proserve-X.Y.Z.zip dans le
manifest pour les channels non-default, mais SignManifest faisait juste
basename(url) → cherchait builds/proserve-X.Y.Z.zip à plat → ZIP introuvable
au moment du Sync.
Fixes :
1. SignManifest::resolveZipPath() : extrait tout le chemin après /builds/
dans l'URL et le mappe sur {$buildsDir}/. Strip ../ pour anti-traversal
même si le manifest est de toute façon signé Ed25519.
2. SignManifest fallback fuzzy étendu à 1 niveau de sous-dossier (glob */*.zip)
pour le cas "admin a renommé le ZIP".
3. admin/versions.php $zips inclut maintenant builds/ + builds/*/ et stocke
les chemins relatifs (asterion-vr/proserve-X.Y.Z.zip) au lieu du basename.
Le check "référencé ?" matche aussi sur le chemin relatif.
4. Workflow text en haut de la page indique le bon chemin SFTP selon le
channel actif (avec rappel "crée le sous-dossier si absent").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
650 lines
33 KiB
PHP
650 lines
33 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);
|
|
// 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>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
|
|
// 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();
|