== Exe par-version, configurable via le backoffice == Pour éviter de devoir bumper le launcher à chaque changement d'Unreal Engine (PROSERVE_UE_5_5.exe → PROSERVE_UE_5_7.exe), l'exe à lancer est maintenant déclaré par version dans le manifest serveur, configurable depuis le form admin. - server/admin/versions.php : nouveau champ "Nom de l'exécutable" dans le form d'ajout de version (regex strict anti-path-traversal). Par défaut PROSERVE_UE_5_7.exe. - InstallationRegistry : nouveau .proserve-meta.json écrit dans chaque dossier d'install fraîchement extrait (contient l'exe declaré par le manifest). Scan() lit cette meta pour résoudre l'exe sans avoir besoin du manifest en mémoire (offline / pré-check). - Fallback glob PROSERVE_UE_*.exe pour les vieux installs sans metadata — garantit la backward compat de l'UE 5.5 actuel sans intervention. == Auto-install des redists Unreal depuis _redist/ == Quand une version change d'Unreal (typique : UE 5.5 → 5.7), les redists Microsoft VC + UEPrereqSetup doivent être installés sur le PC. Au lieu de demander à l'opérateur de les installer à la main, le launcher détecte maintenant un dossier _redist/ dans la racine du ZIP de release et lance automatiquement tous les .exe dedans. - MainViewModel.InstallRedistsAsync : après le ZipInstaller, scan _redist/ pour .exe, lance chacun avec /install /quiet /norestart + Verb=runas (UAC popup par installer, inévitable car les redists écrivent dans Program Files). Tri alphabétique (préfixe 01_, 02_, … pour forcer un ordre si besoin). - InstallationRegistry.MarkRedistInstalledAsync : trace redistInstalledAt dans .proserve-meta.json après succès. Reinstall de la même version : skip silencieux, pas de UAC popup chain. - Best-effort sur les exit codes : les "already installed" retournent souvent un code non-zero, on log mais on continue (l'install PROSERVE ne doit pas être bloquée par un redist mineur). Côté ZIP de release : crée _redist/ à la racine avec les .exe à installer (typiquement VC_redist.x64.exe + UEPrereqSetup_x64.exe). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
722 lines
40 KiB
PHP
722 lines
40 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;
|
|
}
|
|
|
|
/**
|
|
* ID stable interne pour identifier une entrée du manifest. Permet d'avoir
|
|
* plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police +
|
|
* v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas
|
|
* un identifiant unique. Le tag de channels distingue qui voit quoi.
|
|
*
|
|
* Format : 'v' + 8 hex chars random. Immutable une fois généré.
|
|
*/
|
|
function generate_entry_id(): string
|
|
{
|
|
try { return 'v' . bin2hex(random_bytes(4)); }
|
|
catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); }
|
|
}
|
|
|
|
function loadManifest(string $path): array
|
|
{
|
|
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
|
|
$manifest = json_decode(file_get_contents($path), true) ?? [];
|
|
|
|
// Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy
|
|
// pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change
|
|
// plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids
|
|
// jetables et les actions tomberaient à plat au refresh.
|
|
$dirty = false;
|
|
foreach ($manifest['versions'] ?? [] as &$v) {
|
|
if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; }
|
|
}
|
|
unset($v);
|
|
if ($dirty) saveManifest($path, $manifest);
|
|
|
|
return $manifest;
|
|
}
|
|
|
|
function saveManifest(string $path, array $manifest): void
|
|
{
|
|
// Re-tri par SemVer descendant. Pour des entrées de même version,
|
|
// tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter
|
|
// sur un ordre déterministe.
|
|
usort($manifest['versions'], function($a, $b) {
|
|
$cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0');
|
|
return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? '');
|
|
});
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
|
);
|
|
}
|
|
|
|
function find_entry_index_by_id(array $manifest, string $id): ?int
|
|
{
|
|
foreach ($manifest['versions'] ?? [] as $i => $v) {
|
|
if (($v['id'] ?? '') === $id) return $i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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.');
|
|
}
|
|
|
|
// Le numéro de version peut être DUPLIQUÉ entre channels — c'est
|
|
// exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour
|
|
// pompier coexistent). On rejette uniquement la collision sur le
|
|
// nom de ZIP, parce que là on aurait une vraie ambiguïté physique
|
|
// : deux entries pointant sur le même fichier sur disque.
|
|
$zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
|
foreach ($manifest['versions'] as $v) {
|
|
$existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
|
if ($existingZip === $zipFilenameProposed) {
|
|
throw new Exception(
|
|
"Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v"
|
|
. htmlspecialchars($v['version'] ?? '?')
|
|
. ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)."
|
|
);
|
|
}
|
|
}
|
|
|
|
$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';
|
|
// Le ZIP a déjà été normalisé pour le check anti-collision plus haut.
|
|
$zipFilename = $zipFilenameProposed;
|
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
|
$entryId = generate_entry_id();
|
|
$exeName = trim((string)($_POST['executable'] ?? 'PROSERVE_UE_5_7.exe'));
|
|
// Validation stricte : pas de path traversal possible dans le nom du fichier.
|
|
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
|
|
$exeName = 'PROSERVE_UE_5_7.exe';
|
|
}
|
|
$entry = [
|
|
'id' => $entryId,
|
|
'version' => $version,
|
|
'releasedAt' => $releasedAtIso,
|
|
'executable' => $exeName,
|
|
'installFolderTemplate' => 'PROSERVE v{version}',
|
|
'channels' => $channels,
|
|
'download' => [
|
|
'url' => "{$base}/builds/{$zipFilename}",
|
|
'sizeBytes' => 0,
|
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
|
],
|
|
// Release notes adressables par id stable — supporte plusieurs entries
|
|
// de même version sans collision (chaque release a son propre .md).
|
|
'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}",
|
|
'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/{$entryId}.md", $notes);
|
|
}
|
|
$message = "v{$version} ajoutée (id <code>{$entryId}</code>). Upload le ZIP <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, puis clique « Sync (sign-manifest) ».";
|
|
}
|
|
elseif ($action === 'edit_notes') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$notes = $_POST['notes'] ?? '';
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
|
file_put_contents("$notesDir/{$entryId}.md", $notes);
|
|
$message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour.";
|
|
}
|
|
elseif ($action === 'edit_meta') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
|
$releasedAt = trim($_POST['released_at'] ?? '');
|
|
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
|
throw new Exception('min_license_date invalide.');
|
|
}
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$version = $manifest['versions'][$idx]['version'] ?? '';
|
|
|
|
$manifest['versions'][$idx]['minLicenseDate'] = $minLicDate;
|
|
if ($releasedAt !== '') {
|
|
$manifest['versions'][$idx]['releasedAt'] =
|
|
(new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
|
}
|
|
if ($newZipName !== '') {
|
|
$normalized = ps_normalize_zip_filename($newZipName, $version);
|
|
// Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry
|
|
foreach ($manifest['versions'] as $other) {
|
|
if (($other['id'] ?? '') === $entryId) continue;
|
|
$otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
|
if ($otherZip === $normalized) {
|
|
throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom.");
|
|
}
|
|
}
|
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
|
$manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}";
|
|
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
|
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
|
|
}
|
|
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') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
|
$manifest['versions'][$idx]['channels'] = $channels;
|
|
saveManifest($manifestPath, $manifest);
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
|
|
}
|
|
elseif ($action === 'set_beta') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$isBeta = !empty($_POST['is_beta']);
|
|
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
|
if ($isBeta) {
|
|
$manifest['versions'][$idx]['isBeta'] = true;
|
|
if ($betaNotes !== null) {
|
|
$manifest['versions'][$idx]['betaNotes'] = $betaNotes;
|
|
} else {
|
|
unset($manifest['versions'][$idx]['betaNotes']);
|
|
}
|
|
} else {
|
|
unset($manifest['versions'][$idx]['isBeta']);
|
|
unset($manifest['versions'][$idx]['betaNotes']);
|
|
}
|
|
saveManifest($manifestPath, $manifest);
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
$message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »).";
|
|
}
|
|
elseif ($action === 'toggle_available') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true);
|
|
saveManifest($manifestPath, $manifest);
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
$message = "Disponibilité de v{$version} mise à jour.";
|
|
}
|
|
elseif ($action === 'delete') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
// Cleanup release notes file (best effort)
|
|
$notesFile = "$notesDir/{$entryId}.md";
|
|
if (is_file($notesFile)) @unlink($notesFile);
|
|
$manifest['versions'] = array_values(array_filter(
|
|
$manifest['versions'],
|
|
fn($v) => ($v['id'] ?? '') !== $entryId
|
|
));
|
|
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') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
require_once "$root/tools/SignManifest.php";
|
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
|
$force = !empty($_POST['force']);
|
|
// SignManifest::run filtre par numéro de version ; en cas d'entries
|
|
// de même version sur des channels différents, le hash sera recalculé
|
|
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
|
|
// donc le résultat est correct, juste un peu plus de boulot).
|
|
$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') {
|
|
$entryId = (string)($_POST['id'] ?? '');
|
|
$idx = find_entry_index_by_id($manifest, $entryId);
|
|
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
|
$skipHash = !empty($_POST['skip']);
|
|
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
|
if ($skipHash) {
|
|
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
|
|
$manifest['versions'][$idx]['download']['sha256'] = '';
|
|
} else {
|
|
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
|
|
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
|
|
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
|
}
|
|
}
|
|
saveManifest($manifestPath, $manifest);
|
|
|
|
// Re-signature auto pour garder le manifest valide après le changement.
|
|
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 (indexées par
|
|
// id stable maintenant). Fallback sur l'ancien format {version}.md pour les
|
|
// notes créées avant v0.27.1, qu'on n'a pas encore migrées.
|
|
$existingNotes = [];
|
|
foreach ($manifest['versions'] ?? [] as $v) {
|
|
$id = $v['id'] ?? '';
|
|
$byId = $id !== '' ? "$notesDir/{$id}.md" : null;
|
|
$byVersion = "$notesDir/{$v['version']}.md";
|
|
if ($byId !== null && is_file($byId)) {
|
|
$existingNotes[$id] = file_get_contents($byId);
|
|
} elseif (is_file($byVersion)) {
|
|
$existingNotes[$id] = file_get_contents($byVersion);
|
|
} else {
|
|
$existingNotes[$id] = '';
|
|
}
|
|
}
|
|
|
|
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>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
|
|
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" 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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['id'] ?? ''] ?? '') ?></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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
|
<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();
|