Files
PS_Launcher/server/admin/versions.php
j.foucher b0ca082b52 sync_one : hash uniquement l'entrée cliquée, pas toutes celles du même numéro
Bug : sur un manifest avec deux entrées partageant le même numéro de version
(channels multiples — ex proserve-firefighter-1.5.4.32.zip ET proserve-full-
1.5.4.32.zip, deux builds distincts pour firefighter vs full), cliquer le
bouton « 🔁 Hash » d'une ligne déclenchait le hash des DEUX ZIPs dans la
même requête HTTP. Résultat 2 × 14 Go = 28 Go dans une seule requête, ce
qui dépasse le timeout front d'OVH mutualisé (hard-limit invisible côté
PHP même avec set_time_limit(0)) → 500 Internal Server Error.

C'est pour ça que « des hashs de la même taille passaient sans problème
avant » : c'est le cas 28 Go des DEUX ZIPs en une requête qui est nouveau
(depuis l'introduction des channels), pas la taille du single 14 Go qui
passait déjà.

Fix : nouveau paramètre $onlyEntryId sur SignManifest::run(). Prend le pas
sur $onlyVersion. L'admin action sync_one passe l'entryId (unique par
ligne, généré par generate_entry_id()) au lieu du numéro de version. Une
ligne cliquée = un ZIP hashé, point. Le bouton « 🔁 Sync (all versions) »
(action sync_versions) continue de tout hasher — c'est ce que l'opérateur
demande explicitement.

Rétro-compat : si $onlyEntryId est null (CLI, cron, anciens callers),
$onlyVersion filtre comme avant.

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

1415 lines
87 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$root = dirname(__DIR__);
$manifestDir = "$root/manifest";
$buildsDir = "$root/builds";
$notesDir = "$root/releasenotes";
$manifestPath = "$manifestDir/versions.json";
$message = null; $messageType = 'success';
// Liste des channels déclarés dans channels.json (incluant "default" implicite)
$channelRegistry = Channels::load($manifestDir);
$channelNames = array_map(fn($c) => (string)($c['name'] ?? ''), $channelRegistry['channels']);
/**
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
*/
function sanitizeChannels(array $posted, array $allowedNames): array
{
$out = [];
foreach ($posted as $c) {
$c = (string)$c;
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
$out[] = $c;
}
}
if (empty($out)) $out = ['default'];
return $out;
}
/**
* ID stable interne pour identifier une entrée du manifest. Permet d'avoir
* plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police +
* v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas
* un identifiant unique. Le tag de channels distingue qui voit quoi.
*
* Format : 'v' + 8 hex chars random. Immutable une fois généré.
*/
function generate_entry_id(): string
{
try { return 'v' . bin2hex(random_bytes(4)); }
catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); }
}
function loadManifest(string $path): array
{
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
$manifest = json_decode(file_get_contents($path), true) ?? [];
// Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy
// pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change
// plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids
// jetables et les actions tomberaient à plat au refresh.
$dirty = false;
foreach ($manifest['versions'] ?? [] as &$v) {
if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; }
}
unset($v);
if ($dirty) saveManifest($path, $manifest);
return $manifest;
}
function saveManifest(string $path, array $manifest): void
{
// Re-tri par SemVer descendant. Pour des entrées de même version,
// tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter
// sur un ordre déterministe.
usort($manifest['versions'], function($a, $b) {
$cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0');
return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? '');
});
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
function find_entry_index_by_id(array $manifest, string $id): ?int
{
foreach ($manifest['versions'] ?? [] as $i => $v) {
if (($v['id'] ?? '') === $id) return $i;
}
return null;
}
/**
* 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']);
// Filtre par entryId (pas par numéro de version). Sinon, quand deux
// entrées partagent le même numéro (channels firefighter vs full sur
// 1.5.4.32 p.ex.), les DEUX ZIPs sont hashés dans la même requête HTTP
// → 2 × 14 Go = risque de dépasser le timeout front OVH sur mutualisé.
$result = $signer->run('versions', $force, null, $entryId);
$forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'set_skip_hash') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$skipHash = !empty($_POST['skip']);
$version = $manifest['versions'][$idx]['version'] ?? '?';
if ($skipHash) {
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
$manifest['versions'][$idx]['download']['sha256'] = '';
} else {
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
}
}
saveManifest($manifestPath, $manifest);
// Re-signature auto pour garder le manifest valide après le changement.
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$resign = $signer->run('versions', false);
$message = ($skipHash
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
: "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.")
. "\n" . implode("\n", $resign['log']);
if (!$resign['ok']) $messageType = 'error';
}
elseif ($action === 'notify_release') {
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
// cette version. Critères d'éligibilité (= license verra la version
// dans son manifest signé) :
// • Pas révoquée
// • download_entitlement_until >= minLicenseDate de la version
// • channel de la license ∈ channels de la version (ou « default »)
// • can_see_betas = 1 si la version est isBeta=true
// Pour chaque license éligible : split contact_emails + send via Mailer.
// Compteurs retournés dans le message admin.
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$entry = $manifest['versions'][$idx];
$version = $entry['version'] ?? '?';
$isBeta = !empty($entry['isBeta']);
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
? $entry['channels']
: ['default'];
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
// Charge release notes (Markdown brut → on l'envoie tel quel,
// les MUA modernes l'affichent OK même sans rendu)
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles. On inclut maintenant `language`
// pour pouvoir localiser l'email — fallback 'en' si NULL ou inconnu.
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, language, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
AND contact_emails IS NOT NULL
AND contact_emails != \'\''
);
$stmt->execute([$minLic]);
$licenses = $stmt->fetchAll();
$notifConfig = $config['notifications'] ?? [];
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
// SANS demander de permission "Télécharger les images". Le path
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
// override via $notifConfig['logo_path']. Si le fichier n'existe
// pas, $logoCid reste vide → le template skip le bloc image et
// utilise le fallback texte "ASTERION VR" en blanc.
//
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
// il n'offre même pas le bouton « Télécharger les images »
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
// fallback texte que ce visuel anti-pro.
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
$logoCid = '';
$logoMissing = false;
$inlineImages = [];
if (is_file($logoPath) && is_readable($logoPath)) {
$logoCid = 'asterion-logo@pslauncher';
$inlineImages[] = [
'cid' => $logoCid,
'path' => $logoPath,
'type' => 'image/png',
];
} else {
$logoMissing = true;
}
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
foreach ($licenses as $lic) {
$licChannel = $lic['channel'] ?? 'default';
// Match : la license voit la version si son channel est dans
// la liste des channels de la version (ou si version='default'
// et license sans channel = public)
$licChannelEffective = $licChannel ?? 'default';
if (!in_array($licChannelEffective, $vChannels, true)) continue;
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
$emails = Mailer::parseEmails($lic['contact_emails']);
if (empty($emails)) continue;
$licsTouched++;
// Locale par license : whitelist contre les langues supportées,
// fallback 'en' pour les licenses sans préférence définie.
$locale = trim((string)($lic['language'] ?? ''));
if (!in_array($locale, ['fr', 'en', 'zh', 'th', 'ar'], true)) {
$locale = 'en';
}
// Sujet localisé via le même dictionnaire que le corps.
$subjectTemplate = getEmailStrings($locale)['subject'] ?? 'PROSERVE v{version} is now available';
$subject = str_replace('{version}', $version, $subjectTemplate);
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
// tout hotlink par défaut et affiche un placeholder cassé
// sans même offrir le bouton "Télécharger les images" si
// l'URL retourne 404. Cf logoMissing détecté plus haut.
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
$bodyHtml = renderReleaseAnnounceEmail(
$version,
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc,
$locale
);
foreach ($emails as $email) {
// Dédup : ne pas envoyer 2× à la même adresse si elle est
// contact sur plusieurs licenses du même client.
if (isset($emailsSet[$email])) continue;
$emailsSet[$email] = true;
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
if ($ok) $totalSent++; else $totalFailed++;
}
}
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
"{$licsTouched} license(s) éligibles, " .
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
// l'opérateur découvre le bug seulement quand un destinataire lui
// dit que l'email n'a pas de logo.
if ($logoMissing) {
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
$messageType = 'error';
}
if ($totalFailed > 0) $messageType = 'error';
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$manifest = loadManifest($manifestPath);
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
$zipSizes = [];
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
// Pour l'édition : pré-charge les release notes existantes (indexées par
// id stable maintenant). Fallback sur l'ancien format {version}.md pour les
// notes créées avant v0.27.1, qu'on n'a pas encore migrées.
$existingNotes = [];
foreach ($manifest['versions'] ?? [] as $v) {
$id = $v['id'] ?? '';
$byId = $id !== '' ? "$notesDir/{$id}.md" : null;
$byVersion = "$notesDir/{$v['version']}.md";
if ($byId !== null && is_file($byId)) {
$existingNotes[$id] = file_get_contents($byId);
} elseif (is_file($byVersion)) {
$existingNotes[$id] = file_get_contents($byVersion);
} else {
$existingNotes[$id] = '';
}
}
Layout::header('Versions', 'versions');
?>
<h1>Versions</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>Workflow d'une nouvelle release</h2>
<ol class="muted">
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes, channels).</li>
<li>Upload le ZIP en SFTP dans <code>www/PS_Launcher/builds/</code> (le nom doit matcher celui que tu as saisi, par défaut <code>proserve-{version}.zip</code>).</li>
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ », filtrés selon le channel de leur license.</li>
</ol>
<p class="muted" style="margin: 12px 0 0;">
💡 La gestion des channels (création / édition / suppression) se fait sur la page <a href="channels.php">Channels</a>.
</p>
</div>
<div class="card">
<h2>Ajouter une version</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="add">
<div class="row">
<div class="col field">
<label>Version (X.Y.Z, ou X.Y.Z.B pour une itération dev)</label>
<input type="text" name="version" placeholder="1.4.8 ou 1.5.4.13" required pattern="\d+\.\d+\.\d+(\.\d+)?"
title="Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne avec build number)">
</div>
<?php
// Défauts conventionnels :
// • released_at = aujourd'hui (l'opérateur est en train d'éditer
// l'entry au moment du release, ça serait absurde d'avoir un
// champ vide qu'il faut systématiquement remplir)
// • min_license_date = aujourd'hui - 1 an (les licenses sont
// valides 1 an, donc une license émise il y a moins d'un an
// a accès à cette version par défaut). Modifiable pour les
// cas particuliers (release LTS, security patch limité aux
// dernières licenses, etc.).
$defaultReleasedAt = date('Y-m-d');
$defaultMinLicDate = date('Y-m-d', strtotime('-1 year'));
?>
<div class="col field">
<label>Date de release</label>
<input type="date" name="released_at"
value="<?= $defaultReleasedAt ?>"
title="Date publique de la release. Default = aujourd'hui. L'heure n'est pas conservée — toutes les releases sont datées à minuit UTC.">
</div>
<div class="col field">
<label>min_license_date <span class="muted" style="font-weight: normal; font-size: 11px;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date" required
value="<?= $defaultMinLicDate ?>"
title="Une license n'a accès à cette version que si elle a été émise APRÈS cette date. Default = aujourd'hui - 1 an (= les licenses 'standard' couvrent les releases du dernier an).">
</div>
</div>
<div class="field">
<label>Nom du fichier ZIP <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, par défaut <code>proserve-{version}.zip</code>)</span></label>
<input type="text" name="zip_filename" placeholder="proserve-1.4.8-police.zip" maxlength="120">
</div>
<div class="field">
<label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120">
</div>
<div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$isDefault = $cn === 'default';
?>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $isDefault ? 'checked' : '' ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted" style="font-size: 11px;"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="field">
<label>Release notes (Markdown)</label>
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8&#10;&#10;## Nouveautés&#10;- ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
</div>
<div class="row">
<div class="col field">
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
</div>
<div class="col field">
<label><input type="checkbox" name="is_beta"> Version BÊTA <span class="muted" style="font-weight: normal; font-size: 11px;">(visible uniquement par les licenses « can_see_betas »)</span></label>
</div>
</div>
<div class="field">
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal; font-size: 11px;">(affichées en tooltip dans le launcher quand BÊTA est coché)</span></label>
<input type="text" name="beta_notes" placeholder="Tester en priorité : nouvelle UI scènes, intégration capteurs.">
</div>
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
</form>
</div>
<div class="card">
<div class="toolbar">
<h2 style="margin: 0; flex: 1;">Manifest actuel</h2>
<span class="muted">
Signature :
<?= empty($manifest['signature'])
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
<?php if (!empty($manifest['latest'])): ?>
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
<?php endif; ?>
</span>
<form method="post" style="display:inline" onsubmit="showBusyOverlay('bulk')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_versions">
<button class="btn btn-primary" type="submit">🔁 Hasher les versions + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule TOUS les SHA-256 même si les ZIPs n'ont pas changé. Lent. Cocher seulement en cas de doute sur le cache.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form>
</div>
<table>
<thead>
<tr>
<th>Version</th>
<th>Channels</th>
<th>Release</th>
<th>min_license</th>
<th>ZIP</th>
<th>Hash</th>
<th>Visible</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($manifest['versions'] ?? [] as $v):
$zipBase = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
$zipExists = in_array($zipBase, $zips, true);
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
$hashSkipped = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256')) === 'none';
?>
<?php
// Channels du version : array si défini, sinon implicite ['default']
$vChannels = isset($v['channels']) && is_array($v['channels']) && !empty($v['channels'])
? $v['channels']
: ['default'];
?>
<tr>
<td>
<strong>v<?= htmlspecialchars($v['version']) ?></strong>
<?php if (!empty($v['isBeta'])): ?>
<span class="badge badge-warning" title="<?= htmlspecialchars($v['betaNotes'] ?? 'Version BÊTA') ?>" style="margin-left: 4px;">BÊTA</span>
<?php endif; ?>
</td>
<td>
<?php foreach ($vChannels as $ch): ?>
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 4px;"><?= htmlspecialchars($ch) ?></code>
<?php endforeach; ?>
</td>
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
<td>
<?php if ($zipExists): ?>
<span class="badge badge-success">présent</span>
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
<?php else: ?>
<span class="badge badge-warning">absent</span>
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
<?php endif; ?>
</td>
<td>
<?php if ($hashSkipped): ?>
<span class="badge badge-warning" title="hashAlgorithm=none : la vérif SHA-256 est désactivée pour cette release">SKIP</span>
<?php elseif ($hashed): ?>
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
<?php else: ?>
<span class="badge badge-warning">à calculer</span>
<?php endif; ?>
</td>
<td>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_available">
<input type="hidden" name="id" value="<?= htmlspecialchars($v['id'] ?? '') ?>">
<button class="btn btn-secondary" type="submit">
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
</button>
</form>
</td>
<td style="text-align: right; white-space: nowrap;">
<?php
// entryId est utilisé partout (boutons row + modal IDs).
$entryId = (string)($v['id'] ?? '');
$entryIdEsc = htmlspecialchars($entryId);
$modalId = 'edit-' . $entryIdEsc;
?>
<div class="row-actions">
<?php if ($zipExists && !$hashSkipped): ?>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')"
title="Recalcule de force le SHA-256 de cette version, en ignorant le cache .hashcache.json. À utiliser après un re-upload SFTP — le cache server-side peut considérer le fichier 'inchangé' si SFTP a préservé le mtime, ce qui retourne l'ancien hash sans recalcul.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔁 Hash</button>
</form>
<?php endif; ?>
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<form method="post" style="display:inline"
onsubmit="return confirm('Envoyer un email d\'annonce de v<?= htmlspecialchars($v['version']) ?> à tous les contacts des licenses éligibles ?\n\nUn email par contact (dédup automatique). Inclut les release notes actuelles.')"
title="Envoie une notification email à toutes les licenses qui voient cette version (channel + entitlement + can_see_betas pour les BÊTA). Inclut les release notes.">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="notify_release">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<button class="btn btn-secondary" type="submit">✉ Notifier</button>
</form>
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<button class="btn btn-danger" type="submit">✕ Retirer</button>
</form>
</div>
</td>
</tr>
<?php // Dialog rendu plus bas dans un foreach séparé hors de la <table>
// — un <dialog> dans un <tr style="display:none"> est exclu du
// layout tree par certains browsers, ce qui empêche showModal()
// de fonctionner (le bouton « ✎ Modifier » devient muet). ?>
<?php ob_start(); /* début capture HTML dialog → flush après </table> */ ?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>Éditer v<?= htmlspecialchars($v['version']) ?>
<?php if (!empty($v['isBeta'])): ?>
<span class="badge badge-warning" style="margin-left: 6px; font-size: 10px;">BÊTA</span>
<?php endif; ?>
</h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<!-- Tabs : data-tab pointe vers la section à afficher, géré par
un seul handler global JS déclaré en bas de page (délégation
d'événement sur document → pas besoin de rebind par modal). -->
<nav class="modal-tabs" data-modal="<?= $modalId ?>">
<button type="button" data-tab="meta" class="active">Méta</button>
<button type="button" data-tab="notes">Notes</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="channels">Channels</button>
<button type="button" data-tab="advanced">Avancé</button>
</nav>
<div class="modal-body">
<!-- TAB : Méta (min_license, released_at, rename ZIP) -->
<section data-tab="meta">
<p class="section-intro">
Date d'expiration mini d'une license pour télécharger cette version,
date de release publique, et nom du fichier ZIP côté serveur (utile pour
invalider le cache CDN après un re-build).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_meta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<label>min_license_date <span class="muted" style="font-weight: normal;">(license émise après cette date requise)</span></label>
<input type="date" name="min_license_date"
value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
</div>
<div class="field">
<label>Date de release</label>
<?php
// On extrait UNIQUEMENT la portion YYYY-MM-DD du releasedAt
// stocké (qui peut être un ISO complet avec heure : « 2026-05-27T00:00:00Z »).
// L'heure n'est pas affichée à l'opérateur — toutes les releases
// sont datées à minuit UTC, le moment précis n'a pas de sens UX.
$releasedDateOnly = substr($v['releasedAt'] ?? '', 0, 10);
?>
<input type="date" name="released_at"
value="<?= htmlspecialchars($releasedDateOnly) ?>">
</div>
<div class="field">
<label>Renommer le ZIP
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars(basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '')) ?></code>)
</span>
</label>
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>
</div>
</form>
</section>
<!-- TAB : Notes -->
<section data-tab="notes" hidden>
<p class="section-intro">
Markdown des release notes de cette version. Affichées dans le dialog
d'install côté launcher et accessibles via le menu « Notes de release ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit_notes">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<textarea name="notes" rows="14"
style="font-family: 'Cascadia Code', Consolas, monospace;"><?= htmlspecialchars($existingNotes[$entryId] ?? '') ?></textarea>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Notes</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Une version BÊTA n'est visible que par les licenses qui ont le flag
<code>can_see_betas</code> activé. Les autres clients voient la version
comme inexistante.
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_beta">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<label>
<input type="checkbox" name="is_beta" value="1" <?= !empty($v['isBeta']) ? 'checked' : '' ?>>
Cette version est une BÊTA
</label>
</div>
<div class="field">
<label>Notes pour les testeurs <span class="muted" style="font-weight: normal;">(affichées en tooltip dans le launcher)</span></label>
<input type="text" name="beta_notes"
value="<?= htmlspecialchars($v['betaNotes'] ?? '') ?>"
placeholder="Tester en priorité…">
</div>
<p class="warn">⚠ Re-signe le manifest (« 🔁 Sync ») après modif pour que les launchers acceptent.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Channels -->
<section data-tab="channels" hidden>
<p class="section-intro">
Détermine quelles licenses voient cette version. Coche « default » pour
la rendre publique, ou cible un ou plusieurs channels privés (gérés sur
la page <a href="channels.php">Channels</a>).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channels">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<div class="field">
<div class="checkbox-list">
<?php foreach ($channelRegistry['channels'] as $c):
$cn = $c['name'] ?? '';
$checked = in_array($cn, $vChannels, true) ? 'checked' : '';
?>
<label>
<input type="checkbox" name="channels[]" value="<?= htmlspecialchars($cn) ?>" <?= $checked ?>>
<code><?= htmlspecialchars($cn) ?></code>
<span class="muted"><?= htmlspecialchars($c['label'] ?? $cn) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<p class="warn">⚠ Re-signe le manifest après modif.</p>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channels</button>
</div>
</form>
</section>
<!-- TAB : Avancé (skip hash + force re-hash) -->
<section data-tab="advanced" hidden>
<p class="section-intro">
Options de vérification d'intégrité. La sécurité repose sur la signature
Ed25519 du manifest + HTTPS — désactiver SHA-256 reste relativement sûr,
utile sur très gros builds pour gagner 30s-3min en fin de DL.
</p>
<div class="field">
<label>Vérification SHA-256 chez les clients</label>
<p class="hint" style="margin: 0 0 8px;">
<?php if ($hashSkipped): ?>
Actuellement <strong style="color: var(--busy);">désactivée</strong>
pour cette release (le manifest sert <code>hashAlgorithm: "none"</code>).
<?php else: ?>
Actuellement <strong style="color: var(--installed);">activée</strong>
(default sain).
<?php endif; ?>
</p>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_skip_hash">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="skip" value="<?= $hashSkipped ? '0' : '1' ?>">
<button class="btn <?= $hashSkipped ? 'btn-success' : 'btn-warning' ?>" type="submit">
<?= $hashSkipped ? '✓ Réactiver la vérif' : '⏭ Désactiver la vérif' ?>
</button>
</form>
</div>
<?php if ($zipExists): ?>
<div class="field">
<label>Forcer un re-hash du ZIP</label>
<p class="hint" style="margin: 0 0 8px;">
Recalcule le SHA-256 en ignorant le cache, même si le ZIP n'a pas changé.
Utile pour valider une intégrité après transfert SFTP douteux.
</p>
<form method="post" style="display:inline"
onsubmit="showBusyOverlay('single', '<?= htmlspecialchars($v['version']) ?>')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_one">
<input type="hidden" name="id" value="<?= $entryIdEsc ?>">
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">🔄 Force re-hash</button>
</form>
</div>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php
// Capture le markup du dialog et le pousse dans un buffer global
// qui sera vidé APRÈS </table>. Pourquoi : <dialog> directement
// ou indirectement dans <table>/<tbody>/<tr> est foster-parented
// de manière imprévisible par le parser HTML, ce qui peut casser
// showModal() (élément non connecté au layout tree).
$GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean();
?>
<?php endforeach; ?>
<?php if (empty($manifest['versions'])): ?>
<tr><td colspan="8" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php // Tous les <dialog> d'édition sont émis ici, hors de la <table> parent,
// pour que showModal() fonctionne fiablement (le top layer est indépendant
// du DOM context mais l'élément doit être dans le layout tree d'abord). ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<div class="card">
<h2>ZIPs présents dans builds/</h2>
<?php if (empty($zips)): ?>
<p class="muted">Aucun ZIP. Upload tes fichiers via SFTP dans <code>www/PS_Launcher/builds/</code>, puis clique « Sync » ci-dessus.</p>
<?php else: ?>
<table>
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
<tbody>
<?php
$referencedNames = [];
foreach ($manifest['versions'] ?? [] as $v) {
$referencedNames[] = basename(parse_url($v['download']['url'] ?? '', PHP_URL_PATH) ?: '');
}
foreach ($zips as $z):
$referenced = in_array($z, $referencedNames, true);
?>
<tr>
<td><code><?= htmlspecialchars($z) ?></code></td>
<td class="muted"><?= Layout::formatBytes($zipSizes[$z]) ?></td>
<td><?= $referenced
? "<span class='badge badge-success'>oui</span>"
: "<span class='badge badge-warning'>orphelin</span>" ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Overlay busy : visible pendant les opérations longues côté serveur
(hash SHA-256 d'un build de 13 Go = 3-5 min sur OVH mutualisé).
Affiché en synchrone juste avant le submit du form ; disparaît
naturellement au reload de page qui suit la réponse POST. -->
<div id="busy-overlay" class="busy-overlay" hidden>
<div class="busy-content">
<div class="busy-spinner"></div>
<h3 id="busy-title">Calcul du SHA-256 en cours…</h3>
<p id="busy-detail">Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.</p>
<p class="muted">La page se rafraîchira automatiquement quand c'est fini.<br>
Tu peux laisser l'onglet ouvert en arrière-plan.</p>
</div>
</div>
<!-- Handler global pour les onglets des modals d'édition de version.
Délégation d'événement sur document → marche pour tous les .modal-tabs,
y compris ceux qui seraient ajoutés dynamiquement plus tard. -->
<script>
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (!btn) return;
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
// Active button → toggle .active sur les boutons du même <nav>
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
// Sections → toggle [hidden] selon data-tab
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
});
})();
// Affiche l'overlay busy au submit des forms de hash. Message adapté au scope :
// • 'bulk' → "tous les ZIPs" (bouton global Hasher les versions + signer)
// • 'single' → version précise (bouton 🔁 Hash per-row ou Force re-hash modal)
// Note : on N'EMPÊCHE PAS le submit (return undefined) — l'overlay reste affiché
// jusqu'au reload qui suit la réponse POST, donc disparaît naturellement.
function showBusyOverlay(scope, version) {
var overlay = document.getElementById('busy-overlay');
if (!overlay) return;
var title = document.getElementById('busy-title');
var detail = document.getElementById('busy-detail');
if (scope === 'bulk') {
if (title) title.textContent = 'Hashing + signing tous les ZIPs…';
if (detail) detail.textContent = 'Recalcul SHA-256 sur chaque ZIP (35 min par ZIP de 13 Go). Les ZIPs déjà hashés et inchangés sont skip via cache, sauf si « Force re-hash » coché.';
} else {
if (title) title.textContent = 'Hashing SHA-256 v' + (version || '?') + '…';
if (detail) detail.textContent = 'Sur un build de 13 Go, ça prend 35 minutes selon les performances disque du serveur.';
}
overlay.hidden = false;
}
</script>
<?php Layout::footer();