(string)($c['name'] ?? ''), $channelRegistry['channels']);
/**
* Sanitize la liste de channels postée. On garde uniquement ceux qui existent
* dans le registre — un attaquant ne peut pas tagger une version sur un channel
* non déclaré. Si la liste finit vide, on retombe sur ['default'] (= public).
*/
function sanitizeChannels(array $posted, array $allowedNames): array
{
$out = [];
foreach ($posted as $c) {
$c = (string)$c;
if (in_array($c, $allowedNames, true) && !in_array($c, $out, true)) {
$out[] = $c;
}
}
if (empty($out)) $out = ['default'];
return $out;
}
/**
* ID stable interne pour identifier une entrée du manifest. Permet d'avoir
* plusieurs entrées avec le même numéro de version (ex. v1.5.3 pour police +
* v1.5.3 pour pompier) — la "version" devient un libellé human-readable, pas
* un identifiant unique. Le tag de channels distingue qui voit quoi.
*
* Format : 'v' + 8 hex chars random. Immutable une fois généré.
*/
function generate_entry_id(): string
{
try { return 'v' . bin2hex(random_bytes(4)); }
catch (\Throwable) { return 'v' . dechex(mt_rand(0, 0xffffffff)); }
}
function loadManifest(string $path): array
{
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
$manifest = json_decode(file_get_contents($path), true) ?? [];
// Migration douce : on ajoute un `id` aux entrées qui n'en ont pas (legacy
// pre-v0.27.1). Auto-save à la fin pour que l'id soit persisté et ne change
// plus aux reloads suivants. Sans ça, les forms pointeraient sur des ids
// jetables et les actions tomberaient à plat au refresh.
$dirty = false;
foreach ($manifest['versions'] ?? [] as &$v) {
if (empty($v['id'])) { $v['id'] = generate_entry_id(); $dirty = true; }
}
unset($v);
if ($dirty) saveManifest($path, $manifest);
return $manifest;
}
function saveManifest(string $path, array $manifest): void
{
// Re-tri par SemVer descendant. Pour des entrées de même version,
// tiebreak sur l'id (stable mais arbitraire) — l'admin peut compter
// sur un ordre déterministe.
usort($manifest['versions'], function($a, $b) {
$cmp = version_compare($b['version'] ?? '0.0.0', $a['version'] ?? '0.0.0');
return $cmp !== 0 ? $cmp : strcmp($a['id'] ?? '', $b['id'] ?? '');
});
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
function find_entry_index_by_id(array $manifest, string $id): ?int
{
foreach ($manifest['versions'] ?? [] as $i => $v) {
if (($v['id'] ?? '') === $id) return $i;
}
return null;
}
/**
* Construit le corps HTML de l'email de release announce. Message en anglais
* (= langue par défaut du parc client international VR PROSERVE), inclut les
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
* Le owner_name de la license est utilisé pour personnaliser le greeting.
* Le logo (optionnel via
pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
$notesBlock = $notesEsc !== ''
? "Release notes
\n"
. "{$notesEsc}"
: 'No release notes for this version.
';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
// en blanc sur le fond bleu et le destinataire voit quand même la marque.
$logoBlock = $logoEsc !== ''
? "
"
: 'ASTERION VR';
// Email HTML doit utiliser table-based layout + bgcolor attribute (pas juste
// CSS background) parce qu'Outlook desktop utilise le moteur HTML de Word,
// qui ignore linear-gradient(), :root, position:fixed, et beaucoup d'autres
// règles CSS modernes. Le résultat précédent ressemblait à un email plain
// text sans bandeau bleu. Avec ce layout :
// • → pas de strip Outlook
// • width="640" attribute (vs CSS max-width) → respecté par Outlook
// • Pas de gradient (impossible OOTB en HTML email standard) — solid blue
// URL d'install du launcher (= installer Inno Setup self-extracting Windows).
// Servi statiquement par Apache depuis server/installer/. Si l'opérateur
// change ce path, mettre à jour ici. Pas de paramétrage config volontaire :
// le path est canonique côté projet et tous les launchers en partagent un.
$installerUrl = rtrim($baseUrl, '/') !== ''
? rtrim($baseUrl, '/') . '/installer/PS_Launcher-Setup.exe'
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
return <<
{$entryId}). Upload le ZIP {$zipFilename} en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
}
elseif ($action === 'edit_notes') {
$entryId = (string)($_POST['id'] ?? '');
$notes = $_POST['notes'] ?? '';
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
file_put_contents("$notesDir/{$entryId}.md", $notes);
$message = "Release notes de v" . ($manifest['versions'][$idx]['version'] ?? '?') . " mises à jour.";
}
elseif ($action === 'edit_meta') {
$entryId = (string)($_POST['id'] ?? '');
$minLicDate = trim($_POST['min_license_date'] ?? '');
$releasedAt = trim($_POST['released_at'] ?? '');
$newZipName = trim((string)($_POST['zip_filename'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
throw new Exception('min_license_date invalide.');
}
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '';
$manifest['versions'][$idx]['minLicenseDate'] = $minLicDate;
if ($releasedAt !== '') {
$manifest['versions'][$idx]['releasedAt'] =
(new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
}
if ($newZipName !== '') {
$normalized = ps_normalize_zip_filename($newZipName, $version);
// Anti-collision : interdit de renommer vers un ZIP déjà utilisé par une autre entry
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherZip = basename(parse_url($other['download']['url'] ?? '', PHP_URL_PATH) ?: '');
if ($otherZip === $normalized) {
throw new Exception("Le ZIP « {$normalized} » est déjà utilisé par une autre entry. Choisis un autre nom.");
}
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$manifest['versions'][$idx]['download']['url'] = "{$base}/builds/{$normalized}";
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
}
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
}
elseif ($action === 'set_channels') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$channels = sanitizeChannels((array)($_POST['channels'] ?? []), $channelNames);
$manifest['versions'][$idx]['channels'] = $channels;
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$message = "Channels de v{$version} : " . implode(', ', $channels) . ". Re-Sync pour rafraîchir la signature du manifest.";
}
elseif ($action === 'set_beta') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$isBeta = !empty($_POST['is_beta']);
$betaNotes = trim($_POST['beta_notes'] ?? '') ?: null;
if ($isBeta) {
$manifest['versions'][$idx]['isBeta'] = true;
if ($betaNotes !== null) {
$manifest['versions'][$idx]['betaNotes'] = $betaNotes;
} else {
unset($manifest['versions'][$idx]['betaNotes']);
}
} else {
unset($manifest['versions'][$idx]['isBeta']);
unset($manifest['versions'][$idx]['betaNotes']);
}
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$message = "Statut BÊTA de v{$version} mis à jour. N'oublie pas de re-signer le manifest (« 🔁 Sync »).";
}
elseif ($action === 'toggle_available') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$manifest['versions'][$idx]['availableForDownload'] = !($manifest['versions'][$idx]['availableForDownload'] ?? true);
saveManifest($manifestPath, $manifest);
$version = $manifest['versions'][$idx]['version'] ?? '?';
$message = "Disponibilité de v{$version} mise à jour.";
}
elseif ($action === 'delete') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '?';
// Cleanup release notes file (best effort)
$notesFile = "$notesDir/{$entryId}.md";
if (is_file($notesFile)) @unlink($notesFile);
$manifest['versions'] = array_values(array_filter(
$manifest['versions'],
fn($v) => ($v['id'] ?? '') !== $entryId
));
saveManifest($manifestPath, $manifest);
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
}
elseif ($action === 'sync' || $action === 'sync_versions') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$scope = $action === 'sync_versions' ? 'versions' : 'all';
$force = !empty($_POST['force']);
$result = $signer->run($scope, $force);
$label = $scope === 'versions' ? 'versions Proserve' : 'manifest complet';
$forceLabel = $force ? ' [FORCE: cache ignoré]' : ' [cache utilisé pour les ZIPs inchangés]';
$message = "Sortie du script (scope: {$label}{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'sync_one') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$version = $manifest['versions'][$idx]['version'] ?? '?';
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
// SignManifest::run filtre par numéro de version ; en cas d'entries
// de même version sur des channels différents, le hash sera recalculé
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
// donc le résultat est correct, juste un peu plus de boulot).
$result = $signer->run('versions', $force, $version);
$forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
elseif ($action === 'set_skip_hash') {
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$skipHash = !empty($_POST['skip']);
$version = $manifest['versions'][$idx]['version'] ?? '?';
if ($skipHash) {
$manifest['versions'][$idx]['download']['hashAlgorithm'] = 'none';
$manifest['versions'][$idx]['download']['sha256'] = '';
} else {
unset($manifest['versions'][$idx]['download']['hashAlgorithm']);
if (empty($manifest['versions'][$idx]['download']['sha256'])) {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
}
}
saveManifest($manifestPath, $manifest);
// Re-signature auto pour garder le manifest valide après le changement.
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$resign = $signer->run('versions', false);
$message = ($skipHash
? "Vérif SHA-256 désactivée pour v{$version} et manifest re-signé."
: "Vérif SHA-256 réactivée pour v{$version}. Clique « 🔁 Hash » sur cette ligne pour calculer le SHA-256.")
. "\n" . implode("\n", $resign['log']);
if (!$resign['ok']) $messageType = 'error';
}
elseif ($action === 'notify_release') {
// Envoie une notif email aux contacts des licenses ÉLIGIBLES pour
// cette version. Critères d'éligibilité (= license verra la version
// dans son manifest signé) :
// • Pas révoquée
// • download_entitlement_until >= minLicenseDate de la version
// • channel de la license ∈ channels de la version (ou « default »)
// • can_see_betas = 1 si la version est isBeta=true
// Pour chaque license éligible : split contact_emails + send via Mailer.
// Compteurs retournés dans le message admin.
$entryId = (string)($_POST['id'] ?? '');
$idx = find_entry_index_by_id($manifest, $entryId);
if ($idx === null) throw new Exception("Entry id={$entryId} introuvable.");
$entry = $manifest['versions'][$idx];
$version = $entry['version'] ?? '?';
$isBeta = !empty($entry['isBeta']);
$vChannels = isset($entry['channels']) && is_array($entry['channels']) && !empty($entry['channels'])
? $entry['channels']
: ['default'];
$minLic = $entry['minLicenseDate'] ?? '1970-01-01';
// Charge release notes (Markdown brut → on l'envoie tel quel,
// les MUA modernes l'affichent OK même sans rendu)
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
AND contact_emails IS NOT NULL
AND contact_emails != \'\''
);
$stmt->execute([$minLic]);
$licenses = $stmt->fetchAll();
$notifConfig = $config['notifications'] ?? [];
// Prépare le logo en CID embed (vs hotlink) pour qu'Outlook l'affiche
// SANS demander de permission "Télécharger les images". Le path
// pointe vers admin/assets/asterion-logo.png par défaut, ou un
// override via $notifConfig['logo_path']. Si le fichier n'existe
// pas, $logoCid reste vide → le template skip le bloc image et
// utilise le fallback texte "ASTERION VR" en blanc.
//
// PAS DE FALLBACK HOTLINK si le fichier est introuvable : Outlook
// bloque tout hotlink par défaut, et quand l'URL retourne 404,
// il n'offre même pas le bouton « Télécharger les images »
// (il affiche juste un placeholder rouge cassé). Mieux vaut le
// fallback texte que ce visuel anti-pro.
$logoPath = $notifConfig['logo_path'] ?? (__DIR__ . '/assets/asterion-logo.png');
$logoCid = '';
$logoMissing = false;
$inlineImages = [];
if (is_file($logoPath) && is_readable($logoPath)) {
$logoCid = 'asterion-logo@pslauncher';
$inlineImages[] = [
'cid' => $logoCid,
'path' => $logoPath,
'type' => 'image/png',
];
} else {
$logoMissing = true;
}
$totalSent = 0; $totalFailed = 0; $licsTouched = 0; $emailsSet = [];
foreach ($licenses as $lic) {
$licChannel = $lic['channel'] ?? 'default';
// Match : la license voit la version si son channel est dans
// la liste des channels de la version (ou si version='default'
// et license sans channel = public)
$licChannelEffective = $licChannel ?? 'default';
if (!in_array($licChannelEffective, $vChannels, true)) continue;
if ($isBeta && (int)$lic['can_see_betas'] !== 1) continue;
$emails = Mailer::parseEmails($lic['contact_emails']);
if (empty($emails)) continue;
$licsTouched++;
$subject = "PROSERVE v{$version} is now available";
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
// tombe PAS sur logo_url (hotlink) parce qu'Outlook bloque
// tout hotlink par défaut et affiche un placeholder cassé
// sans même offrir le bouton "Télécharger les images" si
// l'URL retourne 404. Cf logoMissing détecté plus haut.
$logoSrc = $logoCid !== '' ? "cid:{$logoCid}" : '';
$bodyHtml = renderReleaseAnnounceEmail(
$version,
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc
);
foreach ($emails as $email) {
// Dédup : ne pas envoyer 2× à la même adresse si elle est
// contact sur plusieurs licenses du même client.
if (isset($emailsSet[$email])) continue;
$emailsSet[$email] = true;
$ok = Mailer::send($notifConfig, $email, $subject, $bodyHtml, $inlineImages);
if ($ok) $totalSent++; else $totalFailed++;
}
}
$message = "Notification release v{$version} : {$totalSent} email(s) envoyé(s) " .
"(à {$licsTouched} license(s) éligibles, " .
($totalFailed > 0 ? "{$totalFailed} échec(s) — vérifier mail() / SMTP serveur)" : "0 échec)");
// Si le logo PNG est introuvable côté serveur, le préviens — sinon
// l'opérateur découvre le bug seulement quand un destinataire lui
// dit que l'email n'a pas de logo.
if ($logoMissing) {
$message .= "\n\n⚠ Logo Asterion non trouvé à : {$logoPath}\n" .
" Upload `asterion-logo.png` dans admin/assets/ via SFTP, sinon les emails partent avec le fallback texte 'ASTERION VR' au lieu du logo.";
$messageType = 'error';
}
if ($totalFailed > 0) $messageType = 'error';
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$manifest = loadManifest($manifestPath);
// Liste des ZIPs : tout est flat dans builds/, indexé par filename simple.
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
$zipSizes = [];
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
// Pour l'édition : pré-charge les release notes existantes (indexées par
// id stable maintenant). Fallback sur l'ancien format {version}.md pour les
// notes créées avant v0.27.1, qu'on n'a pas encore migrées.
$existingNotes = [];
foreach ($manifest['versions'] ?? [] as $v) {
$id = $v['id'] ?? '';
$byId = $id !== '' ? "$notesDir/{$id}.md" : null;
$byVersion = "$notesDir/{$v['version']}.md";
if ($byId !== null && is_file($byId)) {
$existingNotes[$id] = file_get_contents($byId);
} elseif (is_file($byVersion)) {
$existingNotes[$id] = file_get_contents($byVersion);
} else {
$existingNotes[$id] = '';
}
}
Layout::header('Versions', 'versions');
?>
VersionsWorkflow d'une nouvelle release
💡 La gestion des channels (création / édition / suppression) se fait sur la page Channels. Ajouter une version
|