Changement majeur du modèle de channels en réponse au feedback :
« c'est trop compliqué. Il faudrait juste pouvoir tout laisser dans
builds, et taguer une version dans un canal ou un autre voir plusieurs
canaux. ». Le précédent design (1 fichier manifest par channel +
sous-dossier builds/{channel}/) imposait une duplication de structure
et empêchait une version d'être visible sur plusieurs channels à la fois.
NOUVEAU MODÈLE :
- 1 seul fichier manifest/versions.json
- 1 seul dossier builds/ flat (les ZIPs ont des noms libres pour éviter
les collisions homonymes — proserve-1.5.3.zip, proserve-1.5.3-police.zip…)
- Chaque entry de version a un champ `channels: ["default", "police"]`
qui dit qui peut la voir
- Sémantique additive : un client sur channel "police" voit les versions
taggées "default" + celles taggées "police". "default" = public.
- Une version peut être taggée sur plusieurs channels en une fois.
NOUVEAU REGISTRE channels.json :
Le registre des channels (avec name + label + description) vit dans
manifest/channels.json. Le channel "default" est implicite et ne peut
pas être supprimé. Nouvelle page admin Channels (CRUD) pour gérer la
liste, avec garde-fou anti-suppression : compte les versions taggées
et les licenses attribuées avant d'autoriser la suppression.
SERVEUR :
- api/routes/Manifest.php : reçoit ?channel=X depuis la license,
filtre versions où channels[] contient X ou "default", re-signe avec
la clé privée Ed25519 à la volée. Coût ~1ms par requête.
- admin/versions.php : refactor — plus de session "channel actif", plus
de switcher en haut, plus de prefix builds/{channel}/. Add form a
des checkboxes channels[]. Nouveau bouton "Channels" par-row pour
retager.
- admin/licenses.php : dropdown channel alimenté depuis channels.json
au lieu de scanner les fichiers manifest.
- tools/SignManifest.php : revert du constructeur channel-aware,
toujours sur versions.json + builds/ flat.
CLIENT :
- VersionManifest.Channels (List<string>) ajouté pour debug, mais le
filtrage est server-side donc le client n'a rien à faire de ce champ
côté UX.
- L'envoi du ?channel=X depuis la license signée fonctionne déjà
depuis v0.26.0, pas de changement nécessaire.
MIGRATION :
Les fichiers manifest/versions-{X}.json créés en v0.26.0 deviennent
inutiles. Si tu en avais (test "police" par exemple), copie les
versions concernées dans versions.json et tague-les avec les bons
channels via la nouvelle UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
649 lines
35 KiB
PHP
649 lines
35 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib/Auth.php';
|
|
require __DIR__ . '/lib/Layout.php';
|
|
require __DIR__ . '/lib/Channels.php';
|
|
|
|
use PSLauncher\Admin\Auth;
|
|
use PSLauncher\Admin\Layout;
|
|
use PSLauncher\Admin\Channels;
|
|
|
|
Auth::requireLogin();
|
|
$config = require __DIR__ . '/../api/config.php';
|
|
|
|
$root = dirname(__DIR__);
|
|
$manifestDir = "$root/manifest";
|
|
$buildsDir = "$root/builds";
|
|
$notesDir = "$root/releasenotes";
|
|
$manifestPath = "$manifestDir/versions.json";
|
|
|
|
$message = null; $messageType = 'success';
|
|
|
|
// Liste des channels déclarés dans channels.json (incluant "default" implicite)
|
|
$channelRegistry = Channels::load($manifestDir);
|
|
$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']);
|
|
|
|
/**
|
|
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
|
|
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
|
|
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
|
|
*/
|
|
function sanitizeChannels(array $posted, array $allowedNames): array
|
|
{
|
|
$out = [];
|
|
foreach ($posted as $c) {
|
|
$c = (string)$c;
|
|
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
|
|
$out[] = $c;
|
|
}
|
|
}
|
|
if (empty($out)) $out = ['default'];
|
|
return $out;
|
|
}
|
|
|
|
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';
|
|
// Tous les ZIPs vivent dans builds/ flat. Le nom du fichier est libre :
|
|
// par défaut proserve-{version}.zip, l'admin peut surcharger pour des
|
|
// builds homonymes (proserve-1.4.6-police.zip, proserve-1.4.6-asterion.zip…).
|
|
$zipFilename = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
|
// Tags : liste des channels qui voient cette version. Sanitized contre
|
|
// le registre — un channel inconnu est silencieusement filtré. Vide → ['default'].
|
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
|
$entry = [
|
|
'version' => $version,
|
|
'releasedAt' => $releasedAtIso,
|
|
'executable' => 'PROSERVE_UE_5_5.exe',
|
|
'installFolderTemplate' => 'PROSERVE v{version}',
|
|
'channels' => $channels,
|
|
'download' => [
|
|
'url' => "{$base}/builds/{$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 <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, 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 : tous les ZIPs sont à la racine de builds/
|
|
// donc on remplace juste le filename, sans sous-dossier.
|
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
|
$v['download']['url'] = "{$base}/builds/{$normalized}";
|
|
// 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_channels') {
|
|
$version = $_POST['version'] ?? '';
|
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
|
foreach ($manifest['versions'] as &$v) {
|
|
if ($v['version'] === $version) {
|
|
$v['channels'] = $channels;
|
|
break;
|
|
}
|
|
}
|
|
unset($v);
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
|
|
}
|
|
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);
|
|
$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);
|
|
$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);
|
|
$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 : tout est flat dans builds/, indexé par filename simple.
|
|
$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, channels).</li>
|
|
<li>Upload le ZIP en SFTP dans <code>www/PS_Launcher/builds/</code> (le nom doit matcher celui que tu as saisi, par défaut <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 », filtrés selon le channel de leur license.</li>
|
|
</ol>
|
|
<p class="muted" style="margin: 12px 0 0;">
|
|
💡 La gestion des channels (création / édition / suppression) se fait sur la page <a href="channels.php">Channels</a>.
|
|
</p>
|
|
</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>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label>
|
|
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
|
|
</div>
|
|
<div class="field">
|
|
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
|
|
<?php foreach ($channelRegistry['channels'] as $c):
|
|
$cn = $c['name'] ?? '';
|
|
$isDefault = $cn === 'default';
|
|
?>
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $isDefault ? 'checked' : '' ?>>
|
|
<code><?= htmlspecialchars($cn) ?></code>
|
|
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</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>Channels</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';
|
|
?>
|
|
<?php
|
|
// Channels du version : array si défini, sinon implicite ['default']
|
|
$vChannels = isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])
|
|
? $v['channels']
|
|
: ['default'];
|
|
?>
|
|
<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>
|
|
<?php foreach ($vChannels as $ch): ?>
|
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px;"><?= htmlspecialchars($ch) ?></code>
|
|
<?php endforeach; ?>
|
|
</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; 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>
|
|
<details style="display: inline-block; margin: 0 4px;">
|
|
<summary class="btn btn-secondary">Channels</summary>
|
|
<form method="post" style="margin-top: 8px; min-width: 280px;">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="set_channels">
|
|
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
|
<div class="field">
|
|
<?php foreach ($channelRegistry['channels'] as $c):
|
|
$cn = $c['name'] ?? '';
|
|
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
|
?>
|
|
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;">
|
|
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
|
<code><?= htmlspecialchars($cn) ?></code>
|
|
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</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 après modif.</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="8" 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();
|