Files
PS_Launcher/server/admin/versions.php
j.foucher 28c5ca5877 v1.0.8 — Multi-channels : afficher plusieurs entries au même numéro + install guard anti-collision
Contexte : après le fix v1.0.5 (/download-url disambigué par filename), un
opérateur peut avoir un manifest avec deux entries partageant un numéro de
version sur des channels différents (ex : proserve-firefighter-1.5.4.32 vs
proserve-full-1.5.4.32). Deux problèmes restants :

1. Le client n'affichait qu'UNE row : `remote.ToDictionary(v.Version)` dans
   RebuildList crashait sur duplicate key.
2. À l'install, les deux entries résolvaient au même dossier via le default
   `installFolderTemplate = "PROSERVE v{version}"` → l'install le plus récent
   écrasait silencieusement le précédent (ZipInstaller rename en .bak-{ts}
   puis delete en background).

Solution end-to-end :

── Client ─────────────────────────────────────────────────────────────
• RebuildList refactor : index par folder name (résolu via GetInstallFolder-
  Name()) au lieu de par version. Deux entries au même numéro deviennent
  visibles dès qu'elles ont des templates distincts. Warning log si deux
  entries résolvent au même folder.
• VersionRowViewModel : nouveau RowKey (basename du folder ou fallback
  Version), ChannelBadge (premier channel non-default). Sites de lookup
  (DL-in-flight preservation, 404 retry) migrés sur RowKey.
• MainWindow.xaml : badge bleu channel affiché à côté du badge BÊTA, dans
  la row compact ET dans FeaturedVersion.
• Install guard : refuse une install si le dossier cible contient déjà un
  .proserve-meta.json avec un entryId différent. Le meta stocke maintenant
  l'entryId à chaque WriteInstallMetadataAsync. Message clair localisé
  (FR/EN/CN/TH/AR/ES/DE) qui pointe l'opérateur vers le backoffice.
• VersionManifest client model : nouveau champ optionnel `Id` (mappé sur
  le champ serveur existant), utilisé pour identifier l'entrée source.
• Registry regex broadened : accepte `PROSERVE(-<channel>)? v...` en plus
  du `PROSERVE v...` legacy. Les folders custom par channel sont scannés.

── Serveur admin (versions.php) ──────────────────────────────────────
• Nouveau champ éditable `install_folder_template` dans le formulaire
  d'ajout ET dans edit_meta. Validation regex (contient {version}, charset
  whitelisted).
• Default intelligent à la création : si un seul channel non-default est
  coché, pré-remplit avec "PROSERVE-<channel> v{version}". Sinon garde
  "PROSERVE v{version}" (legacy).
• Validation croisée : refuse la save si deux entries résolvent au même
  dossier, avec un message clair qui suggère un template alternatif.

── Rétro-compat ──────────────────────────────────────────────────────
• Vieux installs (sans entryId dans meta) : install guard fail-open, se
  laisse écraser à la ré-install et retrofit l'entryId.
• Vieux manifests (sans `id` sur les entries) : `Id` est null côté client,
  l'install guard reste passif, comportement identique à v1.0.7.
• Vieux serveurs (sans `install_folder_template` éditable) : le manifest
  reste avec le default généré par generate_entry_id, aucune breaking
  change. Le badge channel s'affiche quand même si `channels` est renseigné.
• Setups mono-channel (99 % des cas) : aucun changement visible, sort et
  matching identiques.

Bump : 1.0.7 → 1.0.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 16:54:44 +02:00

1503 lines
93 KiB
PHP
Raw Permalink 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;
}
/**
* Strings localisées pour l'email de release announce. Couvre les 5 langues
* supportées par le launcher (Strings.cs côté .NET) : fr, en, zh, th, ar.
* Le release notes BODY lui-même reste TOUJOURS en anglais — seul le texte
* d'accompagnement (greeting, instructions, CTAs) est localisé. Permet de
* notifier des clients internationaux dans leur langue sans avoir à
* maintenir N traductions du Markdown des notes.
*
* Placeholders dans les chaînes :
* {version} → numéro de version (ex. "1.5.4.16")
* {owner} → nom du client (ex. "Asterion VR")
*
* Fallback : si la locale demandée n'est pas en table → 'en' (= comportement
* conservateur, le marché VR international a globalement l'anglais comme
* lingua franca tech).
*
* @return array<string, string> dictionnaire de strings prêtes à interpoler
*/
function getEmailStrings(string $locale): array
{
static $cache = null;
if ($cache !== null) {
return $cache[$locale] ?? $cache['en'];
}
$cache = [
'en' => [
'subject' => 'PROSERVE v{version} is now available',
'title' => 'PROSERVE v{version} is available!',
'subtitle' => 'A new version is ready to install via your PROSERVE Launcher.',
'greeting' => 'Hi {owner},',
'intro' => 'We\'re happy to let you know that <strong>PROSERVE version {version}</strong> has just been published and is now available for download.',
'instructions' => '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.',
'firstInstallTitle' => 'First install or new workstation?',
'firstInstallBody' => '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.',
'downloadButton' => '⬇ Download PROSERVE Launcher (Windows)',
'directLink' => 'Direct link:',
'releaseNotesTitle' => 'Release notes',
'releaseNotesNone' => 'No release notes for this version.',
'support' => '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.',
'closing' => 'Happy training!',
'signature' => 'The ASTERION VR team',
'footer' => '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.',
],
'fr' => [
'subject' => 'PROSERVE v{version} est disponible',
'title' => 'PROSERVE v{version} est disponible !',
'subtitle' => 'Une nouvelle version est prête à installer via votre PROSERVE Launcher.',
'greeting' => 'Bonjour {owner},',
'intro' => 'Nous avons le plaisir de vous annoncer que <strong>la version {version} de PROSERVE</strong> vient d\'être publiée et est désormais disponible au téléchargement.',
'instructions' => 'Si vous avez déjà le <strong>PROSERVE Launcher</strong> installé, ouvrez-le simplement sur n\'importe lequel de vos postes, cliquez sur <em>« Vérifier les mises à jour »</em> et installez la nouvelle version en un clic.',
'firstInstallTitle' => 'Première installation ou nouveau poste ?',
'firstInstallBody' => 'S\'il s\'agit de votre première installation, ou si vous configurez un nouveau PC, téléchargez le PROSERVE Launcher ci-dessous. <strong>Il doit être lancé sur CHAQUE poste</strong> où PROSERVE sera utilisé.',
'downloadButton' => '⬇ Télécharger PROSERVE Launcher (Windows)',
'directLink' => 'Lien direct :',
'releaseNotesTitle' => 'Notes de version',
'releaseNotesNone' => 'Pas de notes de version pour cette release.',
'support' => 'Comme toujours, en cas de problème (échec de téléchargement, erreur d\'installation, comportement inattendu en VR), n\'hésitez pas à nous contacter — nous sommes là pour vous aider.',
'closing' => 'Bon entraînement !',
'signature' => 'L\'équipe ASTERION VR',
'footer' => 'Cette notification automatique a été envoyée parce que votre email est enregistré comme contact sur l\'une de vos licenses PROSERVE.<br/>Pour ne plus recevoir ces emails, demandez à votre gestionnaire de compte ASTERION VR de retirer votre adresse des contacts license.',
],
'zh' => [
'subject' => 'PROSERVE v{version} 现已发布',
'title' => 'PROSERVE v{version} 已发布!',
'subtitle' => '新版本已准备好通过您的 PROSERVE Launcher 安装。',
'greeting' => '你好 {owner}',
'intro' => '我们很高兴地通知您,<strong>PROSERVE 版本 {version}</strong> 刚刚发布,现已可供下载。',
'instructions' => '如果您已经安装了 <strong>PROSERVE Launcher</strong>,只需在任何一台机器上打开它,单击<em>「检查更新」</em>,然后单击「安装」即可完成新版本的安装。',
'firstInstallTitle' => '首次安装或新工作站?',
'firstInstallBody' => '如果这是您的第一次使用,或者您正在配置一台新 PC请在下方下载 PROSERVE Launcher 安装程序。<strong>您需要在使用 PROSERVE 的每台工作站上运行它</strong>。',
'downloadButton' => '⬇ 下载 PROSERVE Launcher (Windows)',
'directLink' => '直接链接:',
'releaseNotesTitle' => '版本说明',
'releaseNotesNone' => '此版本没有版本说明。',
'support' => '一如既往,如果您遇到任何问题(下载失败、安装错误、游戏中出现异常情况),请随时联系我们 — 我们随时为您提供帮助。',
'closing' => '训练愉快!',
'signature' => 'ASTERION VR 团队',
'footer' => '此自动通知已发送,因为您的联系电子邮件已注册到您的某个 PROSERVE 许可证中。<br/>要停止接收这些通知,请要求您的 ASTERION VR 客户经理从许可证联系人中删除您的地址。',
],
'th' => [
'subject' => 'PROSERVE v{version} พร้อมใช้งานแล้ว',
'title' => 'PROSERVE v{version} พร้อมใช้งาน!',
'subtitle' => 'เวอร์ชันใหม่พร้อมติดตั้งผ่าน PROSERVE Launcher ของคุณ',
'greeting' => 'สวัสดี {owner},',
'intro' => 'เรายินดีที่จะแจ้งให้คุณทราบว่า <strong>PROSERVE เวอร์ชัน {version}</strong> เพิ่งได้รับการเผยแพร่และพร้อมให้ดาวน์โหลดแล้ว',
'instructions' => 'หากคุณติดตั้ง <strong>PROSERVE Launcher</strong> ไว้แล้ว เพียงเปิดบนเครื่องใดก็ได้ของคุณ คลิก <em>« ตรวจสอบการอัปเดต »</em> แล้วติดตั้งเวอร์ชันใหม่ด้วยคลิกเดียว',
'firstInstallTitle' => 'ติดตั้งครั้งแรก หรือเวิร์กสเตชันใหม่?',
'firstInstallBody' => 'หากเป็นครั้งแรกของคุณ หรือคุณกำลังตั้งค่า PC ใหม่ ดาวน์โหลด PROSERVE Launcher ด้านล่าง <strong>คุณต้องเรียกใช้บนเวิร์กสเตชันทุกเครื่อง</strong>ที่จะใช้ PROSERVE',
'downloadButton' => '⬇ ดาวน์โหลด PROSERVE Launcher (Windows)',
'directLink' => 'ลิงก์โดยตรง:',
'releaseNotesTitle' => 'บันทึกการเผยแพร่',
'releaseNotesNone' => 'ไม่มีบันทึกการเผยแพร่สำหรับเวอร์ชันนี้',
'support' => 'เช่นเคย หากคุณพบปัญหาใดๆ (ดาวน์โหลดล้มเหลว ติดตั้งผิดพลาด อะไรแปลกๆ ในเกม) อย่าลังเลที่จะติดต่อ — เราพร้อมช่วยเหลือ',
'closing' => 'ฝึกซ้อมให้สนุก!',
'signature' => 'ทีม ASTERION VR',
'footer' => 'การแจ้งเตือนอัตโนมัตินี้ถูกส่งเนื่องจากอีเมลติดต่อของคุณถูกลงทะเบียนกับหนึ่งในใบอนุญาต PROSERVE ของคุณ<br/>เพื่อหยุดรับการแจ้งเตือนเหล่านี้ ขอให้ผู้จัดการบัญชี ASTERION VR ของคุณลบที่อยู่ของคุณออกจากผู้ติดต่อใบอนุญาต',
],
'ar' => [
'subject' => 'PROSERVE v{version} متوفر الآن',
'title' => 'PROSERVE v{version} متوفر!',
'subtitle' => 'إصدار جديد جاهز للتثبيت عبر PROSERVE Launcher الخاص بك.',
'greeting' => 'مرحباً {owner}،',
'intro' => 'يسعدنا أن نعلمك بأن <strong>الإصدار {version} من PROSERVE</strong> قد تم نشره للتو وأصبح متاحاً للتنزيل.',
'instructions' => 'إذا كان لديك <strong>PROSERVE Launcher</strong> مثبتاً بالفعل، فما عليك سوى فتحه على أي من أجهزتك، والنقر فوق <em>«التحقق من التحديثات»</em>، وتثبيت الإصدار الجديد بنقرة واحدة.',
'firstInstallTitle' => 'تثبيت لأول مرة أو محطة عمل جديدة؟',
'firstInstallBody' => 'إذا كانت هذه المرة الأولى لك، أو إذا كنت تعد جهاز كمبيوتر جديداً، فقم بتنزيل PROSERVE Launcher أدناه. <strong>ستحتاج إلى تشغيله على كل محطة عمل</strong> سيتم استخدام PROSERVE فيها.',
'downloadButton' => '⬇ تنزيل PROSERVE Launcher (Windows)',
'directLink' => 'رابط مباشر:',
'releaseNotesTitle' => 'ملاحظات الإصدار',
'releaseNotesNone' => 'لا توجد ملاحظات إصدار لهذه النسخة.',
'support' => 'كما هو الحال دائماً، إذا واجهت أي مشكلة (فشل التنزيل، خطأ في التثبيت، أي شيء غريب في اللعبة)، فلا تتردد في التواصل معنا — نحن هنا للمساعدة.',
'closing' => 'تدريب سعيد!',
'signature' => 'فريق ASTERION VR',
'footer' => 'تم إرسال هذا الإشعار التلقائي لأن بريدك الإلكتروني للاتصال مسجل في أحد تراخيص PROSERVE الخاصة بك.<br/>للتوقف عن تلقي هذه الإشعارات، اطلب من مدير حسابك في ASTERION VR إزالة عنوانك من جهات اتصال الترخيص.',
],
'es' => [
'subject' => 'PROSERVE v{version} ya está disponible',
'title' => '¡PROSERVE v{version} ya está disponible!',
'subtitle' => 'Una nueva versión está lista para instalar a través de tu PROSERVE Launcher.',
'greeting' => 'Hola {owner},',
'intro' => 'Nos complace informarte que <strong>la versión {version} de PROSERVE</strong> acaba de publicarse y ya está disponible para descargar.',
'instructions' => 'Si ya tienes el <strong>PROSERVE Launcher</strong> instalado, simplemente ábrelo en cualquiera de tus máquinas, haz clic en <em>«Buscar actualizaciones»</em> e instala la nueva versión con un solo clic.',
'firstInstallTitle' => '¿Primera instalación o nueva estación de trabajo?',
'firstInstallBody' => 'Si es la primera vez, o si estás configurando un PC nuevo, descarga el instalador de PROSERVE Launcher abajo. <strong>Deberás ejecutarlo en CADA estación de trabajo</strong> donde se utilice PROSERVE.',
'downloadButton' => '⬇ Descargar PROSERVE Launcher (Windows)',
'directLink' => 'Enlace directo:',
'releaseNotesTitle' => 'Notas de versión',
'releaseNotesNone' => 'No hay notas de versión para esta release.',
'support' => 'Como siempre, si encuentras algún problema (fallo de descarga, error de instalación, comportamiento inesperado en el juego), no dudes en contactarnos — estamos aquí para ayudar.',
'closing' => '¡Buen entrenamiento!',
'signature' => 'El equipo ASTERION VR',
'footer' => 'Esta notificación automática se envió porque tu dirección de correo está registrada como contacto en una de tus licencias PROSERVE.<br/>Para dejar de recibir estos correos, pide a tu gestor de cuenta ASTERION VR que elimine tu dirección de los contactos de la licencia.',
],
'de' => [
'subject' => 'PROSERVE v{version} ist jetzt verfügbar',
'title' => 'PROSERVE v{version} ist verfügbar!',
'subtitle' => 'Eine neue Version ist bereit zur Installation über deinen PROSERVE Launcher.',
'greeting' => 'Hallo {owner},',
'intro' => 'Wir freuen uns, dir mitzuteilen, dass <strong>die Version {version} von PROSERVE</strong> soeben veröffentlicht wurde und jetzt zum Download bereitsteht.',
'instructions' => 'Wenn du den <strong>PROSERVE Launcher</strong> bereits installiert hast, öffne ihn einfach auf einer beliebigen deiner Maschinen, klicke auf <em>«Updates suchen»</em> und installiere die neue Version mit einem Klick.',
'firstInstallTitle' => 'Erstinstallation oder neue Arbeitsstation?',
'firstInstallBody' => 'Wenn dies das erste Mal ist oder du einen neuen PC einrichtest, lade den PROSERVE Launcher-Installer unten herunter. <strong>Du musst ihn auf JEDER Arbeitsstation ausführen</strong>, auf der PROSERVE verwendet wird.',
'downloadButton' => '⬇ PROSERVE Launcher herunterladen (Windows)',
'directLink' => 'Direkter Link:',
'releaseNotesTitle' => 'Versionshinweise',
'releaseNotesNone' => 'Keine Versionshinweise für diese Version.',
'support' => 'Wie immer, wenn du auf Probleme stößt (Download-Fehler, Installationsfehler, irgendetwas Seltsames im Spiel), zögere nicht, uns zu kontaktieren — wir sind hier, um zu helfen.',
'closing' => 'Viel Spaß beim Training!',
'signature' => 'Das ASTERION VR Team',
'footer' => 'Diese automatische Benachrichtigung wurde gesendet, weil deine Kontakt-E-Mail bei einer deiner PROSERVE-Lizenzen registriert ist.<br/>Um diese E-Mails nicht mehr zu erhalten, bitte deinen ASTERION VR Account Manager, deine Adresse aus den Lizenz-Kontakten zu entfernen.',
],
];
return $cache[$locale] ?? $cache['en'];
}
/**
* Construit le corps HTML de l'email de release announce, dans la langue
* <c>$locale</c> demandée (fallback 'en'). Le contenu de la release note
* elle-même (Markdown) reste en anglais — seul le texte d'accompagnement
* (greeting, instructions, CTAs) est localisé via <see cref="getEmailStrings"/>.
* Le owner_name personnalise le greeting. Le logo (optionnel via CID embed
* <c>notifications.logo_path</c>) est inséré dans le bandeau d'en-tête navy.
*
* Pour l'arabe : on ajoute <c>dir="rtl"</c> sur le wrapper table → Outlook,
* Gmail et Apple Mail rendent correctement le texte droit→gauche, et les
* sous-éléments inline (HTML tags, code, URLs) restent affichés LTR comme
* attendu (mixed bidi handling natif).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = '', string $locale = 'en'): string
{
// Récupère le dictionnaire de strings localisées + interpolation simple
// des placeholders {version} et {owner}. On échappe APRÈS interpolation
// pour ne pas double-encoder les < > de balises HTML déjà dans les
// strings (cf. <strong> dans les templates de getEmailStrings).
$s = getEmailStrings($locale);
$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');
// Direction RTL pour l'arabe. Les autres langues sont LTR par défaut.
// Note : on POSITIONNE rtl sur la table racine — les sub-blocks restent
// align="center" / align="left" selon le contexte (les tags HTML inline
// gardent leur direction naturelle via bidi-isolate du browser).
$dirAttr = ($locale === 'ar') ? ' dir="rtl"' : '';
// Helper d'interpolation : remplace {version} et {owner} dans une string
// localisée. NOTE : on ne réencode PAS — les strings dans getEmailStrings
// contiennent volontairement des <strong>, <em>, etc. à rendre comme HTML.
$tr = function (string $key) use ($s, $versionEsc, $ownerEsc): string {
return strtr($s[$key] ?? '', ['{version}' => $versionEsc, '{owner}' => $ownerEsc]);
};
// 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.
// IMPORTANT : le titre "Release notes" est localisé MAIS le contenu reste
// toujours en anglais (= ce que l'admin a saisi dans la modal Notes).
$notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">" . htmlspecialchars($s['releaseNotesTitle'], ENT_QUOTES, 'UTF-8') . "</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; "
. "direction: ltr; text-align: left; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">' . htmlspecialchars($s['releaseNotesNone'], ENT_QUOTES, 'UTF-8') . '</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');
// Toutes les strings utilisateur sont passées par $tr() qui interpole
// {version}/{owner} dans le template localisé pour la $locale demandée.
$titleStr = $tr('title');
$subtitleStr = $tr('subtitle');
$greetingStr = $tr('greeting');
$introStr = $tr('intro');
$instructionsStr = $tr('instructions');
$fiTitleStr = $tr('firstInstallTitle');
$fiBodyStr = $tr('firstInstallBody');
$downloadBtnStr = $tr('downloadButton');
$directLinkStr = $tr('directLink');
$supportStr = $tr('support');
$closingStr = $tr('closing');
$signatureStr = $tr('signature');
$footerStr = $tr('footer');
return <<<HTML
<!DOCTYPE html>
<html{$dirAttr}>
<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;"{$dirAttr}>
<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;">
{$titleStr}
</h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
{$subtitleStr}
</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;">{$greetingStr}</p>
<p style="margin:0 0 14px;">
{$introStr}
</p>
<p style="margin:0 0 14px;">
{$instructionsStr}
</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;">
{$fiTitleStr}
</p>
<p style="margin:0 0 16px; color:#4B5563;">
{$fiBodyStr}
</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;">
{$downloadBtnStr}
</a>
</td>
</tr>
</table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
{$directLinkStr}
<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;">
{$supportStr}
</p>
<p style="margin:0;">
{$closingStr}<br/>
<strong>{$signatureStr}</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
{$footerStr}
</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
*/
/**
* Valide un installFolderTemplate côté admin. Contraintes :
* - doit contenir {version} (sinon deux entries au même template résolvent
* au même dossier une fois substitué, cassant l'anti-collision).
* - charset filename-safe : [A-Za-z0-9_.-] + espaces. Pas de séparateur de
* chemin (/, \) ni de caractères spéciaux qui casseraient un basename
* côté client (Path.GetFileName).
* - non vide, longueur raisonnable (backoffice UI n'accepte pas les URLs).
*/
function ps_is_valid_install_folder_template(string $tpl): bool
{
$tpl = trim($tpl);
if ($tpl === '' || strlen($tpl) > 120) return false;
if (!str_contains($tpl, '{version}')) return false;
// On check le "reste" du template (partie non-{version}) — le placeholder
// {version} lui-même contient `{` et `}` qui ne sont pas dans notre whitelist.
$stripped = str_replace('{version}', '', $tpl);
return (bool)preg_match('/^[A-Za-z0-9 _.\-]*$/', $stripped);
}
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';
}
// installFolderTemplate — nom du sous-dossier créé côté client par le
// launcher. Doit contenir {version} et être unique par (version, channel)
// pour éviter l'écrasement silencieux à l'install quand deux entries
// partagent un numéro. Default intelligent : si un channel non-default
// est sélectionné, on préfixe avec "PROSERVE-<channel>". Sinon on garde
// le legacy "PROSERVE v{version}".
$installFolderTemplateInput = trim((string)($_POST['install_folder_template'] ?? ''));
if ($installFolderTemplateInput === '') {
$nonDefaultChannels = array_values(array_filter($channels, fn($c) => $c !== 'default'));
$installFolderTemplateInput = count($nonDefaultChannels) === 1
? "PROSERVE-{$nonDefaultChannels[0]} v{version}"
: "PROSERVE v{version}";
}
if (!ps_is_valid_install_folder_template($installFolderTemplateInput)) {
throw new Exception(
"InstallFolderTemplate invalide « {$installFolderTemplateInput} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
// Validation anti-collision cross-entries : résous le template avec la
// version courante, refuse si une autre entrée résoud au même dossier.
$installFolderResolved = str_replace('{version}', $version, $installFolderTemplateInput);
foreach ($manifest['versions'] as $vExisting) {
$existingTpl = (string)($vExisting['installFolderTemplate'] ?? 'PROSERVE v{version}');
$existingResolved = str_replace('{version}', $vExisting['version'] ?? '', $existingTpl);
if ($existingResolved === $installFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($vExisting['version'] ?? '?')
. " utilise déjà le dossier « {$installFolderResolved} ». Choisis un installFolderTemplate distinct (ex : « PROSERVE-{$version}-<channel> v{version} » ou en dur « PROSERVE-<channel> v{version} »)."
);
}
}
$entry = [
'id' => $entryId,
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => $exeName,
'installFolderTemplate' => $installFolderTemplateInput,
'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;
}
// installFolderTemplate — édition optionnelle. Laisser vide = ne pas
// changer. Valide + anti-collision cross-entries obligatoire.
$newFolderTpl = trim((string)($_POST['install_folder_template'] ?? ''));
if ($newFolderTpl !== '') {
if (!ps_is_valid_install_folder_template($newFolderTpl)) {
throw new Exception(
"InstallFolderTemplate invalide « {$newFolderTpl} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
$newFolderResolved = str_replace('{version}', $version, $newFolderTpl);
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherTpl = (string)($other['installFolderTemplate'] ?? 'PROSERVE v{version}');
$otherResolved = str_replace('{version}', $other['version'] ?? '', $otherTpl);
if ($otherResolved === $newFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($other['version'] ?? '?')
. " utilise déjà le dossier « {$newFolderResolved} ». Choisis un installFolderTemplate distinct."
);
}
}
$manifest['versions'][$idx]['installFolderTemplate'] = $newFolderTpl;
}
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']);
// Filtre par entryId (pas par numéro de version). Sinon, quand deux
// entrées partagent le même numéro (channels firefighter vs full sur
// 1.5.4.32 p.ex.), les DEUX ZIPs sont hashés dans la même requête HTTP
// → 2 × 14 Go = risque de dépasser le timeout front OVH sur mutualisé.
$result = $signer->run('versions', $force, null, $entryId);
$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. On inclut maintenant `language`
// pour pouvoir localiser l'email — fallback 'en' si NULL ou inconnu.
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, language, 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++;
// Locale par license : whitelist contre les langues supportées,
// fallback 'en' pour les licenses sans préférence définie.
$locale = trim((string)($lic['language'] ?? ''));
if (!in_array($locale, ['fr', 'en', 'zh', 'th', 'ar'], true)) {
$locale = 'en';
}
// Sujet localisé via le même dictionnaire que le corps.
$subjectTemplate = getEmailStrings($locale)['subject'] ?? 'PROSERVE v{version} is now available';
$subject = str_replace('{version}', $version, $subjectTemplate);
// 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,
$locale
);
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>Dossier d'install côté client <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, defaut <code>PROSERVE v{version}</code> ; DOIT contenir <code>{version}</code>. Utilise un nom distinct par channel si tu as plusieurs entrées au même numéro de version — ex. <code>PROSERVE-firefighter v{version}</code>)</span></label>
<input type="text" name="install_folder_template" placeholder="PROSERVE v{version}" maxlength="120" pattern="[A-Za-z0-9 _.\-\{\}]+"
title="Nom du sous-dossier créé par le launcher dans le InstallRoot. DOIT contenir {version}. Sur un channel non-default, préfixe avec le nom du channel pour éviter d'écraser une install d'un autre channel au même numéro de version.">
</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="field">
<label>Dossier d'install côté client
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars($v['installFolderTemplate'] ?? 'PROSERVE v{version}') ?></code>)
</span>
</label>
<input type="text" name="install_folder_template" placeholder="laisse vide pour ne rien changer" maxlength="120">
<p class="muted" style="font-size: 11px;">DOIT contenir <code>{version}</code>. Change-le si deux entries au même numéro se retrouvent à cibler le même dossier (ex : <code>PROSERVE-firefighter v{version}</code>).</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();