(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 {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
}
elseif ($action === 'edit_notes') {
$version = $_POST['version'] ?? '';
$notes = $_POST['notes'] ?? '';
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.');
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$version}.md", $notes);
$message = "Release notes de v{$version} mises à jour.";
}
elseif ($action === 'edit_meta') {
$version = $_POST['version'] ?? '';
$minLicDate = trim($_POST['min_license_date'] ?? '');
$releasedAt = trim($_POST['released_at'] ?? '');
// Renommer le ZIP : on accepte un nouveau filename qui remplace
// celui en bout d'URL (le préfixe builds/{channel}/ est conservé).
// Vide = pas de changement.
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('min_license_date invalide.');
}
foreach ($manifest['versions'] as &$v) {
if ($v['version'] === $version) {
$v['minLicenseDate'] = $minLicDate;
if ($releasedAt !== '') {
$v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
}
if ($newZipName !== '') {
$normalized = ps_normalize_zip_filename($newZipName, $version);
// Reconstruit l'URL : 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');
?>
www/PS_Launcher/builds/ (le nom doit matcher celui que tu as saisi, par défaut proserve-{version}.zip).sizeBytes, bumper latest, et signer le manifest avec Ed25519.💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels.
| Version | Channels | Release | min_license | ZIP | Hash | Visible | Actions |
|---|---|---|---|---|---|---|---|
| v= htmlspecialchars($v['version']) ?> BÊTA |
= htmlspecialchars($ch) ?>
|
= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?> | = htmlspecialchars($v['minLicenseDate'] ?? '—') ?> |
présent
= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?>
absent
attendu :
= htmlspecialchars($zipBase) ?> |
SKIP
= substr($v['download']['sha256'], 0, 12) ?>…
à calculer
|
⚙ Hash
La vérif SHA-256 est désactivée pour cette release.
Le manifest sert MétaNotes= !empty($v['isBeta']) ? '🟡 BÊTA' : 'Bêta' ?>Channels |
|
| Aucune version dans le manifest. | |||||||
Aucun ZIP. Upload tes fichiers via SFTP dans www/PS_Launcher/builds/, puis clique « Sync » ci-dessus.
| Fichier | Taille | Référencé ? |
|---|---|---|
= htmlspecialchars($z) ?> |
= Layout::formatBytes($zipSizes[$z]) ?> | = $referenced ? "oui" : "orphelin" ?> |