Files
PS_Launcher/server/admin/versions.php
j.foucher 500f7d12e6 v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign :
 - Pages versions.php + licenses.php : remplace les <details> inline qui
   débordaient horizontalement par UN bouton « ✎ Modifier » par row qui
   ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes,
   BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel,
   BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets.
 - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback
   visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la
   clé aux clients.
 - Overlay « hashing en cours » plein écran sur tous les boutons de hash
   (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé
   par scope (bulk vs single).
 - Date de release passée de datetime-local à date (l'heure n'a pas de
   sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an
   pour min_license_date (= license standard couvre les releases sur 1 an).

Emails de notification release :
 - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV)
 - Onglet « Contacts » sur la modal licenses pour saisir les emails par
   license (parsing tolérant : CSV, ligne par ligne, point-virgule)
 - Bouton « ✉ Notifier » par version : POST notify_release filtre les
   licenses éligibles (channel match + min_license_date + can_see_betas
   pour BÊTA) et envoie un email HTML à chaque contact (dédup global)
 - Template email table-based + bgcolor (compat Outlook/Word engine),
   navy foncé #0F172A, logo Asterion en CID embed (= affichage direct
   sans demande de permission Outlook), bouton download installer
   centré (align="center" + margin auto), release notes en <pre>
 - Mailer.php helper : parse emails, multipart/related avec attachments
   inline, fallback execCommand pour clipboard

4-digit version support (X.Y.Z.B) :
 - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent =
   0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve
   le format d'origine au round-trip.
 - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît
   « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque
 - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php,
   Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit
 - Use case : dev/test iterations cohabitant avec leur release stable

Bugs fixes :
 - migrate.php : strip ligne par ligne les commentaires SQL avant le
   check is-empty. Sans ça, le PREMIER chunk d'un fichier migration
   (= header + premier ALTER) commençait par `--` et était silencieusement
   skip → ALTER jamais appliqué. Affectait migrations 003, 004.
 - SignManifest::getOrComputeSha256 ignorait son cache interne quand force
   demandé par le caller, retournant l'ancien hash en 0 ms même après
   re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force.
   Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique.
 - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh
   triggers, avec HEAD probe pour comparer taille serveur vs manifest →
   message d'erreur explicite si ZIP tronqué. Bps display lissé sur une
   fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un
   segment finit / Polly retry. SHA mismatch popup enrichi avec les
   deux SHAs (attendu vs calculé) extraits via regex de l'exception.
 - DownloadUrl.php : signature de l'URL utilisait un template hardcodé
   /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit
   maintenant download.url du manifest et signe le filename réel.

Strings i18n (5 langues) :
 - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch,
   stale manifest, manifest refreshed auto-retry, force fresh menu

Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:24:50 +02:00

1219 lines
69 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
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;
}
/**
* Construit le corps HTML de l'email de release announce. Message en anglais
* (= langue par défaut du parc client international VR PROSERVE), inclut les
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
* Le owner_name de la license est utilisé pour personnaliser le greeting.
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
{
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
// Wrap les release notes en <pre> pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
$notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
// en blanc sur le fond bleu et le destinataire voit quand même la marque.
$logoBlock = $logoEsc !== ''
? "<img src=\"{$logoEsc}\" alt=\"ASTERION VR\" width=\"180\" "
. "style=\"display:inline-block; max-height:48px; max-width:240px; height:auto; "
. "color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:18px; "
. "font-weight:700; text-align:center; line-height:48px;\"/>"
: '<span style="color:#FFFFFF; font-family:Segoe UI,Arial,sans-serif; font-size:22px; font-weight:700;">ASTERION VR</span>';
// Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste
// CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word,
// qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres
// règles CSS modernes. Le résultat précédent ressemblait à un email plain
// text sans bandeau bleu. Avec ce layout :
// • <table bgcolor="#1E40AF"> → fond bleu solide visible dans TOUS les MUA
// • Styles inline sur chaque <td> → pas de strip Outlook
// • width="640" attribute (vs CSS max-width) → respecté par Outlook
// • Pas de gradient (impossible OOTB en HTML email standard) — solid blue
// URL d'install du launcher (= installer Inno Setup self-extracting Windows).
// Servi statiquement par Apache depuis server/installer/. Si l'opérateur
// change ce path, mettre à jour ici. Pas de paramétrage config volontaire :
// le path est canonique côté projet et tous les launchers en partagent un.
$installerUrl = rtrim($baseUrl, '/') !== ''
? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe'
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
<tr>
<td bgcolor="#0F172A" align="center"
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
<div style="margin-bottom:16px;">{$logoBlock}</div>
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
PROSERVE v{$versionEsc} is available!
</h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
A new version is ready to install via your PROSERVE Launcher.
</p>
</td>
</tr>
<tr>
<td bgcolor="#FFFFFF"
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
<p style="margin:0 0 14px;">
We're happy to let you know that
<strong>PROSERVE version {$versionEsc}</strong>
has just been published and is now available for download.
</p>
<p style="margin:0 0 14px;">
If you already have the <strong>PROSERVE Launcher</strong> installed,
simply open it on any of your machines, click
<em>« Check for updates »</em>, and install the new version with one click.
</p>
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
+ brève instruction. Bordure haut/bas pour le détacher visuellement
du paragraphe d'install standard ci-dessus. -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
style="margin:20px 0; border-top:1px solid #E5E7EB; border-bottom:1px solid #E5E7EB;">
<tr>
<td style="padding:18px 0;">
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
First install or new workstation?
</p>
<p style="margin:0 0 16px; color:#4B5563;">
If this is your first time, or you're setting up a new PC,
download the PROSERVE Launcher installer below.
<strong>You'll need to run it on EACH workstation</strong>
where PROSERVE will be used.
</p>
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
compat Outlook qui ne supporte ni border-radius CSS sur
<a> ni padding CSS bien rendu. Centrage via align="center"
sur la table wrapper, qui marche dans Outlook ET les MUA
modernes (vs `margin:auto` sur table ignoré par Outlook). -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
<tr>
<td bgcolor="#3B82F6" align="center"
style="background-color:#3B82F6; border-radius:6px;">
<a href="{$installerUrlEsc}"
style="display:inline-block; padding:12px 28px;
color:#FFFFFF; text-decoration:none;
font-family:'Segoe UI',Arial,sans-serif;
font-size:14px; font-weight:600;">
⬇ Download PROSERVE Launcher (Windows)
</a>
</td>
</tr>
</table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
Direct link:
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
</p>
</td>
</tr>
</table>
{$notesBlock}
<p style="margin:24px 0 14px;">
As always, if you run into any issue (download failure, install error,
anything weird in-game), don't hesitate to reach out — we're here to help.
</p>
<p style="margin:0;">
Happy training!<br/>
<strong>The ASTERION VR team</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
</td>
</tr>
</table>
</body></html>
HTML;
}
/**
* 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;
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
// Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de
// pré-release sans bumper le numéro de release final (ex. 1.5.4.13
// pendant le dev, 1.5.4 au release).
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) {
throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B 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';
}
elseif ($action === 'notify_release') {
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
// cette version. Critères d'éligibilité (= license verra la version
// dans son manifest signé) :
// • Pas révoquée
// • download_entitlement_until >= minLicenseDate de la version
// • channel de la license ∈ channels de la version (ou « default »)
// • can_see_betas = 1 si la version est isBeta=true
// Pour chaque license éligible : split contact_emails + send via Mailer.
// Compteurs retournés dans le message admin.
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$entry = $manifest['versions'][$idx];
$version = $entry['version'] ?? '?';
$isBeta = !empty($entry['isBeta']);
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
? $entry['channels']
: ['default'];
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
// Charge release notes (Markdown brut → on l'envoie tel quel,
// les MUA modernes l'affichent OK même sans rendu)
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
AND contact_emails IS NOT NULL
AND contact_emails != \'\''
);
$stmt->execute([$minLic]);
$licenses = $stmt->fetchAll();
$notifConfig = $config['notifications'] ?? [];
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
// SANS demander de permission "Télécharger les images". Le path
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
// override via $notifConfig['logo_path']. Si le fichier n'existe
// pas, $logoCid reste vide → le template skip le bloc image et
// utilise le fallback texte "ASTERION VR" en blanc.
//
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
// il n'offre même pas le bouton « Télécharger les images »
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
// fallback texte que ce visuel anti-pro.
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
$logoCid = '';
$logoMissing = false;
$inlineImages = [];
if (is_file($logoPath) && is_readable($logoPath)) {
$logoCid = 'asterion-logo@pslauncher';
$inlineImages[] = [
'cid' => $logoCid,
'path' => $logoPath,
'type' => 'image/png',
];
} else {
$logoMissing = true;
}
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
foreach ($licenses as $lic) {
$licChannel = $lic['channel'] ?? 'default';
// Match : la license voit la version si son channel est dans
// la liste des channels de la version (ou si version='default'
// et license sans channel = public)
$licChannelEffective = $licChannel ?? 'default';
if (!in_array($licChannelEffective, $vChannels, true)) continue;
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
$emails = Mailer::parseEmails($lic['contact_emails']);
if (empty($emails)) continue;
$licsTouched++;
$subject = "PROSERVE v{$version} is now available";
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
// tout hotlink par défaut et affiche un placeholder cassé
// sans même offrir le bouton "Télécharger les images" si
// l'URL retourne 404. Cf logoMissing détecté plus haut.
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
$bodyHtml = renderReleaseAnnounceEmail(
$version,
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc
);
foreach ($emails as $email) {
// Dédup : ne pas envoyer 2× à la même adresse si elle est
// contact sur plusieurs licenses du même client.
if (isset($emailsSet[$email])) continue;
$emailsSet[$email] = true;
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
if ($ok) $totalSent++; else $totalFailed++;
}
}
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
"{$licsTouched} license(s) éligibles, " .
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
// l'opérateur découvre le bug seulement quand un destinataire lui
// dit que l'email n'a pas de logo.
if ($logoMissing) {
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
$messageType = 'error';
}
if ($totalFailed > 0) $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, ou X.Y.Z.B pour une itération dev)</label>
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
</div>
<?php
// Défauts conventionnels :
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
// l'entry au moment du release, ça serait absurde d'avoir un
// champ vide qu'il faut systématiquement remplir)
// • min_license_date = aujourd'hui - 1 an (les licenses sont
// valides 1 an, donc une license émise il y a moins d'un an
// a accès à cette version par défaut). Modifiable pour les
// cas particuliers (release LTS, security patch limité aux
// dernières licenses, etc.).
$defaultReleasedAt = date('Y-m-d');
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
?>
<div class="col field">
<label>Date de release</label>
<input type="date" name="released_at"
value="<?= $defaultReleasedAt ?>"
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
</div>
<div class="col field">
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date" required
value="<?= $defaultMinLicDate ?>"
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
</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&#10;&#10;## Nouveautés&#10;- ..." 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" onsubmit="showBusyOverlay('bulk')">
<?= 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
// entryId est utilisé partout (boutons row + modal IDs).
$entryId = (string)($v['id'] ?? '');
$entryIdEsc = htmlspecialchars($entryId);
$modalId = 'edit-' . $entryIdEsc;
?>
<div class="row-actions">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<form method="post" style="display:inline"
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="notify_release">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
</form>
<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="<?= $entryIdEsc ?>">
<button class="btn btn-danger" type="submit">✕ Retirer</button>
</form>
</div>
</td>
</tr>
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
// — un <dialog> dans un <tr style="display:none"> est exclu du
// layout tree par certains browsers, ce qui empêche showModal()
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
<?php if (!empty($v['isBeta'])): ?>
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
<?php endif; ?>
</h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
un seul handler global JS déclaré en bas de page (délégation
d'événement sur document → pas besoin de rebind par modal). -->
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
<button type="button" data-tab="meta" class="active">Méta</button>
<button type="button" data-tab="notes">Notes</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="channels">Channels</button>
<button type="button" data-tab="advanced">Avancé</button>
</nav>
<div class="modal-body">
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
<section data-tab="meta">
<p class="section-intro">
Date d'expiration mini d'une license pour télécharger cette version,
date de release publique, et nom du fichier ZIP côté serveur (utile pour
invalider le cache CDN après un re-build).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date"
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
</div>
<div class="field">
<label>Date de release</label>
<?php
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
// L'heure n'est pas affichée à l'opérateur — toutes les releases
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
?>
<input type="date" name="released_at"
value="<?= htmlspecialchars($releasedDateOnly) ?>">
</div>
<div class="field">
<label>Renommer le ZIP
<span class="muted" style="font-weight: normal;">
(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">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
</div>
</form>
</section>
<!-- TAB : Notes -->
<section data-tab="notes" hidden>
<p class="section-intro">
Markdown des release notes de cette version. Affichées dans le dialog
d'install côté launcher et accessibles via le menu « Notes de release ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<textarea name="notes" rows="14"
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Une version BÊTA n'est visible que par les licenses qui ont le flag
<code>can_see_betas</code> activé. Les autres clients voient la version
comme inexistante.
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_beta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<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 <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
<input type="text" name="beta_notes"
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
placeholder="Tester en priorité…">
</div>
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Channels -->
<section data-tab="channels" hidden>
<p class="section-intro">
Détermine quelles licenses voient cette version. Coche « default » pour
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
la page <a href="channels.php">Channels</a>).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channels">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<div class="checkbox-list">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
?>
<label>
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<p class="warn">⚠ Re-signe le manifest après modif.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
</div>
</form>
</section>
<!-- TAB : Avancé (skip hash + force re-hash) -->
<section data-tab="advanced" hidden>
<p class="section-intro">
Options de vérification d'intégrité. La sécurité repose sur la signature
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
utile sur très gros builds pour gagner 30s-3min en fin de DL.
</p>
<div class="field">
<label>Vérification SHA-256 chez les clients</label>
<p class="hint" style="margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
Actuellement <strong style="color: var(--busy);">désactivée</strong>
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
<?php else: ?>
Actuellement <strong style="color: var(--installed);">activée</strong>
(default sain).
<?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="<?= $entryIdEsc ?>">
<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>
</div>
<?php if ($zipExists): ?>
<div class="field">
<label>Forcer un re-hash du ZIP</label>
<p class="hint" style="margin: 0 0 8px;">
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
Utile pour valider une intégrité après transfert SFTP douteux.
</p>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
</div>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php
// Capture le markup du dialog et le pousse dans un buffer global
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
// de manière imprévisible par le parser HTML, ce qui peut casser
// showModal() (élément non connecté au layout tree).
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
?>
<?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>
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
// pour que showModal() fonctionne fiablement (le top layer est indépendant
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<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>
<!-- Overlay busy : visible pendant les opérations longues côté serveur
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
Affiché en synchrone juste avant le submit du form ; disparaît
naturellement au reload de page qui suit la réponse POST. -->
<div id="busy-overlay" class="busy-overlay" hidden>
<div class="busy-content">
<div class="busy-spinner"></div>
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
<p id="busy-detail">Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.</p>
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
</div>
</div>
<!-- Handler global pour les onglets des modals d'édition de version.
Délégation d'événement sur document → marche pour tous les .modal-tabs,
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
<script>
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (!btn) return;
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
// Active button → toggle .active sur les boutons du même <nav>
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
// Sections → toggle [hidden] selon data-tab
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
});
})();
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
function showBusyOverlay(scope, version) {
var overlay = document.getElementById('busy-overlay');
if (!overlay) return;
var title = document.getElementById('busy-title');
var detail = document.getElementById('busy-detail');
if (scope === 'bulk') {
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (35 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
} else {
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.';
}
overlay.hidden = false;
}
</script>
<?php Layout::footer();