(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 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 PROSERVE version {version} has just been published and is now available for download.', 'instructions' => 'If you already have the PROSERVE Launcher installed, simply open it on any of your machines, click « Check for updates », 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. You\'ll need to run it on EACH workstation 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.
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 la version {version} de PROSERVE vient d\'être publiée et est désormais disponible au téléchargement.', 'instructions' => 'Si vous avez déjà le PROSERVE Launcher installé, ouvrez-le simplement sur n\'importe lequel de vos postes, cliquez sur « Vérifier les mises à jour » 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. Il doit être lancé sur CHAQUE poste 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.
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' => '我们很高兴地通知您,PROSERVE 版本 {version} 刚刚发布,现已可供下载。', 'instructions' => '如果您已经安装了 PROSERVE Launcher,只需在任何一台机器上打开它,单击「检查更新」,然后单击「安装」即可完成新版本的安装。', 'firstInstallTitle' => '首次安装或新工作站?', 'firstInstallBody' => '如果这是您的第一次使用,或者您正在配置一台新 PC,请在下方下载 PROSERVE Launcher 安装程序。您需要在使用 PROSERVE 的每台工作站上运行它。', 'downloadButton' => '⬇ 下载 PROSERVE Launcher (Windows)', 'directLink' => '直接链接:', 'releaseNotesTitle' => '版本说明', 'releaseNotesNone' => '此版本没有版本说明。', 'support' => '一如既往,如果您遇到任何问题(下载失败、安装错误、游戏中出现异常情况),请随时联系我们 — 我们随时为您提供帮助。', 'closing' => '训练愉快!', 'signature' => 'ASTERION VR 团队', 'footer' => '此自动通知已发送,因为您的联系电子邮件已注册到您的某个 PROSERVE 许可证中。
要停止接收这些通知,请要求您的 ASTERION VR 客户经理从许可证联系人中删除您的地址。', ], 'th' => [ 'subject' => 'PROSERVE v{version} พร้อมใช้งานแล้ว', 'title' => 'PROSERVE v{version} พร้อมใช้งาน!', 'subtitle' => 'เวอร์ชันใหม่พร้อมติดตั้งผ่าน PROSERVE Launcher ของคุณ', 'greeting' => 'สวัสดี {owner},', 'intro' => 'เรายินดีที่จะแจ้งให้คุณทราบว่า PROSERVE เวอร์ชัน {version} เพิ่งได้รับการเผยแพร่และพร้อมให้ดาวน์โหลดแล้ว', 'instructions' => 'หากคุณติดตั้ง PROSERVE Launcher ไว้แล้ว เพียงเปิดบนเครื่องใดก็ได้ของคุณ คลิก « ตรวจสอบการอัปเดต » แล้วติดตั้งเวอร์ชันใหม่ด้วยคลิกเดียว', 'firstInstallTitle' => 'ติดตั้งครั้งแรก หรือเวิร์กสเตชันใหม่?', 'firstInstallBody' => 'หากเป็นครั้งแรกของคุณ หรือคุณกำลังตั้งค่า PC ใหม่ ดาวน์โหลด PROSERVE Launcher ด้านล่าง คุณต้องเรียกใช้บนเวิร์กสเตชันทุกเครื่องที่จะใช้ PROSERVE', 'downloadButton' => '⬇ ดาวน์โหลด PROSERVE Launcher (Windows)', 'directLink' => 'ลิงก์โดยตรง:', 'releaseNotesTitle' => 'บันทึกการเผยแพร่', 'releaseNotesNone' => 'ไม่มีบันทึกการเผยแพร่สำหรับเวอร์ชันนี้', 'support' => 'เช่นเคย หากคุณพบปัญหาใดๆ (ดาวน์โหลดล้มเหลว ติดตั้งผิดพลาด อะไรแปลกๆ ในเกม) อย่าลังเลที่จะติดต่อ — เราพร้อมช่วยเหลือ', 'closing' => 'ฝึกซ้อมให้สนุก!', 'signature' => 'ทีม ASTERION VR', 'footer' => 'การแจ้งเตือนอัตโนมัตินี้ถูกส่งเนื่องจากอีเมลติดต่อของคุณถูกลงทะเบียนกับหนึ่งในใบอนุญาต PROSERVE ของคุณ
เพื่อหยุดรับการแจ้งเตือนเหล่านี้ ขอให้ผู้จัดการบัญชี ASTERION VR ของคุณลบที่อยู่ของคุณออกจากผู้ติดต่อใบอนุญาต', ], 'ar' => [ 'subject' => 'PROSERVE v{version} متوفر الآن', 'title' => 'PROSERVE v{version} متوفر!', 'subtitle' => 'إصدار جديد جاهز للتثبيت عبر PROSERVE Launcher الخاص بك.', 'greeting' => 'مرحباً {owner}،', 'intro' => 'يسعدنا أن نعلمك بأن الإصدار {version} من PROSERVE قد تم نشره للتو وأصبح متاحاً للتنزيل.', 'instructions' => 'إذا كان لديك PROSERVE Launcher مثبتاً بالفعل، فما عليك سوى فتحه على أي من أجهزتك، والنقر فوق «التحقق من التحديثات»، وتثبيت الإصدار الجديد بنقرة واحدة.', 'firstInstallTitle' => 'تثبيت لأول مرة أو محطة عمل جديدة؟', 'firstInstallBody' => 'إذا كانت هذه المرة الأولى لك، أو إذا كنت تعد جهاز كمبيوتر جديداً، فقم بتنزيل PROSERVE Launcher أدناه. ستحتاج إلى تشغيله على كل محطة عمل سيتم استخدام PROSERVE فيها.', 'downloadButton' => '⬇ تنزيل PROSERVE Launcher (Windows)', 'directLink' => 'رابط مباشر:', 'releaseNotesTitle' => 'ملاحظات الإصدار', 'releaseNotesNone' => 'لا توجد ملاحظات إصدار لهذه النسخة.', 'support' => 'كما هو الحال دائماً، إذا واجهت أي مشكلة (فشل التنزيل، خطأ في التثبيت، أي شيء غريب في اللعبة)، فلا تتردد في التواصل معنا — نحن هنا للمساعدة.', 'closing' => 'تدريب سعيد!', 'signature' => 'فريق ASTERION VR', 'footer' => 'تم إرسال هذا الإشعار التلقائي لأن بريدك الإلكتروني للاتصال مسجل في أحد تراخيص PROSERVE الخاصة بك.
للتوقف عن تلقي هذه الإشعارات، اطلب من مدير حسابك في 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 la versión {version} de PROSERVE acaba de publicarse y ya está disponible para descargar.', 'instructions' => 'Si ya tienes el PROSERVE Launcher instalado, simplemente ábrelo en cualquiera de tus máquinas, haz clic en «Buscar actualizaciones» 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. Deberás ejecutarlo en CADA estación de trabajo 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.
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 die Version {version} von PROSERVE soeben veröffentlicht wurde und jetzt zum Download bereitsteht.', 'instructions' => 'Wenn du den PROSERVE Launcher bereits installiert hast, öffne ihn einfach auf einer beliebigen deiner Maschinen, klicke auf «Updates suchen» 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. Du musst ihn auf JEDER Arbeitsstation ausführen, 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.
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 * $locale 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 . * Le owner_name personnalise le greeting. Le logo (optionnel via CID embed * notifications.logo_path) est inséré dans le bandeau d'en-tête navy. * * Pour l'arabe : on ajoute dir="rtl" 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. 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 , , 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
 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 !== ''
        ? "

" . htmlspecialchars($s['releaseNotesTitle'], ENT_QUOTES, 'UTF-8') . "

\n" . "
{$notesEsc}
" : '

' . htmlspecialchars($s['releaseNotesNone'], ENT_QUOTES, 'UTF-8') . '

'; // 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 !== '' ? "\"ASTERION" : 'ASTERION VR'; // 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 : // • → fond bleu solide visible dans TOUS les MUA // • Styles inline sur chaque
→ 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 <<
{$logoBlock}

{$titleStr}

{$subtitleStr}

{$greetingStr}

{$introStr}

{$instructionsStr}

{$fiTitleStr}

{$fiBodyStr}

{$downloadBtnStr}

{$directLinkStr} {$installerUrlEsc}

{$notesBlock}

{$supportStr}

{$closingStr}
{$signatureStr}

{$footerStr}
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 {$entryId}). Upload le ZIP {$zipFilename} en SFTP dans builds/, 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. 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'); ?>

Versions

Workflow d'une nouvelle release

  1. Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).
  2. Upload le ZIP en SFTP dans www/PS_Launcher/builds/ (le nom doit matcher celui que tu as saisi, par défaut proserve-{version}.zip).
  3. Clique 🔁 Sync (sign-manifest) pour calculer le SHA-256, mettre à jour sizeBytes, bumper latest, et signer le manifest avec Ed25519.
  4. Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.

💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels.

Ajouter une version

Manifest actuel

Signature : NON SIGNÉ" : "SIGNÉ Ed25519" ?> • latest : v
// — un dans un est exclu du // layout tree par certains browsers, ce qui empêche showModal() // de fonctionner (le bouton « ✎ Modifier » devient muet). ?> */ ?>

Éditer v BÊTA

. Pourquoi : directement // ou indirectement dans
Version Channels Release min_license ZIP Hash Visible Actions
v BÊTA présent absent
attendu :
SKIP à calculer
// 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(); ?>
Aucune version dans le manifest.
d'édition sont émis ici, hors de la 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). ?>

ZIPs présents dans builds/

Aucun ZIP. Upload tes fichiers via SFTP dans www/PS_Launcher/builds/, puis clique « Sync » ci-dessus.

FichierTailleRéférencé ?
oui" : "orphelin" ?>