Première release MAJEURE après ~30 itérations 0.x. Le système est complet et stable pour la production : distribution PROSERVE multi-channels, install launcher cross-fleet, monitoring santé VR, sécurité (signatures Ed25519, HMAC URLs présignées, settings lock per-license, RFC1918 filter cache LAN). i18n nouvelles langues (Espagnol + Allemand) : - Strings.cs : signature T() étendue avec 2 params optionnels (es?/de?) en plus des 5 obligatoires. Fallback automatique sur l'anglais quand non traduit → infrastructure prête immédiatement, traduction progressive sur les ~317 strings du launcher selon les besoins terrain. - ~30 strings critiques traduites maintenant (top bar, body, status badges, actions principales Launch/Install/Cancel/Yes/No, MessageBox titles). - Available[] inclut es/Español + de/Deutsch ; auto-détection Windows pour es-* / de-* funcionne au premier launch. - Init() whitelist mise à jour. Emails release announce localisés par license : - Migration 005 : ajout `language VARCHAR(8) NULL` sur licenses (whitelist fr/en/es/de/zh/th/ar, NULL = fallback English). - getEmailStrings() PHP étendu : dictionnaire 7 langues × 14 strings. Traduction complète es + de (gérable : 28 nouvelles strings, vs 634 pour traduire le launcher en intégralité). - notify_release boucle par license : récupère language + locale fallback 'en' + render avec la bonne locale → chaque destinataire reçoit son email dans la langue de SA license, indépendamment des autres clients. - Subject localisé aussi (« PROSERVE v1.5.4 ya está disponible » / « PROSERVE v1.5.4 ist jetzt verfügbar »). - Release notes body reste TOUJOURS en anglais (évite d'avoir à maintenir N traductions du Markdown des notes). - Direction RTL préservée pour l'arabe (dir="rtl" sur <html> + <table>). Admin UI : dropdown langue dans la modal Contacts d'une license + whitelist backend (set_contact_emails accepte fr/en/es/de/zh/th/ar). Bumps : 0.29.10 → 1.0.0 — milestone V1, premier release majeur en prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1415 lines
87 KiB
PHP
1415 lines
87 KiB
PHP
<?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
|
||
*/
|
||
function ps_normalize_zip_filename(string $raw, string $version): string
|
||
{
|
||
$default = "proserve-{$version}.zip";
|
||
$name = trim($raw);
|
||
if ($name === '') return $default;
|
||
// Strip tout chemin → garde juste le filename (sécurité)
|
||
$name = basename($name);
|
||
// Whitelist : on garde un set de chars filename-safe sur Windows + Linux
|
||
$name = preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name);
|
||
if ($name === '' || $name === '.zip' || $name === '.') return $default;
|
||
if (!str_ends_with(strtolower($name), '.zip')) $name .= '.zip';
|
||
return $name;
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
Auth::checkCsrf();
|
||
$action = $_POST['action'] ?? '';
|
||
$manifest = loadManifest($manifestPath);
|
||
|
||
try {
|
||
if ($action === 'add') {
|
||
$version = trim($_POST['version'] ?? '');
|
||
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
||
$releasedAt = trim($_POST['released_at'] ?? '');
|
||
$notes = $_POST['notes'] ?? '';
|
||
$available = isset($_POST['available']);
|
||
$isBeta = isset($_POST['is_beta']);
|
||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||
|
||
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||
// Le 4ᵉ digit est optionnel et permet de gérer plusieurs builds de
|
||
// pré-release sans bumper le numéro de release final (ex. 1.5.4.13
|
||
// pendant le dev, 1.5.4 au release).
|
||
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $version)) {
|
||
throw new Exception('Numéro de version invalide (format X.Y.Z ou X.Y.Z.B attendu).');
|
||
}
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||
throw new Exception('Date min_license_date invalide.');
|
||
}
|
||
|
||
// Le numéro de version peut être DUPLIQUÉ entre channels — c'est
|
||
// exactement le cas d'usage (v1.5.3 pour police + v1.5.3 pour
|
||
// pompier coexistent). On rejette uniquement la collision sur le
|
||
// nom de ZIP, parce que là on aurait une vraie ambiguïté physique
|
||
// : deux entries pointant sur le même fichier sur disque.
|
||
$zipFilenameProposed = ps_normalize_zip_filename((string)($_POST['zip_filename'] ?? ''), $version);
|
||
foreach ($manifest['versions'] as $v) {
|
||
$existingZip = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||
if ($existingZip === $zipFilenameProposed) {
|
||
throw new Exception(
|
||
"Le ZIP « {$zipFilenameProposed} » est déjà utilisé par v"
|
||
. htmlspecialchars($v['version'] ?? '?')
|
||
. ". Choisis un autre nom de ZIP (ex. proserve-{$version}-police.zip)."
|
||
);
|
||
}
|
||
}
|
||
|
||
$releasedAtIso = $releasedAt !== ''
|
||
? (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')
|
||
: (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
||
|
||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||
// Le ZIP a déjà été normalisé pour le check anti-collision plus haut.
|
||
$zipFilename = $zipFilenameProposed;
|
||
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||
$entryId = generate_entry_id();
|
||
$exeName = trim((string)($_POST['executable'] ?? 'PROSERVE_UE_5_7.exe'));
|
||
// Validation stricte : pas de path traversal possible dans le nom du fichier.
|
||
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
|
||
$exeName = 'PROSERVE_UE_5_7.exe';
|
||
}
|
||
$entry = [
|
||
'id' => $entryId,
|
||
'version' => $version,
|
||
'releasedAt' => $releasedAtIso,
|
||
'executable' => $exeName,
|
||
'installFolderTemplate' => 'PROSERVE v{version}',
|
||
'channels' => $channels,
|
||
'download' => [
|
||
'url' => "{$base}/builds/{$zipFilename}",
|
||
'sizeBytes' => 0,
|
||
'sha256' => 'REPLACE_AFTER_BUILD',
|
||
],
|
||
// Release notes adressables par id stable — supporte plusieurs entries
|
||
// de même version sans collision (chaque release a son propre .md).
|
||
'releaseNotesUrl' => "{$base}/api/releasenotes/{$entryId}",
|
||
'minLicenseDate' => $minLicDate,
|
||
'availableForDownload' => $available,
|
||
];
|
||
if ($isBeta) {
|
||
$entry['isBeta'] = true;
|
||
if ($betaNotes !== null) $entry['betaNotes'] = $betaNotes;
|
||
}
|
||
$manifest['versions'][] = $entry;
|
||
|
||
saveManifest($manifestPath, $manifest);
|
||
|
||
if ($notes !== '') {
|
||
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
||
file_put_contents("$notesDir/{$entryId}.md", $notes);
|
||
}
|
||
$message = "v{$version} ajoutée (id <code>{$entryId}</code>). Upload le ZIP <code>{$zipFilename}</code> en SFTP dans <code>builds/</code>, puis clique « Sync (sign-manifest) ».";
|
||
}
|
||
elseif ($action === 'edit_notes') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$notes = $_POST['notes'] ?? '';
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
||
file_put_contents("$notesDir/{$entryId}.md", $notes);
|
||
$message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour.";
|
||
}
|
||
elseif ($action === 'edit_meta') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
||
$releasedAt = trim($_POST['released_at'] ?? '');
|
||
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
||
throw new Exception('min_license_date invalide.');
|
||
}
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$version = $manifest['versions'][$idx]['version'] ?? '';
|
||
|
||
$manifest['versions'][$idx]['minLicenseDate'] = $minLicDate;
|
||
if ($releasedAt !== '') {
|
||
$manifest['versions'][$idx]['releasedAt'] =
|
||
(new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
||
}
|
||
if ($newZipName !== '') {
|
||
$normalized = ps_normalize_zip_filename($newZipName, $version);
|
||
// Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry
|
||
foreach ($manifest['versions'] as $other) {
|
||
if (($other['id'] ?? '') === $entryId) continue;
|
||
$otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||
if ($otherZip === $normalized) {
|
||
throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom.");
|
||
}
|
||
}
|
||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||
$manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}";
|
||
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
||
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
|
||
}
|
||
saveManifest($manifestPath, $manifest);
|
||
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
|
||
}
|
||
elseif ($action === 'set_channels') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
|
||
$manifest['versions'][$idx]['channels'] = $channels;
|
||
saveManifest($manifestPath, $manifest);
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
|
||
}
|
||
elseif ($action === 'set_beta') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$isBeta = !empty($_POST['is_beta']);
|
||
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
|
||
if ($isBeta) {
|
||
$manifest['versions'][$idx]['isBeta'] = true;
|
||
if ($betaNotes !== null) {
|
||
$manifest['versions'][$idx]['betaNotes'] = $betaNotes;
|
||
} else {
|
||
unset($manifest['versions'][$idx]['betaNotes']);
|
||
}
|
||
} else {
|
||
unset($manifest['versions'][$idx]['isBeta']);
|
||
unset($manifest['versions'][$idx]['betaNotes']);
|
||
}
|
||
saveManifest($manifestPath, $manifest);
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
$message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »).";
|
||
}
|
||
elseif ($action === 'toggle_available') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true);
|
||
saveManifest($manifestPath, $manifest);
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
$message = "Disponibilité de v{$version} mise à jour.";
|
||
}
|
||
elseif ($action === 'delete') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
// Cleanup release notes file (best effort)
|
||
$notesFile = "$notesDir/{$entryId}.md";
|
||
if (is_file($notesFile)) @unlink($notesFile);
|
||
$manifest['versions'] = array_values(array_filter(
|
||
$manifest['versions'],
|
||
fn($v) => ($v['id'] ?? '') !== $entryId
|
||
));
|
||
saveManifest($manifestPath, $manifest);
|
||
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
|
||
}
|
||
elseif ($action === 'sync' || $action === 'sync_versions') {
|
||
require_once "$root/tools/SignManifest.php";
|
||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||
$scope = $action === 'sync_versions' ? 'versions' : 'all';
|
||
$force = !empty($_POST['force']);
|
||
$result = $signer->run($scope, $force);
|
||
$label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet';
|
||
$forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]';
|
||
$message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']);
|
||
if (!$result['ok']) $messageType = 'error';
|
||
}
|
||
elseif ($action === 'sync_one') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
require_once "$root/tools/SignManifest.php";
|
||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||
$force = !empty($_POST['force']);
|
||
// SignManifest::run filtre par numéro de version ; en cas d'entries
|
||
// de même version sur des channels différents, le hash sera recalculé
|
||
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
|
||
// donc le résultat est correct, juste un peu plus de boulot).
|
||
$result = $signer->run('versions', $force, $version);
|
||
$forceLabel = $force ? ' [FORCE]' : '';
|
||
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
|
||
if (!$result['ok']) $messageType = 'error';
|
||
}
|
||
elseif ($action === 'set_skip_hash') {
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$skipHash = !empty($_POST['skip']);
|
||
$version = $manifest['versions'][$idx]['version'] ?? '?';
|
||
if ($skipHash) {
|
||
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
|
||
$manifest['versions'][$idx]['download']['sha256'] = '';
|
||
} else {
|
||
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
|
||
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
|
||
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
|
||
}
|
||
}
|
||
saveManifest($manifestPath, $manifest);
|
||
|
||
// Re-signature auto pour garder le manifest valide après le changement.
|
||
require_once "$root/tools/SignManifest.php";
|
||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||
$resign = $signer->run('versions', false);
|
||
$message = ($skipHash
|
||
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
|
||
: "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.")
|
||
. "\n" . implode("\n", $resign['log']);
|
||
if (!$resign['ok']) $messageType = 'error';
|
||
}
|
||
elseif ($action === 'notify_release') {
|
||
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
|
||
// cette version. Critères d'éligibilité (= license verra la version
|
||
// dans son manifest signé) :
|
||
// • Pas révoquée
|
||
// • download_entitlement_until >= minLicenseDate de la version
|
||
// • channel de la license ∈ channels de la version (ou « default »)
|
||
// • can_see_betas = 1 si la version est isBeta=true
|
||
// Pour chaque license éligible : split contact_emails + send via Mailer.
|
||
// Compteurs retournés dans le message admin.
|
||
$entryId = (string)($_POST['id'] ?? '');
|
||
$idx = find_entry_index_by_id($manifest, $entryId);
|
||
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
|
||
$entry = $manifest['versions'][$idx];
|
||
$version = $entry['version'] ?? '?';
|
||
$isBeta = !empty($entry['isBeta']);
|
||
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
|
||
? $entry['channels']
|
||
: ['default'];
|
||
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
|
||
|
||
// Charge release notes (Markdown brut → on l'envoie tel quel,
|
||
// les MUA modernes l'affichent OK même sans rendu)
|
||
$notesFile = "$notesDir/{$entryId}.md";
|
||
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
|
||
|
||
// Récupère les licenses éligibles. 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>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
|
||
<?php foreach ($channelRegistry['channels'] as $c):
|
||
$cn = $c['name'] ?? '';
|
||
$isDefault = $cn === 'default';
|
||
?>
|
||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $isDefault ? 'checked' : '' ?>>
|
||
<code><?= htmlspecialchars($cn) ?></code>
|
||
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Release notes (Markdown)</label>
|
||
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col field">
|
||
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
||
</div>
|
||
<div class="col field">
|
||
<label><input type="checkbox" name="is_beta"> Version BÊTA <span class="muted" style="font-weight: normal; font-size: 11px;">(visible uniquement par les licenses « can_see_betas »)</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal; font-size: 11px;">(affichées en tooltip dans le launcher quand BÊTA est coché)</span></label>
|
||
<input type="text" name="beta_notes" placeholder="Tester en priorité : nouvelle UI scènes, intégration capteurs.">
|
||
</div>
|
||
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<h2 style="margin: 0; flex: 1;">Manifest actuel</h2>
|
||
<span class="muted">
|
||
Signature :
|
||
<?= empty($manifest['signature'])
|
||
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
|
||
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
|
||
<?php if (!empty($manifest['latest'])): ?>
|
||
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
||
<?php endif; ?>
|
||
</span>
|
||
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="sync_versions">
|
||
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
|
||
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule TOUS les SHA-256 même si les ZIPs n'ont pas changé. Lent. Cocher seulement en cas de doute sur le cache.">
|
||
<input type="checkbox" name="force" value="1"> Force re-hash
|
||
</label>
|
||
</form>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Version</th>
|
||
<th>Channels</th>
|
||
<th>Release</th>
|
||
<th>min_license</th>
|
||
<th>ZIP</th>
|
||
<th>Hash</th>
|
||
<th>Visible</th>
|
||
<th style="text-align:right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($manifest['versions'] ?? [] as $v):
|
||
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||
$zipExists = in_array($zipBase, $zips, true);
|
||
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
|
||
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
|
||
?>
|
||
<?php
|
||
// Channels du version : array si défini, sinon implicite ['default']
|
||
$vChannels = isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])
|
||
? $v['channels']
|
||
: ['default'];
|
||
?>
|
||
<tr>
|
||
<td>
|
||
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
|
||
<?php if (!empty($v['isBeta'])): ?>
|
||
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<?php foreach ($vChannels as $ch): ?>
|
||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px;"><?= htmlspecialchars($ch) ?></code>
|
||
<?php endforeach; ?>
|
||
</td>
|
||
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
|
||
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
|
||
<td>
|
||
<?php if ($zipExists): ?>
|
||
<span class="badge badge-success">présent</span>
|
||
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
|
||
<?php else: ?>
|
||
<span class="badge badge-warning">absent</span>
|
||
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<?php if ($hashSkipped): ?>
|
||
<span class="badge badge-warning" title="hashAlgorithm=none : la vérif SHA-256 est désactivée pour cette release">SKIP</span>
|
||
<?php elseif ($hashed): ?>
|
||
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
|
||
<?php else: ?>
|
||
<span class="badge badge-warning">à calculer</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<form method="post" style="display:inline">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="toggle_available">
|
||
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
|
||
<button class="btn btn-secondary" type="submit">
|
||
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
|
||
</button>
|
||
</form>
|
||
</td>
|
||
<td style="text-align: right; white-space: nowrap;">
|
||
<?php
|
||
// entryId est utilisé partout (boutons row + modal IDs).
|
||
$entryId = (string)($v['id'] ?? '');
|
||
$entryIdEsc = htmlspecialchars($entryId);
|
||
$modalId = 'edit-' . $entryIdEsc;
|
||
?>
|
||
<div class="row-actions">
|
||
<?php if ($zipExists && !$hashSkipped): ?>
|
||
<form method="post" style="display:inline"
|
||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
|
||
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="sync_one">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<input type="hidden" name="force" value="1">
|
||
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<button type="button" class="btn btn-primary"
|
||
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||
✎ Modifier
|
||
</button>
|
||
<form method="post" style="display:inline"
|
||
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
|
||
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="notify_release">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
|
||
</form>
|
||
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="delete">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<button class="btn btn-danger" type="submit">✕ Retirer</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
|
||
// — un <dialog> dans un <tr style="display:none"> est exclu du
|
||
// layout tree par certains browsers, ce qui empêche showModal()
|
||
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
|
||
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
|
||
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||
<header>
|
||
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
|
||
<?php if (!empty($v['isBeta'])): ?>
|
||
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
|
||
<?php endif; ?>
|
||
</h3>
|
||
<button type="button" class="close-x" aria-label="Fermer"
|
||
onclick="this.closest('dialog').close()">×</button>
|
||
</header>
|
||
|
||
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
|
||
un seul handler global JS déclaré en bas de page (délégation
|
||
d'événement sur document → pas besoin de rebind par modal). -->
|
||
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
|
||
<button type="button" data-tab="meta" class="active">Méta</button>
|
||
<button type="button" data-tab="notes">Notes</button>
|
||
<button type="button" data-tab="beta">BÊTA</button>
|
||
<button type="button" data-tab="channels">Channels</button>
|
||
<button type="button" data-tab="advanced">Avancé</button>
|
||
</nav>
|
||
|
||
<div class="modal-body">
|
||
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
|
||
<section data-tab="meta">
|
||
<p class="section-intro">
|
||
Date d'expiration mini d'une license pour télécharger cette version,
|
||
date de release publique, et nom du fichier ZIP côté serveur (utile pour
|
||
invalider le cache CDN après un re-build).
|
||
</p>
|
||
<form method="post">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="edit_meta">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<div class="field">
|
||
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
|
||
<input type="date" name="min_license_date"
|
||
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
||
</div>
|
||
<div class="field">
|
||
<label>Date de release</label>
|
||
<?php
|
||
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
|
||
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
|
||
// L'heure n'est pas affichée à l'opérateur — toutes les releases
|
||
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
|
||
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
|
||
?>
|
||
<input type="date" name="released_at"
|
||
value="<?= htmlspecialchars($releasedDateOnly) ?>">
|
||
</div>
|
||
<div class="field">
|
||
<label>Renommer le ZIP
|
||
<span class="muted" style="font-weight: normal;">
|
||
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
|
||
</span>
|
||
</label>
|
||
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
|
||
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
|
||
</div>
|
||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- TAB : Notes -->
|
||
<section data-tab="notes" hidden>
|
||
<p class="section-intro">
|
||
Markdown des release notes de cette version. Affichées dans le dialog
|
||
d'install côté launcher et accessibles via le menu « Notes de release ».
|
||
</p>
|
||
<form method="post">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="edit_notes">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<div class="field">
|
||
<textarea name="notes" rows="14"
|
||
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
|
||
</div>
|
||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- TAB : BÊTA -->
|
||
<section data-tab="beta" hidden>
|
||
<p class="section-intro">
|
||
Une version BÊTA n'est visible que par les licenses qui ont le flag
|
||
<code>can_see_betas</code> activé. Les autres clients voient la version
|
||
comme inexistante.
|
||
</p>
|
||
<form method="post">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="set_beta">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<div class="field">
|
||
<label>
|
||
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
|
||
Cette version est une BÊTA
|
||
</label>
|
||
</div>
|
||
<div class="field">
|
||
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
|
||
<input type="text" name="beta_notes"
|
||
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
|
||
placeholder="Tester en priorité…">
|
||
</div>
|
||
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
|
||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- TAB : Channels -->
|
||
<section data-tab="channels" hidden>
|
||
<p class="section-intro">
|
||
Détermine quelles licenses voient cette version. Coche « default » pour
|
||
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
|
||
la page <a href="channels.php">Channels</a>).
|
||
</p>
|
||
<form method="post">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="set_channels">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<div class="field">
|
||
<div class="checkbox-list">
|
||
<?php foreach ($channelRegistry['channels'] as $c):
|
||
$cn = $c['name'] ?? '';
|
||
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
|
||
?>
|
||
<label>
|
||
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
|
||
<code><?= htmlspecialchars($cn) ?></code>
|
||
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<p class="warn">⚠ Re-signe le manifest après modif.</p>
|
||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- TAB : Avancé (skip hash + force re-hash) -->
|
||
<section data-tab="advanced" hidden>
|
||
<p class="section-intro">
|
||
Options de vérification d'intégrité. La sécurité repose sur la signature
|
||
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
|
||
utile sur très gros builds pour gagner 30s-3min en fin de DL.
|
||
</p>
|
||
<div class="field">
|
||
<label>Vérification SHA-256 chez les clients</label>
|
||
<p class="hint" style="margin: 0 0 8px;">
|
||
<?php if ($hashSkipped): ?>
|
||
Actuellement <strong style="color: var(--busy);">désactivée</strong>
|
||
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
|
||
<?php else: ?>
|
||
Actuellement <strong style="color: var(--installed);">activée</strong>
|
||
(default sain).
|
||
<?php endif; ?>
|
||
</p>
|
||
<form method="post" style="display:inline">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="set_skip_hash">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
|
||
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
|
||
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<?php if ($zipExists): ?>
|
||
<div class="field">
|
||
<label>Forcer un re-hash du ZIP</label>
|
||
<p class="hint" style="margin: 0 0 8px;">
|
||
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
|
||
Utile pour valider une intégrité après transfert SFTP douteux.
|
||
</p>
|
||
<form method="post" style="display:inline"
|
||
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
|
||
<?= Layout::csrfField() ?>
|
||
<input type="hidden" name="action" value="sync_one">
|
||
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
|
||
<input type="hidden" name="force" value="1">
|
||
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
|
||
</form>
|
||
</div>
|
||
<?php endif; ?>
|
||
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</dialog>
|
||
<?php
|
||
// Capture le markup du dialog et le pousse dans un buffer global
|
||
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
|
||
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
|
||
// de manière imprévisible par le parser HTML, ce qui peut casser
|
||
// showModal() (élément non connecté au layout tree).
|
||
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
|
||
?>
|
||
<?php endforeach; ?>
|
||
<?php if (empty($manifest['versions'])): ?>
|
||
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
||
<?php endif; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
|
||
// pour que showModal() fonctionne fiablement (le top layer est indépendant
|
||
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
|
||
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||
|
||
<div class="card">
|
||
<h2>ZIPs présents dans builds/</h2>
|
||
<?php if (empty($zips)): ?>
|
||
<p class="muted">Aucun ZIP. Upload tes fichiers via SFTP dans <code>www/PS_Launcher/builds/</code>, puis clique « Sync » ci-dessus.</p>
|
||
<?php else: ?>
|
||
<table>
|
||
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
|
||
<tbody>
|
||
<?php
|
||
$referencedNames = [];
|
||
foreach ($manifest['versions'] ?? [] as $v) {
|
||
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
|
||
}
|
||
foreach ($zips as $z):
|
||
$referenced = in_array($z, $referencedNames, true);
|
||
?>
|
||
<tr>
|
||
<td><code><?= htmlspecialchars($z) ?></code></td>
|
||
<td class="muted"><?= Layout::formatBytes($zipSizes[$z]) ?></td>
|
||
<td><?= $referenced
|
||
? "<span class='badge badge-success'>oui</span>"
|
||
: "<span class='badge badge-warning'>orphelin</span>" ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- Overlay busy : visible pendant les opérations longues côté serveur
|
||
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
|
||
Affiché en synchrone juste avant le submit du form ; disparaît
|
||
naturellement au reload de page qui suit la réponse POST. -->
|
||
<div id="busy-overlay" class="busy-overlay" hidden>
|
||
<div class="busy-content">
|
||
<div class="busy-spinner"></div>
|
||
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
|
||
<p id="busy-detail">Sur un build de 13 Go, ça prend 3–5 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 (3–5 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 3–5 minutes selon les performances disque du serveur.';
|
||
}
|
||
overlay.hidden = false;
|
||
}
|
||
</script>
|
||
|
||
<?php Layout::footer();
|