diff --git a/install.sh b/install.sh index e13201c..faa0e64 100755 --- a/install.sh +++ b/install.sh @@ -184,9 +184,9 @@ touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asteris chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true -touch /var/log/nexus_security.log -chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true -chmod 0664 /var/log/nexus_security.log 2>/dev/null || true +touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log +chown www-data:asterisk /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true +chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true # Droits applicatifs stricts chown -R www-data:asterisk "$TARGET_DIR" @@ -195,6 +195,7 @@ chmod 2775 "$TARGET_DIR/storage" \ "$TARGET_DIR/storage/system_backups" \ "$TARGET_DIR/storage/sounds" \ "$TARGET_DIR/public/avatars" +[ -d "$TARGET_DIR/ucp" ] && chmod 0755 "$TARGET_DIR/ucp" "$TARGET_DIR/ucp/assets" 2>/dev/null || true mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk @@ -207,7 +208,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s # Droits sudoers pour l'interface web (www-data) cat << 'EOF' > /etc/sudoers.d/nexus-pbx # Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data -www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster +www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster EOF chmod 0440 /etc/sudoers.d/nexus-pbx @@ -436,16 +437,22 @@ mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled rm -f /etc/nginx/sites-enabled/default ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx -# Application de la configuration Nginx complète (Ports 80, 8080, 8443) +# Application de la configuration Nginx complète (Ports Admin 80/8080/8443 + VHosts UCP isolés 8444/8084) php -r ' require_once "/var/www/nexus-pbx/src/Database.php"; require_once "/var/www/nexus-pbx/src/FirewallManager.php"; -$db = App\Database::getConnection(); -$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); -App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0)); +$st = App\FirewallManager::getSettings(); +App\FirewallManager::ensureUcpFail2banArtifacts(); +App\FirewallManager::applyNginxConfig( + (int)($st["admin_https_port"] ?? 8443), + (int)($st["certbot_strict_port80"] ?? 0), + (int)($st["ucp_enabled"] ?? 1), + (int)($st["ucp_https_port"] ?? 8444), + (int)($st["ucp_http_port"] ?? 8084) +); ' -# Filtre Fail2ban pour l'interface Nexus-Web +# Filtre Fail2ban pour l'interface Nexus-Web et Portail UCP cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf [Definition] failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*.*$ @@ -465,7 +472,12 @@ App\FirewallManager::generateJailLocal( (int)$st["fail2ban_sip_bantime"], (int)$st["fail2ban_web_maxretry"], (int)$st["fail2ban_web_bantime"], - (int)$st["admin_https_port"] + (int)$st["admin_https_port"], + (int)($st["ucp_enabled"] ?? 1), + (int)($st["ucp_https_port"] ?? 8444), + (int)($st["ucp_http_port"] ?? 8084), + (int)($st["fail2ban_ucp_maxretry"] ?? 3), + (int)($st["fail2ban_ucp_bantime"] ?? 3600) ); ' diff --git a/public/api.php b/public/api.php index a8be442..974fe0c 100644 --- a/public/api.php +++ b/public/api.php @@ -13,6 +13,7 @@ require_once __DIR__ . '/../src/FirewallManager.php'; require_once __DIR__ . '/../src/PhonebookManager.php'; require_once __DIR__ . '/../src/CdrManager.php'; require_once __DIR__ . '/../src/ClusterManager.php'; +require_once __DIR__ . '/../src/UcpManager.php'; // Autoloader PSR-4 automatique pour toutes les classes du namespace App spl_autoload_register(function (string $class): void { @@ -36,6 +37,7 @@ use App\FirewallManager; use App\PhonebookManager; use App\CdrManager; use App\ClusterManager; +use App\UcpManager; header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate'); @@ -96,10 +98,7 @@ try { ]); } else { // Journalisation de sécurité pour Fail2ban (jail nexus-web) - $clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; - if (str_contains($clientIp, ',')) { - $clientIp = trim(explode(',', $clientIp)[0]); - } + $clientIp = FirewallManager::resolveClientIp(); $logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n"; @file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX); @@ -134,6 +133,9 @@ try { switch ($action) { case 'get_state': + try { + FirewallManager::ensureUcpDeployed(); + } catch (\Throwable) {} $settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch(); $extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); @@ -210,6 +212,7 @@ try { 'backups_count' => count(BackupManager::listBackups($db)), 'phonebook_contacts' => PhonebookManager::getContacts($db), 'phonebook_groups' => PhonebookManager::getGroups($db), + 'ucp_users' => UcpManager::listUsers($db), 'metrics' => SystemMetrics::collect($db), 'previews' => [ 'pjsip' => $generator->generatePjsipConf(), @@ -273,6 +276,13 @@ try { @chgrp($webLog, 'asterisk'); @chmod($webLog, 0664); } + $ucpLog = FirewallManager::UCP_SECURITY_LOG; + if (file_exists($ucpLog)) { + @file_put_contents($ucpLog, ''); + @chown($ucpLog, 'www-data'); + @chgrp($ucpLog, 'asterisk'); + @chmod($ucpLog, 0664); + } @unlink('/tmp/nexus_fw_status_cache.json'); exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1'); @@ -301,6 +311,7 @@ try { $voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1; $voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0; $ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20))); + $groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0; if ($force_pin_change === 1) { $secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf"; @@ -329,7 +340,7 @@ try { encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled, voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc, voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma, - voicemail_delete = :vmd, ring_time = :rt + voicemail_delete = :vmd, ring_time = :rt, groundwire_enabled = :gwe WHERE id = :id '); $stmt->execute([ @@ -350,14 +361,15 @@ try { ':vma' => $voicemail_attach, ':vmd' => $voicemail_delete, ':rt' => $ring_time, + ':gwe' => $groundwire_enabled, ]); } else { $stmt = $db->prepare(' INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled, voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin, - voicemail_email, voicemail_attach, voicemail_delete, ring_time) + voicemail_email, voicemail_attach, voicemail_delete, ring_time, groundwire_enabled) VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled, - :vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt) + :vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt, :gwe) ON CONFLICT(extension) DO UPDATE SET name = excluded.name, secret = excluded.secret, @@ -373,7 +385,8 @@ try { voicemail_email = excluded.voicemail_email, voicemail_attach = excluded.voicemail_attach, voicemail_delete = excluded.voicemail_delete, - ring_time = excluded.ring_time + ring_time = excluded.ring_time, + groundwire_enabled = excluded.groundwire_enabled '); $stmt->execute([ ':ext' => $extension, @@ -392,6 +405,7 @@ try { ':vma' => $voicemail_attach, ':vmd' => $voicemail_delete, ':rt' => $ring_time, + ':gwe' => $groundwire_enabled, ]); } @@ -1913,6 +1927,11 @@ try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); + // S'assurer que le VHost UCP & Fail2ban UCP sont déployés même après un git pull manuel + try { + FirewallManager::ensureUcpDeployed(); + } catch (\Throwable) {} + // Actualisation de l'horodatage de modification en base pour la réconciliation cluster try { $db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1"); @@ -2446,6 +2465,39 @@ try { echo json_encode($res); break; + case 'get_ucp_users': + try { + FirewallManager::ensureUcpDeployed(); + } catch (\Throwable) {} + $users = UcpManager::listUsers($db); + echo json_encode([ + 'success' => true, + 'users' => $users, + 'ucp_users' => $users, + ]); + break; + + case 'save_ucp_user': + $users = UcpManager::saveUser($db, $payload); + echo json_encode([ + 'success' => true, + 'users' => $users, + 'ucp_users' => $users, + 'message' => 'Compte utilisateur UCP enregistré avec succès.', + ]); + break; + + case 'delete_ucp_user': + $uid = (int)($payload['id'] ?? 0); + $users = UcpManager::deleteUser($db, $uid); + echo json_encode([ + 'success' => true, + 'users' => $users, + 'ucp_users' => $users, + 'message' => 'Compte utilisateur UCP supprimé.', + ]); + break; + case 'get_cdr': $filters = [ 'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100), diff --git a/public/assets/app.js b/public/assets/app.js index 8568ae4..c45f5ed 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -28,6 +28,31 @@ const chartHistory = { let metricsTimer = null; +function adminIcon(name, cls = 'w-3.5 h-3.5') { + const paths = { + shield: '', + lock: '', + unlock: '', + warning: '', + phone: '', + mobile: '', + building: '', + voicemail: '', + music: '', + refresh: '', + trash: '', + tag: '', + ban: '', + speaker: '', + rocket: '', + emergency: '', + clock: '', + globe: '', + dotGreen: '', + }; + return `${paths[name] || paths.tag}`; +} + // --- Notifications Toast Tailwind --- function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); @@ -211,6 +236,7 @@ function renderAll() { renderFeatureCodes(); renderSipSettings(); renderCertificates(); + renderUcpTab(); renderSettingsAndDiagnostics(); } @@ -356,6 +382,8 @@ function renderSummaryCards() { if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length; const pbBadge = document.getElementById('nav-badge-phonebook'); if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length; + const ucpBadge = document.getElementById('nav-badge-ucp'); + if (ucpBadge) ucpBadge.textContent = (appState.ucp_users || []).length; document.getElementById('nav-badge-trunks').textContent = trks.length; const obBadge = document.getElementById('nav-badge-outbound'); if (obBadge) obBadge.textContent = obRoutes.length; @@ -762,11 +790,11 @@ function renderDashboardPhonesTable() { const encMode = Number(ext.encryption ?? (isTls ? 1 : 0)); let srtpBadge = ''; if (encMode === 2) { - srtpBadge = `🛡️ SRTP Forcé`; + srtpBadge = `${adminIcon('shield', 'w-3 h-3')} SRTP Forcé`; } else if (encMode === 1) { - srtpBadge = `🛡️ SRTP Négociable`; + srtpBadge = `${adminIcon('shield', 'w-3 h-3')} SRTP Négociable`; } else { - srtpBadge = `🔈 RTP Clair`; + srtpBadge = `${adminIcon('speaker', 'w-3 h-3')} RTP Clair`; } // Détection dynamique du mode réel négocié en direct par le téléphone @@ -779,21 +807,21 @@ function renderDashboardPhonesTable() { if (actTr === 'tls') { trPill = ` - 🔒 TLS Actif (Chiffré) + ${adminIcon('lock', 'w-3 h-3')} TLS Actif (Chiffré) `; profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'}`; } else if (actTr === 'wss') { trPill = ` - 🔒 WSS WebRTC + ${adminIcon('lock', 'w-3 h-3')} WSS WebRTC `; profileNote = `WebRTC`; } else if (actTr === 'tcp') { trPill = ` - 🔓 TCP Actif (Clair) + ${adminIcon('unlock', 'w-3 h-3')} TCP Actif (Clair) `; profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'}`; @@ -801,10 +829,10 @@ function renderDashboardPhonesTable() { // UDP trPill = ` - 🔓 UDP Actif (Clair) + ${adminIcon('unlock', 'w-3 h-3')} UDP Actif (Clair) `; - profileNote = isTls ? '⚠️ Exige TLS' : 'UDP Interne'; + profileNote = isTls ? `${adminIcon('warning', 'w-3 h-3')} Exige TLS` : 'UDP Interne'; } modeBadge = ` @@ -850,7 +878,7 @@ function renderDashboardPhonesTable() { contactHtml = `
${escapeHtml(cleanIp)}
- ${ua ? `
📱 ${ua}
` : ''} + ${ua ? `
${adminIcon('mobile', 'w-3 h-3 text-indigo-400')} ${ua}
` : ''}
`; } else { @@ -906,10 +934,10 @@ function renderExtensionsTable() { encLabel = 'RTP Clair'; } const badge = isTls - ? `🔒 TLS (${encLabel})` + ? `${adminIcon('lock', 'w-3.5 h-3.5 text-emerald-400')} TLS (${encLabel})` : (encMode > 0 - ? `🛡️ UDP (${encLabel})` - : `🏢 UDP (RTP Clair)`); + ? `${adminIcon('shield', 'w-3.5 h-3.5 text-indigo-400')} UDP (${encLabel})` + : `${adminIcon('building', 'w-3.5 h-3.5 text-slate-400')} UDP (RTP Clair)`); const actTr = c ? (c.active_transport || 'udp').toUpperCase() : ''; const stLower = c ? String(c.status).toLowerCase() : ''; @@ -932,16 +960,20 @@ function renderExtensionsTable() { : `Aucun`; const vmBadge = Number(ext.voicemail_enabled) === 1 - ? `📟 Boîte` + ? `${adminIcon('voicemail', 'w-3 h-3')} Boîte` + : ''; + const gwBadge = Number(ext.groundwire_enabled) === 1 + ? `GroundWire` : ''; return ` ${escapeHtml(ext.extension)} -
+
${escapeHtml(ext.name)} ${vmBadge} + ${gwBadge}
${groupBadges}
@@ -951,10 +983,21 @@ function renderExtensionsTable() { ${escapeHtml(ext.secret)} - - - - + +
+ + + +
`; @@ -1024,6 +1067,9 @@ function openExtensionModal(ext = null) { }); syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0)); + const gwCheckbox = document.getElementById('ext-groundwire-enabled'); + if (gwCheckbox) gwCheckbox.checked = ext ? (Number(ext.groundwire_enabled) === 1) : false; + // Réinitialisation de l'onglet actif vers "Général" switchExtModalTab('general'); @@ -1235,7 +1281,7 @@ function renderGroupsTable() { ? members.map(m => `${escapeHtml(m)}`).join(' ') : 'Aucun membre'; const failoverLabel = String(grp.failover_dest || '').startsWith('vm-') - ? `📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}` + ? `${adminIcon('voicemail', 'w-3.5 h-3.5')} Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}` : `${escapeHtml(grp.failover_dest)}`; return ` @@ -1752,7 +1798,7 @@ function renderTrunksTable() { const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id)); const mohBadge = mohSound - ? `🎵 ${escapeHtml(mohSound.name)}` + ? `${adminIcon('music', 'w-3 h-3')} ${escapeHtml(mohSound.name)}` : `MoH : Globale`; return ` @@ -1782,12 +1828,12 @@ function renderTrunksTable() { ${t.cid_name_prefix ? `CID: ${escapeHtml(t.cid_name_prefix)}` : ''} ${didCount > 0 ? `${didCount} règle(s) entrante(s)` : 'Tous numéros (Any)'} ${blockCount > 0 ? `${blockCount} blocage(s) CID` : ''} - ${tcCount > 0 ? `⏱️ ${tcCount} horaire(s)` : ''} + ${tcCount > 0 ? `${adminIcon('clock', 'w-3 h-3')} ${tcCount} horaire(s)` : ''}
${mohBadge}
- ${wantsReg ? `` : ''} + ${wantsReg ? `` : ''} @@ -1861,7 +1907,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { let html = ''; if (exts.length > 0) { - html += ``; + html += ``; exts.forEach(e => { const val = `ext-${e.extension}`; html += ``; @@ -1870,7 +1916,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { } if (grps.length > 0) { - html += ``; + html += ``; grps.forEach(g => { const val = `grp-${g.extension}`; html += ``; @@ -1879,7 +1925,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { } if (paging.length > 0) { - html += ``; + html += ``; paging.forEach(pg => { const val = `page-${pg.extension}`; html += ``; @@ -1888,7 +1934,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { } if (ivrs.length > 0) { - html += ``; + html += ``; ivrs.forEach(i => { const val = i.name; html += ``; @@ -1899,7 +1945,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1); const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal); if (vmExts.length > 0 || hasMissingVm) { - html += ``; + html += ``; if (hasMissingVm) { const missingExt = normVal.slice(3); html += ``; @@ -1911,35 +1957,35 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { html += ``; } - // Piège à Spammeurs / Musique d'attente en boucle continue (MoH) + // Musique d'attente en boucle continue (MoH) const mohSounds = sounds.filter(s => s.usage_type === 'moh'); - html += ``; - html += ``; + html += ``; + html += ``; mohSounds.forEach(s => { const val = `moh-${s.id}`; - html += ``; + html += ``; }); html += ``; const announcements = sounds.filter(s => s.usage_type !== 'moh'); if (announcements.length > 0) { - html += ``; + html += ``; announcements.forEach(s => { const val = `sound-${s.id}`; html += ``; }); html += ``; - html += ``; + html += ``; announcements.forEach(s => { const val = `sound-loop-${s.id}`; - html += ``; + html += ``; }); html += ``; } html += ` - + @@ -1951,6 +1997,209 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { return html; } +// ----------------------------------------------------------------------------- +// SÉLECTEUR DE DESTINATION CUSTOM AVEC ICÔNES SVG (STYLE MENU LATÉRAL) +// ----------------------------------------------------------------------------- +let activeDestDropdownMenu = null; +let activeDestDropdownSelect = null; + +function getDestinationIconSvg(val, label = '') { + const v = String(val || ''); + const l = String(label || '').toLowerCase(); + const mk = (cls, path) => `${path}`; + + if (v.startsWith('ext-') || l.includes('postes sip')) { + return mk('text-indigo-400', ''); + } + if (v.startsWith('grp-') || l.includes('ring groups')) { + return mk('text-cyan-400', ''); + } + if (v.startsWith('page-') || l.includes('paging') || l.includes('interphonie')) { + return mk('text-purple-400', ''); + } + if (v.startsWith('vm-') || l.includes('messagerie') || l.includes('répondeur')) { + return mk('text-amber-400', ''); + } + if (v.startsWith('moh-') || l.includes('musique')) { + return mk('text-sky-400', ''); + } + if (v.startsWith('sound-loop-') || l.includes('boucle continue')) { + return mk('text-violet-400', ''); + } + if (v.startsWith('sound-') || l.includes('annonces vocales')) { + return mk('text-teal-400', ''); + } + if (v.startsWith('terminate-') || ['hangup', 'busy', 'congestion', 'zapateller', 'ringforever'].includes(v) || l.includes('terminer')) { + return mk('text-rose-400', ''); + } + return mk('text-emerald-400', ''); +} + +function closeDestinationDropdown() { + if (activeDestDropdownMenu) { + activeDestDropdownMenu.remove(); + activeDestDropdownMenu = null; + activeDestDropdownSelect = null; + } +} + +function updateDestinationSelectTrigger(selectEl) { + const wrap = selectEl._nexusDestWrap; + if (!wrap) return; + const btn = wrap.querySelector('.nexus-dest-trigger'); + if (!btn) return; + const opt = selectEl.options[selectEl.selectedIndex] || selectEl.options[0]; + const val = opt ? opt.value : ''; + const txt = opt ? opt.textContent : 'Sélectionner…'; + btn.innerHTML = ` + + ${getDestinationIconSvg(val)} + ${escapeHtml(txt)} + + + + + `; +} + +function openDestinationDropdown(selectEl, triggerBtn) { + if (activeDestDropdownSelect === selectEl) { + closeDestinationDropdown(); + return; + } + closeDestinationDropdown(); + + const menu = document.createElement('div'); + menu.className = 'fixed z-[120] bg-anthracite-900 border border-anthracite-700 rounded-xl shadow-2xl shadow-black/70 overflow-hidden flex flex-col'; + + const listEl = document.createElement('div'); + listEl.className = 'overflow-y-auto p-1.5 space-y-0.5 max-h-72'; + + const curVal = selectEl.value; + const appendOptionBtn = (opt) => { + const isSel = opt.value === curVal; + const item = document.createElement('button'); + item.type = 'button'; + item.className = `w-full flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg text-left text-xs transition ${ + isSel ? 'bg-indigo-600/25 text-white font-semibold' : 'text-slate-200 hover:bg-anthracite-800 hover:text-white' + }`; + item.innerHTML = ` + + ${getDestinationIconSvg(opt.value)} + ${escapeHtml(opt.textContent)} + + ${isSel ? '' : ''} + `; + item.addEventListener('click', (e) => { + e.stopPropagation(); + selectEl.value = opt.value; + updateDestinationSelectTrigger(selectEl); + selectEl.dispatchEvent(new Event('change', { bubbles: true })); + closeDestinationDropdown(); + }); + listEl.appendChild(item); + }; + + Array.from(selectEl.children).forEach(node => { + if (node.tagName === 'OPTGROUP') { + const hdr = document.createElement('div'); + hdr.className = 'flex items-center gap-1.5 px-2 pt-2 pb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 select-none'; + hdr.innerHTML = `${getDestinationIconSvg('', node.label)}${escapeHtml(node.label)}`; + listEl.appendChild(hdr); + Array.from(node.children).forEach(opt => { + if (opt.tagName === 'OPTION') appendOptionBtn(opt); + }); + } else if (node.tagName === 'OPTION') { + appendOptionBtn(node); + } + }); + + menu.appendChild(listEl); + document.body.appendChild(menu); + + const rect = triggerBtn.getBoundingClientRect(); + const width = Math.max(rect.width, 280); + const spaceBelow = window.innerHeight - rect.bottom - 12; + const menuHeight = Math.min(296, menu.scrollHeight || 296); + const openUp = spaceBelow < 180 && rect.top > spaceBelow; + menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`; + menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`; + if (openUp) { + menu.style.bottom = `${window.innerHeight - rect.top + 4}px`; + } else { + menu.style.top = `${rect.bottom + 4}px`; + } + + activeDestDropdownMenu = menu; + activeDestDropdownSelect = selectEl; +} + +function enhanceDestinationSelect(selectEl) { + if (!selectEl || selectEl.tagName !== 'SELECT') return; + if (!selectEl.querySelector('option[value="terminate-hangup"]')) return; + + if (!selectEl._nexusDestWrap) { + selectEl.classList.add('sr-only'); + selectEl.setAttribute('tabindex', '-1'); + + const wrap = document.createElement('div'); + wrap.className = 'relative w-full'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'nexus-dest-trigger w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 hover:border-indigo-500/60 focus:border-indigo-500 focus:outline-none text-xs text-white text-left transition'; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openDestinationDropdown(selectEl, btn); + }); + + selectEl.parentNode.insertBefore(wrap, selectEl); + wrap.appendChild(selectEl); + wrap.appendChild(btn); + selectEl._nexusDestWrap = wrap; + + selectEl.addEventListener('change', () => updateDestinationSelectTrigger(selectEl)); + const optObs = new MutationObserver(() => updateDestinationSelectTrigger(selectEl)); + optObs.observe(selectEl, { childList: true, subtree: true }); + } + updateDestinationSelectTrigger(selectEl); +} + +if (typeof document !== 'undefined' && !window._nexusDestSelectInit) { + window._nexusDestSelectInit = true; + document.addEventListener('click', (e) => { + if (activeDestDropdownMenu && !activeDestDropdownMenu.contains(e.target)) { + closeDestinationDropdown(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeDestinationDropdown(); + }); + window.addEventListener('resize', closeDestinationDropdown); + document.addEventListener('scroll', (e) => { + if (activeDestDropdownMenu && !activeDestDropdownMenu.contains(e.target)) { + closeDestinationDropdown(); + } + }, true); + + const domObs = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.target && m.target.tagName === 'SELECT') { + enhanceDestinationSelect(m.target); + } + m.addedNodes?.forEach(n => { + if (n.nodeType !== 1) return; + if (n.tagName === 'SELECT') enhanceDestinationSelect(n); + else n.querySelectorAll?.('select').forEach(enhanceDestinationSelect); + }); + } + }); + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('select').forEach(enhanceDestinationSelect); + domObs.observe(document.body, { childList: true, subtree: true }); + }); +} + function updateInboundRoutesBadge() { const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length; const badge = document.getElementById('trunk-inbound-rules-badge'); @@ -2006,11 +2255,11 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
- - +
@@ -2054,7 +2303,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
@@ -2083,7 +2332,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des if (isEnabled) { toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm'; const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim')); - badgeContainer.innerHTML = `⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})`; + badgeContainer.innerHTML = `${adminIcon('clock', 'w-3 h-3')} ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})`; } else { toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition'; badgeContainer.innerHTML = ''; @@ -2493,10 +2742,10 @@ function openTrunkModal(trunk = null) { if (mohSelect) { const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0; - let opts = ``; + let opts = ``; mohSounds.forEach(s => { const isSel = (Number(s.id) === curMohId) ? 'selected' : ''; - opts += ``; + opts += ``; }); mohSelect.innerHTML = opts; } @@ -2597,7 +2846,7 @@ function renderOutboundRoutesTable() { ${tIdx + 1}.${escapeHtml(t)} `).join(' → ') - : `⚠️ Aucun Trunk associé`; + : `${adminIcon('warning', 'w-3.5 h-3.5')} Aucun Trunk associé`; const isRestricted = r.permission_mode === 'restricted'; const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : []; @@ -2622,7 +2871,7 @@ function renderOutboundRoutesTable() { const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', '); const rTypeBadge = r.route_type === 'emergency' - ? `🚑 Urgence` + ? `${adminIcon('emergency', 'w-3 h-3')} Urgence` : (r.route_type === 'intracompany' ? `Intra` : `Standard`); @@ -2717,19 +2966,19 @@ function openOutboundRouteModal(route = null) { if (congSel) { const curCong = route ? route.congestion_dest : 'all-circuits-busy'; let congOpts = ` - - + + `; const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh'); if (annSounds.length > 0) { annSounds.forEach(s => { const val = `sound-${s.id}`; - congOpts += ``; + congOpts += ``; }); } congOpts += ` - + @@ -2743,7 +2992,7 @@ function openOutboundRouteModal(route = null) { if (mohSel) { const curMoh = route ? (route.moh_class || 'default') : 'default'; const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); - let mohOpts = ``; + let mohOpts = ``; mohSounds.forEach(s => { const sSlug = slugifyTrunkName(s.site_category || 'global'); const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`; @@ -2868,7 +3117,7 @@ function addOutboundTrunkSeqRow(selectedTrunk = '') { - + `; row.querySelector('.btn-del-trunk-row').addEventListener('click', () => { @@ -2915,7 +3164,7 @@ function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '') class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono">
- +
`; @@ -3098,8 +3347,8 @@ function renderIvrsTable() { -
⏱️ Attente : ${Number(ivr.timeout)}s (${Number(ivr.timeout_retries || 2)} rép)
-
⚠️ Erreur : max ${Number(ivr.invalid_retries || 3)} essais
+
${adminIcon('clock', 'w-3.5 h-3.5 text-amber-400')} Attente : ${Number(ivr.timeout)}s (${Number(ivr.timeout_retries || 2)} rép)
+
${adminIcon('warning', 'w-3.5 h-3.5 text-amber-400')} Erreur : max ${Number(ivr.invalid_retries || 3)} essais
@@ -3173,7 +3422,7 @@ function openIvrModal(ivr = null) { let audioHtml = ''; if (sounds.length > 0) { - audioHtml += ``; + audioHtml += ``; sounds.forEach(s => { const val = `custom/${s.slug}`; const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id); @@ -3183,7 +3432,7 @@ function openIvrModal(ivr = null) { } audioHtml += ` - + @@ -3629,6 +3878,7 @@ const PAGE_TITLES = { system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf', backups: 'Sauvegardes & Restauration Système (Disaster Recovery)', firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)', + ucp: 'Portail Utilisateur (UCP) — Comptes & Droits', cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)' }; @@ -3709,6 +3959,11 @@ function switchTab(tabId) { loadCluster(); } + // Si onglet Portail Utilisateur (UCP), actualiser l'affichage + if (tabId === 'ucp') { + renderUcpTab(); + } + toggleMobileSidebar(false); } @@ -3820,6 +4075,7 @@ document.addEventListener('DOMContentLoaded', () => { payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0; payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0; payload.ring_time = Number(payload.ring_time || 20); + payload.groundwire_enabled = document.getElementById('ext-groundwire-enabled')?.checked ? 1 : 0; try { const res = await apiCall('save_extension', payload); @@ -4346,19 +4602,29 @@ document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('btn-save-firewall-settings'); if (btn) btn.disabled = true; + const rawUcpHttps = f.elements['ucp_https_port']?.value; + const rawUcpHttp = f.elements['ucp_http_port']?.value; const payload = { firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0, admin_https_port: Number(f.elements['admin_https_port']?.value || 8443), certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0, fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3), fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600), - fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim() + fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim(), + ucp_enabled: f.elements['ucp_enabled']?.checked ? 1 : 0, + ucp_https_port: rawUcpHttps !== '' && rawUcpHttps !== undefined ? Number(rawUcpHttps) : 8444, + ucp_http_port: rawUcpHttp !== '' && rawUcpHttp !== undefined ? Number(rawUcpHttp) : 8084, + fail2ban_ucp_maxretry: Number(f.elements['fail2ban_ucp_maxretry']?.value || 3), + fail2ban_ucp_bantime: Number(f.elements['fail2ban_ucp_bantime']?.value || 3600) }; try { const res = await apiCall('save_firewall_settings', payload); showToast(res.message, 'success'); await loadFirewallStatus(); + if (typeof loadState === 'function') { + await loadState(); + } } catch (err) { showToast("Erreur application sécurité : " + err.message, 'error'); } finally { @@ -5379,11 +5645,16 @@ function renderFirewall() { `; } else { tbody.innerHTML = banned.map(b => { - const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; + let jailClass = 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; + if (b.jail === 'asterisk') { + jailClass = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'; + } else if (b.jail === 'nexus-ucp') { + jailClass = 'bg-sky-500/20 text-sky-300 border border-sky-500/30'; + } return ` - ⛔ ${escapeHtml(b.ip)} + ${adminIcon('ban', 'w-3.5 h-3.5')} ${escapeHtml(b.ip)} ${escapeHtml(b.jail_label || b.jail)} @@ -5424,6 +5695,21 @@ function renderFirewall() { if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) { form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || ''; } + if (form.elements['ucp_enabled']) { + form.elements['ucp_enabled'].checked = Number(s.ucp_enabled ?? 1) === 1; + } + if (form.elements['ucp_https_port'] && document.activeElement !== form.elements['ucp_https_port']) { + form.elements['ucp_https_port'].value = s.ucp_https_port !== undefined ? s.ucp_https_port : 8444; + } + if (form.elements['ucp_http_port'] && document.activeElement !== form.elements['ucp_http_port']) { + form.elements['ucp_http_port'].value = s.ucp_http_port !== undefined ? s.ucp_http_port : 8084; + } + if (form.elements['fail2ban_ucp_maxretry']) { + form.elements['fail2ban_ucp_maxretry'].value = s.fail2ban_ucp_maxretry || 3; + } + if (form.elements['fail2ban_ucp_bantime']) { + form.elements['fail2ban_ucp_bantime'].value = s.fail2ban_ucp_bantime || 3600; + } } } @@ -5999,7 +6285,7 @@ async function checkSystemUpdate(notify = false) { if (statusText) { statusText.className = 'text-amber-400 font-bold'; - statusText.innerHTML = `🚀 Nouvelle version disponible : v${escapeHtml(data.latest_version)} !`; + statusText.innerHTML = `${adminIcon('rocket', 'w-3.5 h-3.5 text-indigo-300')} Nouvelle version disponible : v${escapeHtml(data.latest_version)} !`; } if (btnRun) { btnRun.style.display = 'inline-flex'; @@ -6650,13 +6936,13 @@ async function refreshClusterStatus() { if (svcKeepalived) { if (!st.keepalived_installed) { svcKeepalived.className = 'font-mono text-amber-400 font-bold'; - svcKeepalived.textContent = '⚠️ Non installé'; + svcKeepalived.innerHTML = adminIcon('warning', 'w-3 h-3 text-amber-400') + ' Non installé'; } else if (st.keepalived_running) { svcKeepalived.className = 'font-mono text-emerald-400 font-bold'; - svcKeepalived.textContent = '🟢 Actif (En cours)'; + svcKeepalived.innerHTML = 'Actif (En cours)'; } else { svcKeepalived.className = 'font-mono text-slate-400'; - svcKeepalived.textContent = '⚪ Arrêté'; + svcKeepalived.innerHTML = 'Arrêté'; } } @@ -6664,13 +6950,13 @@ async function refreshClusterStatus() { if (svcAsterisk) { if (st.asterisk_healthy) { svcAsterisk.className = 'font-mono text-emerald-400 font-bold'; - svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)'; + svcAsterisk.innerHTML = 'En ligne (Ping Pong OK)'; } else if (st.asterisk_running) { svcAsterisk.className = 'font-mono text-amber-400 font-bold'; - svcAsterisk.textContent = '🟡 En cours (Ping en attente)'; + svcAsterisk.innerHTML = 'En cours (Ping en attente)'; } else { svcAsterisk.className = 'font-mono text-red-400 font-bold'; - svcAsterisk.textContent = '🔴 Arrêté'; + svcAsterisk.innerHTML = 'Arrêté'; } } @@ -6680,10 +6966,10 @@ async function refreshClusterStatus() { svcPeer.textContent = 'Non configuré'; svcPeer.className = 'font-mono text-slate-500'; } else if (st.peer_reachable) { - svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`; + svcPeer.innerHTML = `Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`; svcPeer.className = 'font-mono text-emerald-400 font-bold'; } else { - svcPeer.textContent = '🔴 Injoignable (Timeout)'; + svcPeer.innerHTML = 'Injoignable (Timeout)'; svcPeer.className = 'font-mono text-red-400 font-bold'; } } @@ -6798,4 +7084,214 @@ function copyClusterSecret() { }); } +// ========================================================================= +// GESTION DES COMPTES PORTAIL UTILISATEUR (UCP) +// ========================================================================= +function renderUcpTab() { + const s = appState.settings || {}; + const ucpEnabled = Number(s.ucp_enabled ?? 1) === 1; + const httpsPort = Number(s.ucp_https_port ?? 8444); + const httpPort = Number(s.ucp_http_port ?? 8084); + const host = window.location.hostname || 'localhost'; + + const statusBadge = document.getElementById('ucp-service-status-badge'); + if (statusBadge) { + if (!ucpEnabled || (httpsPort <= 0 && httpPort <= 0)) { + statusBadge.textContent = 'Portail désactivé'; + statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30'; + } else { + statusBadge.textContent = 'En ligne & Isolé'; + statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'; + } + } + + const urlsBox = document.getElementById('ucp-access-urls'); + if (urlsBox) { + if (!ucpEnabled) { + urlsBox.innerHTML = 'Activez l’UCP dans Pare-feu & Sécurité pour exposer le portail.'; + } else { + const links = []; + if (httpsPort > 0) { + const url = `https://${host}:${httpsPort}/`; + links.push(`${adminIcon('lock', 'w-3.5 h-3.5')} ${escapeHtml(url)}`); + } + if (httpPort > 0) { + const url = `http://${host}:${httpPort}/`; + links.push(`${adminIcon('globe', 'w-3.5 h-3.5')} ${escapeHtml(url)} (Reverse Proxy)`); + } + urlsBox.innerHTML = links.length ? links.join('') : 'Aucun port d’écoute configuré.'; + } + } + + renderUcpUsersTable(); +} + +function renderUcpUsersTable() { + const tbody = document.getElementById('ucp-users-table-body'); + if (!tbody) return; + + const users = appState.ucp_users || []; + const extMap = new Map((appState.extensions || []).map(e => [String(e.extension), e])); + + if (users.length === 0) { + tbody.innerHTML = ` + + +
+ Aucun utilisateur UCP créé pour le moment. + Cliquez sur « Nouvel Utilisateur UCP » pour ouvrir un accès employé à sa messagerie vocale, ses renvois et l’annuaire. +
+ + + `; + return; + } + + tbody.innerHTML = users.map(u => { + const exts = Array.isArray(u.extensions) ? u.extensions : []; + const extChips = exts.length + ? exts.map(num => { + const info = extMap.get(String(num)); + const title = info ? `${num} — ${info.caller_id_name || ''}` : String(num); + return `${escapeHtml(String(num))}`; + }).join(' ') + : 'Aucune extension'; + + const isEnabled = Number(u.enabled) === 1; + const statusPill = isEnabled + ? 'Actif' + : 'Désactivé'; + + const lastLogin = u.last_login_at ? escapeHtml(u.last_login_at) : 'Jamais connecté'; + + return ` + + ${escapeHtml(u.username || '')} + +
${escapeHtml(u.display_name || u.username || '')}
+
${u.email ? escapeHtml(u.email) : '—'}
+ + +
${extChips}
+ + ${statusPill} + ${lastLogin} + + + + + + `; + }).join(''); +} + +function openUcpUserModal(userId = 0) { + const modal = document.getElementById('modal-ucp-user'); + if (!modal) return; + + const users = appState.ucp_users || []; + const user = userId ? users.find(u => Number(u.id) === Number(userId)) : null; + + document.getElementById('ucp-user-id').value = user ? Number(user.id) : 0; + document.getElementById('ucp-modal-title').textContent = user ? `Modifier l'utilisateur UCP (${user.username})` : 'Nouvel utilisateur UCP'; + document.getElementById('ucp-user-username').value = user ? (user.username || '') : ''; + document.getElementById('ucp-user-display-name').value = user ? (user.display_name || '') : ''; + document.getElementById('ucp-user-email').value = user ? (user.email || '') : ''; + document.getElementById('ucp-user-password').value = ''; + document.getElementById('ucp-user-password').required = !user; + document.getElementById('ucp-password-hint').textContent = user ? '(laisser vide pour conserver)' : '(min. 6 car., requis)'; + document.getElementById('ucp-user-enabled').checked = user ? Number(user.enabled) === 1 : true; + + const selectedExts = new Set((user?.extensions || []).map(String)); + const container = document.getElementById('ucp-user-extensions-container'); + const exts = [...(appState.extensions || [])].sort((a, b) => Number(a.extension) - Number(b.extension)); + + if (container) { + if (exts.length === 0) { + container.innerHTML = '
Aucune extension SIP configurée sur le PBX.
'; + } else { + container.innerHTML = exts.map(ext => { + const num = String(ext.extension); + const checked = selectedExts.has(num) ? 'checked' : ''; + return ` + + `; + }).join(''); + } + } + + modal.classList.remove('hidden'); + modal.classList.add('flex'); +} + +function closeUcpUserModal() { + const modal = document.getElementById('modal-ucp-user'); + if (!modal) return; + modal.classList.add('hidden'); + modal.classList.remove('flex'); +} + +async function saveUcpUser(event) { + event.preventDefault(); + const btn = document.getElementById('btn-save-ucp-user'); + if (btn) btn.disabled = true; + + const checkedExts = Array.from(document.querySelectorAll('#ucp-user-extensions-container input[name="ucp_ext_choice"]:checked')) + .map(el => el.value); + + const payload = { + id: Number(document.getElementById('ucp-user-id').value || 0), + username: (document.getElementById('ucp-user-username').value || '').trim(), + display_name: (document.getElementById('ucp-user-display-name').value || '').trim(), + email: (document.getElementById('ucp-user-email').value || '').trim(), + password: document.getElementById('ucp-user-password').value || '', + enabled: document.getElementById('ucp-user-enabled').checked ? 1 : 0, + extensions: checkedExts + }; + + try { + const res = await apiCall('save_ucp_user', payload); + appState.ucp_users = res.ucp_users || res.users || []; + renderUcpTab(); + const ucpBadge = document.getElementById('nav-badge-ucp'); + if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length; + closeUcpUserModal(); + showToast(res.message || 'Utilisateur UCP enregistré.', 'success'); + } catch (err) { + showToast(err.message || "Erreur lors de l'enregistrement de l'utilisateur UCP.", 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +async function deleteUcpUser(userId, username) { + if (!confirm(`Supprimer définitivement le compte UCP « ${username} » ?`)) { + return; + } + try { + const res = await apiCall('delete_ucp_user', { id: Number(userId) }); + appState.ucp_users = res.ucp_users || res.users || []; + renderUcpTab(); + const ucpBadge = document.getElementById('nav-badge-ucp'); + if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length; + showToast(res.message || 'Utilisateur UCP supprimé.', 'success'); + } catch (err) { + showToast(err.message || "Erreur lors de la suppression de l'utilisateur UCP.", 'error'); + } +} + + diff --git a/public/index.php b/public/index.php index a214264..b37e137 100644 --- a/public/index.php +++ b/public/index.php @@ -41,7 +41,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') - <?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk + <?= $isDevEnv ? '[DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk @@ -338,6 +338,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') • Annuaire d'Entreprise 0 + @@ -532,7 +537,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') @@ -1378,14 +1383,14 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
-
📢
+
Annonce Générale de Groupe (Paging)

Composez le numéro du groupe configuré (ex: 100). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.

-
📞
+
Interphonie Directe 1-à-1 (*80 + poste)

Composez *80 suivi du numéro de l'extension (ex: *80101) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.

@@ -2199,6 +2204,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
+ +
+
+
+ Portail Utilisateur Isolé (UCP) +

VHost Nginx séparé de l'Admin avec prison Fail2ban [nexus-ucp].

+
+ +
+ +
+
+ + +

Défaut : 8444 (0 = désactivé)

+
+
+ + +

Défaut : 8084 (0 = désactivé)

+
+
+ +
+
+ + +
+
+ + +
+
+
+
@@ -2304,6 +2359,80 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
+ + + + + @@ -2353,8 +2482,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
Ligne (Line)Line 1
Extension (Pickup)**
-
- 💡 Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe ** ! +
+ + Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe ** !
@@ -2430,7 +2560,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
Réseaux Locaux (Local Networks - CIDR) *
@@ -2939,9 +3069,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') + class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"> + class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"> @@ -3030,7 +3160,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
- 💡 Conseil d'Architecture : Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse IP Virtuelle (VIP). + Conseil d'Architecture : Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse IP Virtuelle (VIP).
@@ -3052,11 +3182,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
@@ -3134,6 +3264,20 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')

Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.

+ +
Appartenance aux Groupes d'Extensions (Sites & Droits) @@ -3234,7 +3378,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
- 💡 +
Notification MWI automatique : Le voyant LED ou l'enveloppe clignoteront sur vos téléphones IP (Yealink, Fanvil, Grandstream, Snom...) dès qu'un message vocal est reçu. @@ -3568,11 +3712,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
@@ -3614,11 +3758,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
- +
- +
@@ -3629,7 +3773,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
-
🪄 Masques de numérotation pré-définis (1 clic)
+
Masques de numérotation pré-définis (1 clic)

Ajoutez instantanément les masques standards ou créez vos propres règles (prepend) + préfixe | [match pattern / ID appelant].

@@ -3708,7 +3852,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
-
👥 Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)
+
Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)

Tous les postes rattachés à un groupe d'extensions coché ci-dessous auront le droit d'appeler via cette route sortante.

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + Cochez un ou plusieurs postes +
+
+
+
+ + + +
+ + +
+
+
+ + diff --git a/scripts/nexus-firewall b/scripts/nexus-firewall index 55326d1..b119030 100755 --- a/scripts/nexus-firewall +++ b/scripts/nexus-firewall @@ -19,6 +19,8 @@ switch ($cmd) { echo "• Pare-feu & Filtrage : " . ($settings['firewall_enabled'] ? "\033[32mACTIF\033[0m" : "\033[33mINACTIF\033[0m") . "\n"; echo "• Mode Strict Certbot 80 : " . ($settings['certbot_strict_port80'] ? "OUI (Port 80 ACME seul)" : "NON (Port 80 ouvert)") . "\n"; echo "• Port HTTPS Admin : " . ($settings['admin_https_port'] ?? 8443) . "\n"; + $ucpSt = !empty($settings['ucp_enabled']) ? "\033[32mACTIF\033[0m (HTTPS:" . ($settings['ucp_https_port'] ?? 8444) . " / HTTP:" . ($settings['ucp_http_port'] ?? 8084) . ")" : "\033[33mINACTIF\033[0m"; + echo "• Portail Utilisateur UCP: " . $ucpSt . "\n"; echo "• Total IPs Bannies : " . $st['total_banned'] . "\n"; if (!empty($st['banned_ips'])) { echo "\nListe des IPs bannies :\n"; @@ -72,8 +74,17 @@ switch ($cmd) { echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n"; break; + case 'init-ucp': + case 'sync-ucp': + case 'apply': + $settings = FirewallManager::getSettings(); + $res = FirewallManager::saveSettings($settings); + @unlink('/tmp/nexus_fw_status_cache.json'); + echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . ($res['message'] ?? '') . "\n"; + break; + default: - echo "Usage: nexus-firewall [status|on|off|ban |unban ]\n"; + echo "Usage: nexus-firewall [status|on|off|init-ucp|ban |unban ]\n"; exit(1); } diff --git a/scripts/update.sh b/scripts/update.sh index bbea215..58c8252 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -81,6 +81,9 @@ fi echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}" +# Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp +find /tmp -maxdepth 1 \( -name 'nexus_cluster_extract_*' -o -name 'nexus_cluster_tar_*' -o -name 'nexus_cluster_recv_*' -o -name 'nexus_cluster_sync_*' -o -name 'nexus_cluster_db_*' \) -mmin +10 -exec rm -rf {} + 2>/dev/null || true + # 1. Sauvegarde préventive automatique (Disaster Recovery complète) echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}" php -r ' @@ -151,26 +154,68 @@ done # Mise à jour des privilèges sudoers (www-data) si accessible en écriture if [ -w /etc/sudoers.d ]; then cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true -www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster +www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster EOF_SUDO chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true fi -# 3. Vérification des dépendances Debian +# 3. Vérification des dépendances Debian & extensions PHP echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}" -which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || { - echo -e " -> Installation de paquets complémentaires..." - (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true -} +if ! which sqlite3 >/dev/null 2>&1 || ! which ffmpeg >/dev/null 2>&1 || ! which rsync >/dev/null 2>&1 || ! which sox >/dev/null 2>&1 || ! which sshpass >/dev/null 2>&1 || ! which keepalived >/dev/null 2>&1 || ! which arping >/dev/null 2>&1; then + echo -e " -> Installation de paquets système complémentaires..." + (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends sqlite3 acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true +fi -# 4. Exécution des migrations SQLite et régénération Dialplan -echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}" +PHP_MODS=$(php -m 2>/dev/null || true) +if ! echo "$PHP_MODS" | grep -qi "^sqlite3$" || ! echo "$PHP_MODS" | grep -qi "^curl$" || ! echo "$PHP_MODS" | grep -qi "^xml$" || ! echo "$PHP_MODS" | grep -qi "^mbstring$" || ! echo "$PHP_MODS" | grep -qi "^zip$"; then + echo -e " -> Installation des extensions PHP manquantes..." + (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends php-sqlite3 php-curl php-xml php-mbstring php-zip) 2>/dev/null || true + PHP_FPM_SVC=$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '/php.*-fpm\.service/ {print $1; exit}') + [ -n "$PHP_FPM_SVC" ] && systemctl restart "$PHP_FPM_SVC" >/dev/null 2>&1 || true +fi + +# 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP +echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan/web...${C_RESET}" +if [ -w /var/log ]; then + touch /var/log/nexus_ucp_security.log 2>/dev/null || true + chown www-data:asterisk /var/log/nexus_ucp_security.log 2>/dev/null || true + chmod 0664 /var/log/nexus_ucp_security.log 2>/dev/null || true +fi +[ -d "$APP_DIR/ucp" ] && chmod 0755 "$APP_DIR/ucp" "$APP_DIR/ucp/assets" 2>/dev/null || true php -r ' require_once "/var/www/nexus-pbx/src/Database.php"; require_once "/var/www/nexus-pbx/src/ConfigGenerator.php"; +require_once "/var/www/nexus-pbx/src/FirewallManager.php"; $db = App\Database::getConnection(true); $gen = new App\ConfigGenerator($db); $gen->writeAllConfigs(); +try { + $st = App\FirewallManager::getSettings(); + App\FirewallManager::ensureUcpFail2banArtifacts(); + App\FirewallManager::applyNginxConfig( + (int)($st["admin_https_port"] ?? 8443), + (int)($st["certbot_strict_port80"] ?? 0), + (int)($st["ucp_enabled"] ?? 1), + (int)($st["ucp_https_port"] ?? 8444), + (int)($st["ucp_http_port"] ?? 8084) + ); + App\FirewallManager::generateJailLocal( + (string)($st["fail2ban_whitelist_ips"] ?? ""), + (int)($st["fail2ban_sip_maxretry"] ?? 3), + (int)($st["fail2ban_sip_findtime"] ?? 600), + (int)($st["fail2ban_sip_bantime"] ?? 3600), + (int)($st["fail2ban_web_maxretry"] ?? 3), + (int)($st["fail2ban_web_bantime"] ?? 3600), + (int)($st["admin_https_port"] ?? 8443), + (int)($st["ucp_enabled"] ?? 1), + (int)($st["ucp_https_port"] ?? 8444), + (int)($st["ucp_http_port"] ?? 8084), + (int)($st["fail2ban_ucp_maxretry"] ?? 3), + (int)($st["fail2ban_ucp_bantime"] ?? 3600) + ); +} catch (\Throwable $e) { + fwrite(STDERR, "Warning UCP/Firewall update: " . $e->getMessage() . "\n"); +} ' # 5. Fix permissions & ACLs @@ -200,6 +245,9 @@ chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true +chown root:asterisk /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true +chmod 0664 /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true +setfacl -m u:www-data:rw /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true # 6. Rechargement des services echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}" @@ -211,8 +259,9 @@ asterisk -rx "dialplan reload" >/dev/null 2>&1 || true if [ -t 1 ]; then systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true systemctl reload nginx 2>/dev/null || true + fail2ban-client reload >/dev/null 2>&1 || true else - (nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true" >/dev/null 2>&1 &) + (nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" >/dev/null 2>&1 &) fi NEW_VER="Inconnue" diff --git a/src/ClusterManager.php b/src/ClusterManager.php index b43bb03..0aed668 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -495,78 +495,98 @@ class ClusterManager throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } + $this->cleanupStaleClusterTempFiles(); + $uniq = getmypid() . '_' . bin2hex(random_bytes(4)); + // Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite - $tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.sqlite'; - @unlink($tmpDbDump); + $tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite'; + $tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz'; + $tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3'; + $tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq; + $hasAstdb = false; + try { - $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump)); - } catch (\Throwable $e) { - shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true"); - } - - if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) { - $this->db->exec('PRAGMA wal_checkpoint(FULL);'); - @copy($dbPath, $tmpDbDump); - } - - $dbIntegrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); - if ($dbIntegrity !== 'ok') { @unlink($tmpDbDump); - throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); - } + try { + $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump)); + } catch (\Throwable $e) { + shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true"); + } - $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; - $tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3'; - $hasAstdb = false; + if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) { + $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + @copy($dbPath, $tmpDbDump); + } - // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) - $astdbPath = '/var/lib/asterisk/astdb.sqlite3'; - if (file_exists($astdbPath) && is_readable($astdbPath)) { - @unlink($tmpAstdb); - shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); - if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { - $integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); - if ($integrity === 'ok') { - // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) - shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); - $hasAstdb = true; - } else { - @unlink($tmpAstdb); + $dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump); + if ($dbIntegrity !== 'ok') { + throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); + } + + // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) + $astdbPath = '/var/lib/asterisk/astdb.sqlite3'; + if (file_exists($astdbPath) && is_readable($astdbPath)) { + @unlink($tmpAstdb); + try { + if (class_exists(\SQLite3::class)) { + $srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY); + $dstAst = new \SQLite3($tmpAstdb); + $srcAst->backup($dstAst); + $srcAst->close(); + $dstAst->close(); + } else { + shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); + } + } catch (\Throwable) { + @copy($astdbPath, $tmpAstdb); + } + if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { + $integrity = $this->checkSqliteIntegrity($tmpAstdb); + if ($integrity === 'ok') { + // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) + try { + $astPdo = new PDO('sqlite:' . $tmpAstdb); + $astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'"); + $astPdo = null; + } catch (\Throwable) { + shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); + } + $hasAstdb = true; + } } } - } - // Préparation du dossier temporaire de tarage pour isoler database.sqlite - $tmpDbDir = '/tmp/nexus_cluster_tar_' . time(); - @mkdir($tmpDbDir, 0755, true); - rename($tmpDbDump, $tmpDbDir . '/database.sqlite'); + // Préparation du dossier temporaire de tarage pour isoler database.sqlite + @mkdir($tmpDbDir, 0755, true); + rename($tmpDbDump, $tmpDbDir . '/database.sqlite'); - $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite"; - if (is_dir('/var/lib/asterisk/sounds/custom')) { - $cmd .= " -C /var/lib/asterisk/sounds custom"; - } - if (is_dir('/var/spool/asterisk/voicemail')) { - $cmd .= " -C /var/spool/asterisk voicemail"; - } - if ($hasAstdb) { - $cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; - } - shell_exec($cmd); + $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite"; + if (is_dir('/var/lib/asterisk/sounds/custom')) { + $cmd .= " -C /var/lib/asterisk/sounds custom"; + } + if (is_dir('/var/spool/asterisk/voicemail')) { + $cmd .= " -C /var/spool/asterisk voicemail"; + } + if (is_dir('/var/www/nexus-pbx/public/avatars')) { + $cmd .= " -C /var/www/nexus-pbx/public avatars"; + } + if ($hasAstdb) { + $cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb)); + } + exec($cmd . " 2>&1", $tarOut, $tarCode); - @unlink($tmpDbDir . '/database.sqlite'); - @rmdir($tmpDbDir); - if ($hasAstdb) { + if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) { + throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode})."); + } + + $archiveData = file_get_contents($tmpArchive); + } finally { + @unlink($tmpDbDump); @unlink($tmpAstdb); - } - - if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { @unlink($tmpArchive); - throw new RuntimeException("Échec de génération de l'archive de réplication."); + shell_exec('rm -rf ' . escapeshellarg($tmpDbDir) . ' 2>/dev/null || true'); } - $archiveData = file_get_contents($tmpArchive); - @unlink($tmpArchive); - // Collecte des adresses IP bannies dans Fail2ban (jails actives) $bannedIps = $this->getActiveFail2banBans(); @@ -629,6 +649,9 @@ class ClusterManager $resJson = json_decode((string)$response, true); $detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : ""); $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}"; + if (in_array($httpCode, [400, 401, 403, 422], true) && !empty($resJson['message'])) { + break; + } } } @@ -668,94 +691,113 @@ class ClusterManager throw new RuntimeException("Paquet de réplication vide ou corrompu."); } - $archiveRaw = base64_decode($data['archive_b64']); - $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; - file_put_contents($tmpArchive, $archiveRaw); - - // Extraction sécurisée dans un dossier temporaire pour validation préalable - $tmpExtractDir = '/tmp/nexus_cluster_extract_' . time(); - @mkdir($tmpExtractDir, 0755, true); - shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true"); - - $extractedDb = $tmpExtractDir . '/database.sqlite'; - if (!file_exists($extractedDb) || filesize($extractedDb) === 0) { - @unlink($tmpArchive); - @rmdir($tmpExtractDir); - throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide."); + $this->cleanupStaleClusterTempFiles(); + $uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4)); + $archiveRaw = base64_decode($data['archive_b64'], true); + if ($archiveRaw === false || $archiveRaw === '') { + throw new RuntimeException("Archive de réplication invalide (décodage base64)."); } - // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local - $check = trim(shell_exec("sqlite3 " . escapeshellarg($extractedDb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); - if ($check !== 'ok') { - @unlink($extractedDb); - @rmdir($tmpExtractDir); - @unlink($tmpArchive); - throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application."); - } - - // Sauvegarde d'urgence de la base actuelle avant synchronisation - $dbPath = Database::getDatabasePath(); - if (file_exists($dbPath)) { - @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); - } - - // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master + $tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz'; + $tmpExtractDir = '/tmp/nexus_cluster_extract_' . $uniqRecv; $localClusterSettings = $settings; $localSettingsRow = null; + try { - $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); - } catch (\Throwable $e) {} - - // Restauration officielle en ligne via l'API SQLite .restore - // Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM, - // et n'invalide aucune lecture concurrente dans PHP-FPM. - if (file_exists($dbPath)) { - $restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1"; - $restoreOut = shell_exec($restoreCmd); - if (!empty($restoreOut)) { - error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut)); + $written = @file_put_contents($tmpArchive, $archiveRaw); + if ($written === false || $written !== strlen($archiveRaw)) { + throw new RuntimeException("Espace insuffisant dans /tmp lors de l'écriture de l'archive HA."); } - } else { - @copy($extractedDb, $dbPath); - @chmod($dbPath, 0660); - @chown($dbPath, 'www-data'); - @chgrp($dbPath, 'asterisk'); - } - @unlink($extractedDb); + // Extraction sécurisée dans un dossier temporaire pour validation préalable + @mkdir($tmpExtractDir, 0755, true); + exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>&1", $extOut, $extCode); - // Contrôle d'intégrité immédiat après synchronisation - $postCheck = trim(shell_exec("sqlite3 " . escapeshellarg($dbPath) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); - if ($postCheck !== 'ok') { - if (file_exists($dbPath . '.pre_cluster_sync.bak')) { - shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($dbPath . '.pre_cluster_sync.bak') . "\" 2>/dev/null || true"); + $extractedDb = $tmpExtractDir . '/database.sqlite'; + if (!file_exists($extractedDb) || filesize($extractedDb) === 0) { + $detail = !empty($extOut) ? ' (' . implode(' | ', array_slice($extOut, 0, 2)) . ')' : ''; + throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide{$detail}."); } - throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); - } - // Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) - $extractedCustom = $tmpExtractDir . '/custom'; - if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { - shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); - shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true"); - } + // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local + $check = $this->checkSqliteIntegrity($extractedDb); + if ($check !== 'ok') { + throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application."); + } - // Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) - $extractedVm = $tmpExtractDir . '/voicemail'; - if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { - shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); - shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); - } + // Sauvegarde d'urgence de la base actuelle avant synchronisation + $dbPath = Database::getDatabasePath(); + if (file_exists($dbPath)) { + @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + } - // Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root - $extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3'; - if (file_exists($extractedAstdb)) { - shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); - @unlink($extractedAstdb); - } + // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master + try { + $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + } catch (\Throwable $e) {} - @rmdir($tmpExtractDir); - @unlink($tmpArchive); + // Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore) + if (file_exists($dbPath)) { + $this->restoreSqliteDatabase($extractedDb, $dbPath); + } else { + @copy($extractedDb, $dbPath); + @chmod($dbPath, 0660); + @chown($dbPath, 'www-data'); + @chgrp($dbPath, 'asterisk'); + } + + @unlink($extractedDb); + + // Contrôle d'intégrité immédiat après synchronisation + $postCheck = $this->checkSqliteIntegrity($dbPath); + if ($postCheck !== 'ok') { + if (file_exists($dbPath . '.pre_cluster_sync.bak')) { + $this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath); + } + throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); + } + + // Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) + $extractedCustom = $tmpExtractDir . '/custom'; + if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true"); + } + + // Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) + $extractedVm = $tmpExtractDir . '/voicemail'; + if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); + } + + // Synchronisation miroir des avatars de l'annuaire / UCP + $extractedAvatars = $tmpExtractDir . '/avatars'; + if (is_dir($extractedAvatars)) { + if (!is_dir('/var/www/nexus-pbx/public/avatars')) { + @mkdir('/var/www/nexus-pbx/public/avatars', 0775, true); + } + shell_exec("rsync -a --delete " . escapeshellarg($extractedAvatars . '/') . " /var/www/nexus-pbx/public/avatars/ 2>/dev/null || true"); + shell_exec("chown -R www-data:asterisk /var/www/nexus-pbx/public/avatars 2>/dev/null || true"); + shell_exec("chmod -R 775 /var/www/nexus-pbx/public/avatars 2>/dev/null || true"); + } + + // Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root + $extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync_' . $uniqRecv . '.sqlite3'; + if (!file_exists($extractedAstdb)) { + $candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: []; + if (!empty($candidates)) { + $extractedAstdb = $candidates[0]; + } + } + if (file_exists($extractedAstdb)) { + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); + @unlink($extractedAstdb); + } + } finally { + shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); + @unlink($tmpArchive); + } // Réouverture de la connexion DB fraîche $newDb = Database::getConnection(true); @@ -809,6 +851,17 @@ class ClusterManager // Régénérer les configurations PJSIP et Asterisk ConfigGenerator::generateAndApply($newDb); + // Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués) + try { + if (function_exists('posix_geteuid') && posix_geteuid() !== 0) { + @exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1'); + } else { + FirewallManager::saveSettings(FirewallManager::getSettings()); + } + } catch (\Throwable $e) { + // Non-bloquant lors de la synchronisation HA + } + // Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM try { $ami = AmiClient::fromDatabase($newDb); @@ -1067,6 +1120,79 @@ class ClusterManager ]; } + private function cleanupStaleClusterTempFiles(): void + { + $cutoff = time() - 600; + $patterns = [ + '/tmp/nexus_cluster_extract_*', + '/tmp/nexus_cluster_tar_*', + '/tmp/nexus_cluster_recv_*', + '/tmp/nexus_cluster_sync_*', + '/tmp/nexus_cluster_db_*', + '/tmp/astdb_cluster_sync*', + ]; + foreach ($patterns as $pattern) { + foreach (glob($pattern) ?: [] as $path) { + $mtime = @filemtime($path); + if ($mtime !== false && $mtime < $cutoff) { + if (is_dir($path)) { + shell_exec('rm -rf ' . escapeshellarg($path) . ' 2>/dev/null || true'); + } else { + @unlink($path); + } + } + } + } + } + + private function checkSqliteIntegrity(string $filePath): string + { + if (!file_exists($filePath) || filesize($filePath) === 0) { + return 'fichier manquant ou vide'; + } + + try { + $pdo = new PDO('sqlite:' . $filePath); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $res = trim((string)$pdo->query('PRAGMA integrity_check;')->fetchColumn()); + $pdo = null; + if ($res !== '') { + return $res; + } + } catch (\Throwable $e) { + $cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1")); + return $cliOut !== '' ? $cliOut : $e->getMessage(); + } + + $cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1")); + return $cliOut !== '' ? $cliOut : 'réponse integrity_check vide'; + } + + private function restoreSqliteDatabase(string $sourceFile, string $targetFile): void + { + if (class_exists(\SQLite3::class)) { + try { + $src = new \SQLite3($sourceFile, SQLITE3_OPEN_READONLY); + $dst = new \SQLite3($targetFile); + $dst->busyTimeout(5000); + $ok = $src->backup($dst); + $src->close(); + $dst->close(); + if ($ok) { + return; + } + } catch (\Throwable $e) { + error_log('[CLUSTER] Avertissement SQLite3::backup : ' . $e->getMessage()); + } + } + + $restoreCmd = 'sqlite3 ' . escapeshellarg($targetFile) . ' ".restore ' . escapeshellarg($sourceFile) . '" 2>&1'; + $restoreOut = shell_exec($restoreCmd); + if (!empty($restoreOut)) { + error_log('[CLUSTER] Avertissement sqlite3 .restore : ' . trim((string)$restoreOut)); + } + } + private function updateSyncStatus(string $status, string $message): void { $stmt = $this->db->prepare(' diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index eae062a..b00df39 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -987,9 +987,9 @@ class ConfigGenerator $lines[] = " same => n,Hangup()"; } $lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})"; - $lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CF/{$num}):3},1:from-internal,\${DB(CF/{$num})},1)"; $lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})"; - $lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)"; + $lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CFU/{$num}):3},1:from-internal,\${DB(CFU/{$num})},1)"; $lines[] = ""; } diff --git a/src/Database.php b/src/Database.php index 0d8f0b7..50f581b 100644 --- a/src/Database.php +++ b/src/Database.php @@ -48,7 +48,9 @@ class Database // Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP // ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises. try { - self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1"); + self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret FROM settings LIMIT 1"); + self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1"); + self::$instance->query("SELECT id FROM ucp_users LIMIT 1"); } catch (\Throwable $e) { self::initializeSchema(); } @@ -116,6 +118,7 @@ class Database encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)), codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus', max_contacts INTEGER NOT NULL DEFAULT 2, + groundwire_enabled INTEGER NOT NULL DEFAULT 0 CHECK (groundwire_enabled IN (0, 1)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -338,6 +341,21 @@ class Database created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + -- 15. Table ucp_users : Utilisateurs du portail UCP (isolé de l'Admin) + CREATE TABLE IF NOT EXISTS ucp_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(128) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + extensions_json TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + last_login_at DATETIME DEFAULT NULL, + last_login_ip VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); SQL; $db->exec($schema); @@ -418,12 +436,25 @@ class Database 'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3", 'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600", 'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'", + 'ucp_enabled' => "INTEGER NOT NULL DEFAULT 1", + 'ucp_https_port' => "INTEGER NOT NULL DEFAULT 8444", + 'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084", + 'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3", + 'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600", + 'groundwire_secret' => "VARCHAR(128) NOT NULL DEFAULT ''", ]; foreach ($extraSettings as $colName => $colDef) { if (!in_array($colName, $existing, true)) { $db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}"); } } + + // Clé HMAC persistante en base (partagée entre nœuds HA) pour les liens XML GroundWire + $gwSecret = (string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: ''); + if ($gwSecret === '') { + $newSecret = bin2hex(random_bytes(32)); + $db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $newSecret]); + } } private static function migrateExtensionsTable(PDO $db): void @@ -466,6 +497,7 @@ class Database 'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1", 'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0", 'ring_time' => "INTEGER NOT NULL DEFAULT 20", + 'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0", ]; foreach ($extraExtensionCols as $colName => $colDef) { if (!in_array($colName, $existing, true)) { diff --git a/src/FirewallManager.php b/src/FirewallManager.php index 55c2de2..d52e00e 100644 --- a/src/FirewallManager.php +++ b/src/FirewallManager.php @@ -13,6 +13,9 @@ class FirewallManager public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local'; public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx'; public const WEB_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-web.conf'; + public const UCP_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-ucp.conf'; + public const UCP_SECURITY_LOG = '/var/log/nexus_ucp_security.log'; + public const UCP_ROOT_DIR = '/var/www/nexus-pbx/ucp'; /** * Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite. @@ -23,7 +26,9 @@ class FirewallManager $stmt = $db->query("SELECT firewall_enabled, admin_https_port, certbot_strict_port80, fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime, - fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips + fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips, + ucp_enabled, ucp_https_port, ucp_http_port, + fail2ban_ucp_maxretry, fail2ban_ucp_bantime FROM settings WHERE id = 1"); $row = $stmt->fetch(PDO::FETCH_ASSOC); @@ -38,6 +43,11 @@ class FirewallManager 'fail2ban_web_maxretry' => 3, 'fail2ban_web_bantime' => 3600, 'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60', + 'ucp_enabled' => 1, + 'ucp_https_port' => 8444, + 'ucp_http_port' => 8084, + 'fail2ban_ucp_maxretry' => 3, + 'fail2ban_ucp_bantime' => 3600, ]; } @@ -51,6 +61,11 @@ class FirewallManager 'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3), 'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600), 'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'), + 'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1), + 'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444), + 'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084), + 'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3), + 'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600), ]; } @@ -111,11 +126,8 @@ class FirewallManager } } - // IP actuelle du client - $currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; - if (str_contains($currentIp, ',')) { - $currentIp = trim(explode(',', $currentIp)[0]); - } + // IP actuelle du client (avec résolution reverse proxy si proxy privé/local) + $currentIp = self::resolveClientIp(); $status = [ 'is_running' => $isRunning, @@ -151,7 +163,7 @@ class FirewallManager } } else { // Débannir de toutes les prisons connues - foreach (['asterisk', 'nexus-web', 'sshd'] as $j) { + foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) { $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1"; exec($cmd, $out, $code); if ($code === 0) { @@ -200,6 +212,7 @@ class FirewallManager { $db = Database::getConnection(); + $oldSettings = self::getSettings(); $strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0; $firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1; $adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443))); @@ -209,6 +222,26 @@ class FirewallManager $webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3))); $webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600))); + $ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled']; + $ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port']; + $ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port']; + $ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry']; + $ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime']; + + if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) { + throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0."); + } + $reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061]; + if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) { + throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME)."); + } + if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) { + throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME)."); + } + if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) { + throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents."); + } + $whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? '')); $cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist))); @@ -233,22 +266,33 @@ class FirewallManager fail2ban_web_maxretry = :wmr, fail2ban_web_bantime = :wbt, fail2ban_whitelist_ips = :wl, + ucp_enabled = :ue, + ucp_https_port = :uhs, + ucp_http_port = :uhp, + fail2ban_ucp_maxretry = :umr, + fail2ban_ucp_bantime = :ubt, updated_at = CURRENT_TIMESTAMP WHERE id = 1"); $stmt->execute([ - ':fe' => $firewallEnabled, - ':ap' => $adminPort, + ':fe' => $firewallEnabled, + ':ap' => $adminPort, ':sp80' => $strictPort80, - ':smr' => $sipMaxRetry, - ':sft' => $sipFindTime, - ':sbt' => $sipBanTime, - ':wmr' => $webMaxRetry, - ':wbt' => $webBanTime, - ':wl' => $finalWhitelist, + ':smr' => $sipMaxRetry, + ':sft' => $sipFindTime, + ':sbt' => $sipBanTime, + ':wmr' => $webMaxRetry, + ':wbt' => $webBanTime, + ':wl' => $finalWhitelist, + ':ue' => $ucpEnabled, + ':uhs' => $ucpHttpsPort, + ':uhp' => $ucpHttpPort, + ':umr' => $ucpMaxRetry, + ':ubt' => $ucpBanTime, ]); - // 2. Génération et application du fichier /etc/fail2ban/jail.local + // 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local + self::ensureUcpFail2banArtifacts(); self::generateJailLocal( $finalWhitelist, $sipMaxRetry, @@ -256,7 +300,12 @@ class FirewallManager $sipBanTime, $webMaxRetry, $webBanTime, - $adminPort + $adminPort, + $ucpEnabled, + $ucpHttpsPort, + $ucpHttpPort, + $ucpMaxRetry, + $ucpBanTime ); // 3. Rechargement / Redémarrage propre de Fail2ban @@ -265,14 +314,19 @@ class FirewallManager exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1'); } - // 4. Mise à jour de la configuration Nginx - self::applyNginxConfig($adminPort, $strictPort80); + // 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP + self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort); + self::syncUcpFirewallPorts($oldSettings, [ + 'ucp_enabled' => $ucpEnabled, + 'ucp_https_port' => $ucpHttpsPort, + 'ucp_http_port' => $ucpHttpPort, + ]); @unlink('/tmp/nexus_fw_status_cache.json'); return [ 'success' => true, - 'message' => 'Configuration Pare-feu & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.', + 'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.', 'settings' => self::getSettings(), ]; } @@ -287,9 +341,30 @@ class FirewallManager int $sipBanTime, int $webMaxRetry, int $webBanTime, - int $adminPort + int $adminPort, + ?int $ucpEnabled = null, + ?int $ucpHttpsPort = null, + ?int $ucpHttpPort = null, + ?int $ucpMaxRetry = null, + ?int $ucpBanTime = null ): void { + if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) { + $st = self::getSettings(); + $ucpEnabled = $ucpEnabled ?? $st['ucp_enabled']; + $ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port']; + $ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port']; + $ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry']; + $ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime']; + } + + self::ensureUcpFail2banArtifacts(); + $portsWeb = "http,https,{$adminPort}"; + $ucpPortsList = []; + if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort; + if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort; + $ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false'; + $portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444'; $conf = << 0) { + $blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock); + } + if ($ucpHttpPort > 0) { + $blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock); + } + } + + $fullNginxConfig = implode("\n\n", $blocks) . "\n"; file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig); @chgrp(self::NGINX_CONF_PATH, 'asterisk'); @chmod(self::NGINX_CONF_PATH, 0664); @@ -500,6 +609,203 @@ EOF; exec('sudo -n /usr/bin/systemctl reload nginx 2>&1'); } + private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string + { + $ucpRoot = self::UCP_ROOT_DIR; + $listenDirectives = $ssl + ? "listen {$port} ssl;\n listen [::]:{$port} ssl;" + : "listen {$port};\n listen [::]:{$port};"; + + $sslDirectives = $ssl ? <</dev/null 2>&1'); + return; + } + } + if (!file_exists(self::UCP_SECURITY_LOG)) { + if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) { + @exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1'); + return; + } + } + @chown(self::UCP_SECURITY_LOG, 'www-data'); + @chgrp(self::UCP_SECURITY_LOG, 'asterisk'); + @chmod(self::UCP_SECURITY_LOG, 0664); + } + + /** + * Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull). + */ + public static function ensureUcpDeployed(): void + { + $st = self::getSettings(); + if (empty($st['ucp_enabled'])) { + return; + } + $httpsPort = (int)($st['ucp_https_port'] ?? 8444); + $httpPort = (int)($st['ucp_http_port'] ?? 8084); + if ($httpsPort <= 0 && $httpPort <= 0) { + return; + } + + $nginxFile = '/etc/nginx/sites-available/nexus-pbx'; + $nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : ''; + $hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR); + $hasFilter = file_exists(self::UCP_FILTER_PATH); + $hasLog = file_exists(self::UCP_SECURITY_LOG); + + if ($hasNginxUcp && $hasFilter && $hasLog) { + return; + } + + if (function_exists('posix_geteuid') && posix_geteuid() !== 0) { + exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code); + if ($code === 0) { + return; + } + } + + self::saveSettings($st); + } + + /** + * Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe. + */ + private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void + { + $oldPorts = []; + if (!empty($oldSettings['ucp_enabled'])) { + if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port']; + if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port']; + } + $newPorts = []; + if (!empty($newSettings['ucp_enabled'])) { + if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port']; + if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port']; + } + + $toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts)); + $toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts)); + + // UFW si installé et actif + $ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null')); + if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) { + foreach ($toRemove as $p) { + @shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1'); + } + foreach ($newPorts as $p) { + @shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1'); + } + } + + // iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT) + $iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1")); + if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) { + foreach ($toRemove as $p) { + @shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1"); + } + foreach ($toAdd as $p) { + @shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1"); + } + } + } + + /** + * Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance. + */ + public static function resolveClientIp(): string + { + $remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1')); + if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) { + return '127.0.0.1'; + } + + // Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback + $isTrustedProxy = ( + filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false + ); + + if ($isTrustedProxy) { + $candidates = []; + if (!empty($_SERVER['HTTP_X_REAL_IP'])) { + $candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']); + } + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) { + $candidates[] = trim($part); + } + } + foreach ($candidates as $cand) { + if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) { + return $cand; + } + } + } + + return $remote; + } + private static function getJailDetails(string $jail): array { exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out); @@ -526,7 +832,12 @@ EOF; $totalFailed = max($totalFailed, $logIncidents); } elseif ($jail === 'nexus-web') { $logIncidents = is_readable('/var/log/nexus_security.log') - ? (int)shell_exec("grep -c 'FailedLogin' /var/log/nexus_security.log 2>/dev/null") + ? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null") + : 0; + $totalFailed = max($totalFailed, $logIncidents); + } elseif ($jail === 'nexus-ucp') { + $logIncidents = is_readable(self::UCP_SECURITY_LOG) + ? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null") : 0; $totalFailed = max($totalFailed, $logIncidents); } @@ -558,6 +869,7 @@ EOF; return match ($jail) { 'asterisk' => 'Téléphonie SIP (PJSIP)', 'nexus-web' => 'Interface Web Admin', + 'nexus-ucp' => 'Portail Utilisateur (UCP)', 'sshd' => 'Accès Console SSH', default => ucfirst($jail), }; diff --git a/src/PhonebookManager.php b/src/PhonebookManager.php index a84ecb3..05a32e8 100644 --- a/src/PhonebookManager.php +++ b/src/PhonebookManager.php @@ -48,14 +48,22 @@ class PhonebookManager CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name); "); - // 1. Récupère la cartographie des groupes d'extensions - $egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll(); - $extToGroup = []; + // 1. Récupère la cartographie complète des groupes d'extensions (un poste peut appartenir à plusieurs groupes) + $egRows = $db->query("SELECT id, name, members FROM extension_groups ORDER BY name COLLATE NOCASE ASC")->fetchAll(); + $extToGroups = []; foreach ($egRows as $eg) { + $grpName = trim((string)($eg['name'] ?? '')); + if ($grpName === '') { + continue; + } $members = json_decode((string)$eg['members'], true) ?: []; foreach ($members as $extNum) { - if (!isset($extToGroup[(string)$extNum])) { - $extToGroup[(string)$extNum] = trim((string)$eg['name']); + $k = (string)$extNum; + if (!isset($extToGroups[$k])) { + $extToGroups[$k] = []; + } + if (!in_array($grpName, $extToGroups[$k], true)) { + $extToGroups[$k][] = $grpName; } } } @@ -80,7 +88,7 @@ class PhonebookManager $extNum = trim((string)$ext['extension']); $extName = trim((string)$ext['name']); $email = trim((string)($ext['voicemail_email'] ?? '')); - $group = $extToGroup[$extNum] ?? 'Général'; + $group = !empty($extToGroups[$extNum]) ? implode(', ', $extToGroups[$extNum]) : 'Général'; $activeExtIds[] = $extId; $findStmt->execute([':ext_id' => $extId, ':num' => $extNum]); @@ -122,33 +130,48 @@ class PhonebookManager { self::syncExtensions($db); - $params = []; - $sql = "SELECT * FROM phonebook_contacts"; + $stmt = $db->query("SELECT * FROM phonebook_contacts ORDER BY display_name COLLATE NOCASE ASC"); + $contacts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') { - $cleanGroup = trim($group); - $sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')"; - $params[':grp'] = $cleanGroup; - $params[':grp_like'] = "%{$cleanGroup}%"; - } + $cleanGroup = ($group !== null) ? trim($group) : ''; + $filterByGroup = ($cleanGroup !== '' && !in_array(mb_strtolower($cleanGroup), ['all', 'tous'], true)); + $targetLower = mb_strtolower($cleanGroup); - $sql .= " ORDER BY display_name COLLATE NOCASE ASC"; - $stmt = $db->prepare($sql); - $stmt->execute($params); - $contacts = $stmt->fetchAll(PDO::FETCH_ASSOC); - - // Vérification de la présence réelle du fichier sur le disque - foreach ($contacts as &$c) { + $result = []; + foreach ($contacts as $c) { if (!empty($c['photo_url'])) { $filename = basename((string)$c['photo_url']); if (!file_exists(self::AVATARS_DIR . '/' . $filename)) { $c['photo_url'] = ''; } } - } - unset($c); - return $contacts; + $rawGroups = preg_split('/\s*,\s*/', trim((string)($c['group_name'] ?? ''))) ?: []; + $groupsList = array_values(array_unique(array_filter($rawGroups, fn($g) => $g !== ''))); + if (empty($groupsList)) { + $groupsList = ['Général']; + } + $c['groups'] = $groupsList; + $c['group_name'] = implode(', ', $groupsList); + + if ($filterByGroup) { + $matched = false; + foreach ($groupsList as $g) { + $gLower = mb_strtolower($g); + if ($gLower === $targetLower || in_array($gLower, ['tous', 'all'], true)) { + $matched = true; + break; + } + } + if (!$matched) { + continue; + } + } + + $result[] = $c; + } + + return $result; } /** @@ -163,9 +186,11 @@ class PhonebookManager if ($g !== '') $groups[$g] = true; } $pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN); - foreach ($pbRows as $g) { - $g = trim((string)$g); - if ($g !== '') $groups[$g] = true; + foreach ($pbRows as $raw) { + foreach (preg_split('/\s*,\s*/', trim((string)$raw)) ?: [] as $g) { + $g = trim((string)$g); + if ($g !== '') $groups[$g] = true; + } } // Ajoute Issy-Les-Moulineaux si pas encore présent diff --git a/src/UcpAuth.php b/src/UcpAuth.php new file mode 100644 index 0000000..f2e959f --- /dev/null +++ b/src/UcpAuth.php @@ -0,0 +1,197 @@ + true, + 'cookie_samesite' => 'Lax', + 'cookie_path' => '/', + ]); + } + if (empty($_SESSION['ucp_csrf'])) { + $_SESSION['ucp_csrf'] = bin2hex(random_bytes(24)); + } + } + + public static function getCsrfToken(): string + { + self::startSession(); + return (string)$_SESSION['ucp_csrf']; + } + + public static function verifyCsrfToken(string $token): bool + { + self::startSession(); + $expected = (string)($_SESSION['ucp_csrf'] ?? ''); + return $expected !== '' && hash_equals($expected, $token); + } + + public static function isUcpEnabled(PDO $db): bool + { + try { + $val = $db->query('SELECT ucp_enabled FROM settings WHERE id = 1')->fetchColumn(); + return $val === false ? true : ((int)$val === 1); + } catch (\Throwable) { + return true; + } + } + + public static function getCurrentUser(PDO $db): ?array + { + self::startSession(); + if (empty($_SESSION['ucp_user_id']) || !self::isUcpEnabled($db)) { + return null; + } + + $stmt = $db->prepare(' + SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip + FROM ucp_users + WHERE id = :id + LIMIT 1 + '); + $stmt->execute([':id' => (int)$_SESSION['ucp_user_id']]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$user || (int)$user['enabled'] !== 1) { + self::logout(); + return null; + } + + return $user; + } + + public static function isAuthenticated(PDO $db): bool + { + return self::getCurrentUser($db) !== null; + } + + public static function login(PDO $db, string $username, string $password): bool + { + self::startSession(); + if (!self::isUcpEnabled($db)) { + return false; + } + + $cleanUser = trim($username); + $stmt = $db->prepare(' + SELECT id, username, display_name, password_hash, enabled + FROM ucp_users + WHERE username = :u + LIMIT 1 + '); + $stmt->execute([':u' => $cleanUser]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($user && (int)$user['enabled'] === 1 && password_verify($password, (string)$user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['ucp_user_id'] = (int)$user['id']; + $_SESSION['ucp_username'] = (string)$user['username']; + if (empty($_SESSION['ucp_csrf'])) { + $_SESSION['ucp_csrf'] = bin2hex(random_bytes(24)); + } + + $ip = FirewallManager::resolveClientIp(); + $db->prepare('UPDATE ucp_users SET last_login_at = CURRENT_TIMESTAMP, last_login_ip = :ip WHERE id = :id') + ->execute([':ip' => $ip, ':id' => (int)$user['id']]); + return true; + } + + self::logAuthFailure($cleanUser); + return false; + } + + public static function logAuthFailure(string $username): void + { + $clientIp = FirewallManager::resolveClientIp(); + $safeUser = preg_replace('/[^a-zA-Z0-9._@\-]/', '', $username) ?: 'vide'; + $logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-UCP-AUTH-FAIL]: {$clientIp} (username: {$safeUser})\n"; + @file_put_contents(FirewallManager::UCP_SECURITY_LOG, $logMsg, FILE_APPEND | LOCK_EX); + @chmod(FirewallManager::UCP_SECURITY_LOG, 0664); + } + + public static function logout(): void + { + self::startSession(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + } + session_destroy(); + } + + /** + * Retourne la liste fraîche des extensions autorisées et actives pour un utilisateur UCP. + */ + public static function getAllowedExtensions(PDO $db, int $ucpUserId): array + { + $stmt = $db->prepare('SELECT extensions_json, enabled FROM ucp_users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $ucpUserId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row || (int)$row['enabled'] !== 1) { + return []; + } + + $linked = json_decode((string)($row['extensions_json'] ?? '[]'), true); + if (!is_array($linked) || empty($linked)) { + return []; + } + $linkedSet = array_values(array_unique(array_map('strval', $linked))); + + $extRows = $db->query(' + SELECT id, extension, name, transport, encryption, groundwire_enabled, voicemail_enabled, voicemail_email, voicemail_pin, ring_time + FROM extensions + WHERE enabled = 1 + ORDER BY extension ASC + ')->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $allowed = []; + foreach ($extRows as $ext) { + if (in_array((string)$ext['extension'], $linkedSet, true)) { + $allowed[] = $ext; + } + } + return $allowed; + } + + public static function updateOwnPassword(PDO $db, int $ucpUserId, string $currentPassword, string $newPassword): void + { + if (strlen($newPassword) < 6) { + throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères."); + } + + $stmt = $db->prepare('SELECT password_hash FROM ucp_users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $ucpUserId]); + $hash = $stmt->fetchColumn(); + if (!$hash || !password_verify($currentPassword, (string)$hash)) { + throw new InvalidArgumentException("Le mot de passe actuel est incorrect."); + } + + $db->prepare('UPDATE ucp_users SET password_hash = :p, updated_at = CURRENT_TIMESTAMP WHERE id = :id') + ->execute([ + ':p' => password_hash($newPassword, PASSWORD_DEFAULT), + ':id' => $ucpUserId, + ]); + } +} + diff --git a/src/UcpManager.php b/src/UcpManager.php new file mode 100644 index 0000000..23321ee --- /dev/null +++ b/src/UcpManager.php @@ -0,0 +1,1024 @@ +query(' + SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip, created_at, updated_at + FROM ucp_users + ORDER BY username ASC + ')->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $extRows = $db->query('SELECT extension, name, enabled FROM extensions ORDER BY extension ASC')->fetchAll(PDO::FETCH_ASSOC) ?: []; + $extMap = []; + foreach ($extRows as $e) { + $extMap[(string)$e['extension']] = [ + 'extension' => (string)$e['extension'], + 'name' => (string)$e['name'], + 'enabled' => (int)$e['enabled'], + ]; + } + + foreach ($users as &$u) { + $exts = json_decode((string)($u['extensions_json'] ?? '[]'), true); + $u['extensions'] = is_array($exts) ? array_values(array_map('strval', $exts)) : []; + $u['extensions_meta'] = array_values(array_filter(array_map( + fn(string $num) => $extMap[$num] ?? ['extension' => $num, 'name' => 'Poste inconnu', 'enabled' => 0], + $u['extensions'] + ))); + $u['enabled'] = (int)$u['enabled']; + unset($u['extensions_json']); + } + unset($u); + + return $users; + } + + public static function saveUser(PDO $db, array $data): array + { + $id = isset($data['id']) && (int)$data['id'] > 0 ? (int)$data['id'] : null; + $username = trim((string)($data['username'] ?? '')); + $displayName = trim((string)($data['display_name'] ?? '')); + $email = trim((string)($data['email'] ?? '')); + $password = (string)($data['password'] ?? ''); + $enabled = isset($data['enabled']) ? (!empty($data['enabled']) ? 1 : 0) : 1; + $rawExts = is_array($data['extensions'] ?? null) ? $data['extensions'] : []; + + if (!preg_match('/^[a-zA-Z0-9._@\-]{2,64}$/', $username)) { + throw new InvalidArgumentException("L'identifiant UCP doit contenir 2 à 64 caractères (lettres, chiffres, . _ @ -)."); + } + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("Adresse e-mail invalide."); + } + if (!$id && strlen($password) < 6) { + throw new InvalidArgumentException("Le mot de passe initial doit contenir au moins 6 caractères."); + } + if ($id && $password !== '' && strlen($password) < 6) { + throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères."); + } + + // Ne conserver que des extensions valides existantes + $validExts = $db->query('SELECT extension FROM extensions')->fetchAll(PDO::FETCH_COLUMN) ?: []; + $validSet = array_map('strval', $validExts); + $cleanExts = []; + foreach ($rawExts as $ext) { + $extStr = trim((string)$ext); + if ($extStr !== '' && in_array($extStr, $validSet, true) && !in_array($extStr, $cleanExts, true)) { + $cleanExts[] = $extStr; + } + } + if (empty($cleanExts)) { + throw new InvalidArgumentException("Veuillez lier au moins un poste SIP à cet utilisateur UCP."); + } + + $extJson = json_encode($cleanExts); + + // Vérification d'unicité du username + $dupStmt = $db->prepare('SELECT id FROM ucp_users WHERE username = :u' . ($id ? ' AND id != :id' : '')); + $dupParams = [':u' => $username]; + if ($id) { + $dupParams[':id'] = $id; + } + $dupStmt->execute($dupParams); + if ($dupStmt->fetchColumn()) { + throw new InvalidArgumentException("Un compte UCP avec l'identifiant « {$username} » existe déjà."); + } + + if ($id) { + if ($password !== '') { + $stmt = $db->prepare(' + UPDATE ucp_users + SET username = :u, display_name = :dn, email = :em, password_hash = :ph, + extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP + WHERE id = :id + '); + $stmt->execute([ + ':id' => $id, + ':u' => $username, + ':dn' => $displayName ?: $username, + ':em' => $email, + ':ph' => password_hash($password, PASSWORD_DEFAULT), + ':exts' => $extJson, + ':en' => $enabled, + ]); + } else { + $stmt = $db->prepare(' + UPDATE ucp_users + SET username = :u, display_name = :dn, email = :em, + extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP + WHERE id = :id + '); + $stmt->execute([ + ':id' => $id, + ':u' => $username, + ':dn' => $displayName ?: $username, + ':em' => $email, + ':exts' => $extJson, + ':en' => $enabled, + ]); + } + } else { + $stmt = $db->prepare(' + INSERT INTO ucp_users (username, display_name, email, password_hash, extensions_json, enabled) + VALUES (:u, :dn, :em, :ph, :exts, :en) + '); + $stmt->execute([ + ':u' => $username, + ':dn' => $displayName ?: $username, + ':em' => $email, + ':ph' => password_hash($password, PASSWORD_DEFAULT), + ':exts' => $extJson, + ':en' => $enabled, + ]); + } + + return self::listUsers($db); + } + + public static function deleteUser(PDO $db, int $id): array + { + $db->prepare('DELETE FROM ucp_users WHERE id = :id')->execute([':id' => $id]); + return self::listUsers($db); + } + + // ========================================================================= + // 2. ÉTAT TEMPS RÉEL DES POSTES & RENVOIS (DND / CF / CFU) + // ========================================================================= + + /** + * Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées. + */ + public static function getExtensionsState(PDO $db, array $allowedExts, ?int $ucpUserId = null): array + { + $extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts); + if (empty($extNums)) { + return []; + } + + $contactsByExt = []; + $dndMap = []; + $cfMap = []; + $cfuMap = []; + + try { + $ami = AmiClient::fromDatabase($db); + + // Contacts PJSIP & métadonnées AstDB + $rawContacts = $ami->runCommand('pjsip show contacts'); + $rawRegMeta = $ami->runCommand('database show registrar/contact'); + $contactMeta = []; + foreach (preg_split('/\r?\n/', $rawRegMeta) ?: [] as $dbLine) { + if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) { + $parsed = json_decode($dbM[2], true); + if ($parsed) { + $contactMeta[$dbM[1]] = $parsed; + } + } + } + + foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) { + if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) { + $extNum = $m[1]; + if (!in_array($extNum, $extNums, true)) { + continue; + } + $status = $m[4]; + $rttRaw = $m[5] ?? null; + $isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false; + $meta = $contactMeta[$extNum] ?? []; + $fullUri = $meta['uri'] ?? ('sip:' . $m[2]); + + $ip = ''; + if (preg_match('/@([^:;>]+)/', $fullUri, $ipM)) { + $ip = $ipM[1]; + } + + $contactsByExt[$extNum] = [ + 'online' => $isAvail, + 'status' => $status, + 'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null, + 'ip' => $ip, + 'user_agent' => (string)($meta['user_agent'] ?? ''), + ]; + } + } + + // États DND / CF / CFU depuis AstDB + $dndMap = self::parseAstDbPrefix($ami->runCommand('database show DND'), '/DND/'); + $cfMap = self::parseAstDbPrefix($ami->runCommand('database show CF'), '/CF/'); + $cfuMap = self::parseAstDbPrefix($ami->runCommand('database show CFU'), '/CFU/'); + } catch (\Throwable) { + // Asterisk indisponible : on renvoie les métadonnées locales sans bloquer l'interface + } + + // Photo & email annuaire liés + $pbByExtId = []; + try { + $pbRows = $db->query('SELECT extension_id, email, photo_url FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL') + ->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($pbRows as $p) { + $pbByExtId[(int)$p['extension_id']] = $p; + } + } catch (\Throwable) {} + + $result = []; + foreach ($allowedExts as $ext) { + $num = (string)$ext['extension']; + $extId = (int)$ext['id']; + $cInfo = $contactsByExt[$num] ?? ['online' => false, 'status' => 'Unavailable', 'rtt_ms' => null, 'ip' => '', 'user_agent' => '']; + $vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0]; + $pb = $pbByExtId[$extId] ?? []; + + $gwEnabled = !empty($ext['groundwire_enabled']) ? 1 : 0; + $gwXmlUrl = ''; + $gwDeepLink = ''; + if ($gwEnabled === 1 && $ucpUserId !== null && $ucpUserId > 0) { + $gwXmlUrl = self::buildGroundwireXmlUrl($db, $extId, $ucpUserId); + if ($gwXmlUrl !== '') { + $gwDeepLink = 'provlinkbs:' . preg_replace('#^https?://#i', '', $gwXmlUrl); + } + } + + $result[] = [ + 'id' => $extId, + 'extension' => $num, + 'name' => (string)$ext['name'], + 'transport' => (string)$ext['transport'], + 'online' => $cInfo['online'], + 'status_label' => $cInfo['status'], + 'rtt_ms' => $cInfo['rtt_ms'], + 'ip' => $cInfo['ip'], + 'user_agent' => $cInfo['user_agent'], + 'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES', + 'cf' => (string)($cfMap[$num] ?? ''), + 'cfu' => (string)($cfuMap[$num] ?? ''), + 'voicemail_enabled' => (int)$ext['voicemail_enabled'], + 'voicemail_email' => (string)($ext['voicemail_email'] ?? ''), + 'vm_inbox_count' => $vmCounts['inbox'], + 'vm_old_count' => $vmCounts['old'], + 'profile_email' => (string)($pb['email'] ?? ''), + 'photo_url' => (string)($pb['photo_url'] ?? ''), + 'groundwire_enabled' => $gwEnabled, + 'groundwire_xml_url' => $gwXmlUrl, + 'groundwire_deeplink_url' => $gwDeepLink, + ]; + } + + return $result; + } + + /** + * Retourne la liste des cibles de renvoi autorisées (postes internes + répondeurs vm-). + */ + public static function getForwardTargets(PDO $db): array + { + $rows = $db->query('SELECT extension, name, voicemail_enabled FROM extensions WHERE enabled = 1 ORDER BY extension ASC') + ->fetchAll(PDO::FETCH_ASSOC) ?: []; + $targets = []; + foreach ($rows as $r) { + $targets[] = [ + 'extension' => (string)$r['extension'], + 'name' => (string)$r['name'], + 'voicemail_enabled' => (int)$r['voicemail_enabled'], + ]; + } + return $targets; + } + + /** + * Applique DND, CF et CFU sur une extension autorisée via AMI + mise à jour BLF *76. + */ + public static function setCallFeatures(PDO $db, array $allowedExts, string $ext, array $features): void + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + + $dnd = !empty($features['dnd']); + $cf = trim((string)($features['cf'] ?? '')); + $cfu = trim((string)($features['cfu'] ?? '')); + + self::validateForwardTarget($db, $ext, $cf, 'Renvoi inconditionnel (CF)'); + self::validateForwardTarget($db, $ext, $cfu, 'Renvoi sur non-réponse (CFU)'); + + $ami = AmiClient::fromDatabase($db); + + // DND + mise à jour état BLF *76 en direct + if ($dnd) { + $ami->runCommand("database put DND {$ext} YES"); + $ami->runCommand("devstate change Custom:DND{$ext} BUSY"); + } else { + $ami->runCommand("database del DND {$ext}"); + $ami->runCommand("devstate change Custom:DND{$ext} NOT_INUSE"); + } + + // CF (Renvoi inconditionnel) + if ($cf !== '') { + $ami->runCommand("database put CF {$ext} {$cf}"); + } else { + $ami->runCommand("database del CF {$ext}"); + } + + // CFU (Renvoi non-réponse / indisponible) + if ($cfu !== '') { + $ami->runCommand("database put CFU {$ext} {$cfu}"); + } else { + $ami->runCommand("database del CFU {$ext}"); + } + } + + private static function validateForwardTarget(PDO $db, string $sourceExt, string $target, string $label): void + { + if ($target === '') { + return; + } + + // Cible répondeur direct vm- + if (str_starts_with($target, 'vm-')) { + $vmExt = substr($target, 3); + if (!preg_match('/^\d{2,6}$/', $vmExt)) { + throw new InvalidArgumentException("{$label} : boîte vocale cible invalide."); + } + $stmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1'); + $stmt->execute([':ext' => $vmExt]); + if (!(int)$stmt->fetchColumn()) { + throw new InvalidArgumentException("{$label} : le répondeur du poste {$vmExt} n'est pas activé."); + } + return; + } + + // Cible poste interne uniquement (sécurité anti-fraude UCP) + if (!preg_match('/^\d{2,6}$/', $target)) { + throw new InvalidArgumentException("{$label} : seuls les postes internes ou répondeurs sont autorisés."); + } + if ($target === $sourceExt) { + throw new InvalidArgumentException("{$label} : impossible de renvoyer un poste vers lui-même."); + } + $stmt = $db->prepare('SELECT id FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1'); + $stmt->execute([':ext' => $target]); + if (!$stmt->fetchColumn()) { + throw new InvalidArgumentException("{$label} : le poste interne {$target} n'existe pas ou est désactivé."); + } + } + + private static function parseAstDbPrefix(string $rawOutput, string $prefix): array + { + $map = []; + foreach (preg_split('/\r?\n/', $rawOutput) ?: [] as $line) { + $pos = strpos($line, $prefix); + if ($pos === 0 && str_contains($line, ':')) { + $rest = substr($line, strlen($prefix)); + [$k, $v] = explode(':', $rest, 2); + $k = trim($k); + $v = trim($v); + if ($k !== '' && !str_contains($k, ' ')) { + $map[$k] = $v; + } + } + } + return $map; + } + + // ========================================================================= + // 3. MESSAGERIE VOCALE (LECTURE, TÉLÉCHARGEMENT, ARCHIVAGE, SUPPRESSION, PIN) + // ========================================================================= + + public static function countVoicemails(string $ext): array + { + return [ + 'inbox' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/INBOX/msg*.txt") ?: []), + 'old' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/Old/msg*.txt") ?: []), + ]; + } + + public static function getVoicemails(array $allowedExts, string $ext): array + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $messages = []; + + foreach (['INBOX', 'Old'] as $folder) { + $dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}"; + if (!is_dir($dir)) { + continue; + } + $txtFiles = glob($dir . '/msg[0-9][0-9][0-9][0-9].txt') ?: []; + foreach ($txtFiles as $txtFile) { + $msgId = basename($txtFile, '.txt'); + if (!preg_match('/^msg\d{4}$/', $msgId)) { + continue; + } + $meta = self::parseVoicemailTxt($txtFile); + $audioPath = self::findVoicemailAudioFile($dir, $msgId); + if (!$audioPath) { + continue; + } + + $origTime = (int)($meta['origtime'] ?? @filemtime($audioPath)); + $messages[] = [ + 'id' => $msgId, + 'extension' => $ext, + 'folder' => $folder, + 'is_new' => ($folder === 'INBOX'), + 'callerid' => self::formatVmCallerId((string)($meta['callerid'] ?? 'Inconnu')), + 'origtime' => $origTime, + 'date_iso' => $origTime > 0 ? date('c', $origTime) : '', + 'date_human' => $origTime > 0 ? date('d/m/Y H:i', $origTime) : '—', + 'duration' => (int)($meta['duration'] ?? 0), + 'size_bytes' => (int)@filesize($audioPath), + 'format' => strtolower(pathinfo($audioPath, PATHINFO_EXTENSION)), + ]; + } + } + + usort($messages, fn($a, $b) => $b['origtime'] <=> $a['origtime']); + return $messages; + } + + public static function getVoicemailAudioPath(array $allowedExts, string $ext, string $folder, string $msgId): string + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $folder = self::assertValidVmFolder($folder); + $msgId = self::assertValidMsgId($msgId); + + $dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}"; + $path = self::findVoicemailAudioFile($dir, $msgId); + if (!$path || !is_readable($path)) { + throw new RuntimeException("Message vocal introuvable sur le disque."); + } + return $path; + } + + public static function moveVoicemailFolder(PDO $db, array $allowedExts, string $ext, string $fromFolder, string $msgId): void + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $fromFolder = self::assertValidVmFolder($fromFolder); + $msgId = self::assertValidMsgId($msgId); + $toFolder = ($fromFolder === 'INBOX') ? 'Old' : 'INBOX'; + + $srcDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$fromFolder}"; + $dstDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$toFolder}"; + + if (!is_dir($dstDir)) { + @mkdir($dstDir, 0775, true); + @chown($dstDir, 'asterisk'); + @chgrp($dstDir, 'asterisk'); + } + + $srcFiles = glob("{$srcDir}/{$msgId}.*") ?: []; + if (empty($srcFiles)) { + throw new RuntimeException("Message vocal {$msgId} introuvable dans {$fromFolder}."); + } + + // Trouver le prochain index msgNNNN libre dans le dossier cible + $nextIdx = 0; + while ( + !empty(glob(sprintf("{$dstDir}/msg%04d.*", $nextIdx))) + && $nextIdx < 9999 + ) { + $nextIdx++; + } + $newMsgId = sprintf('msg%04d', $nextIdx); + + foreach ($srcFiles as $srcFile) { + $fileExt = pathinfo($srcFile, PATHINFO_EXTENSION); + $dstFile = "{$dstDir}/{$newMsgId}.{$fileExt}"; + if (!@rename($srcFile, $dstFile)) { + if (@copy($srcFile, $dstFile)) { + @unlink($srcFile); + } else { + throw new RuntimeException("Impossible de déplacer le fichier vocal ({$fileExt})."); + } + } + @chown($dstFile, 'asterisk'); + @chgrp($dstFile, 'asterisk'); + @chmod($dstFile, 0664); + } + + self::reloadVoicemailQuiet($db); + } + + public static function deleteVoicemail(PDO $db, array $allowedExts, string $ext, string $folder, string $msgId): void + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $folder = self::assertValidVmFolder($folder); + $msgId = self::assertValidMsgId($msgId); + + $dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}"; + $files = glob("{$dir}/{$msgId}.*") ?: []; + if (empty($files)) { + throw new RuntimeException("Message vocal {$msgId} introuvable."); + } + foreach ($files as $f) { + @unlink($f); + } + + self::reloadVoicemailQuiet($db); + } + + public static function updateVoicemailPrefs(PDO $db, array $allowedExts, string $ext, string $newPin, string $vmEmail): void + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $cleanPin = trim($newPin); + $cleanEmail = trim($vmEmail); + + if ($cleanPin !== '' && !preg_match('/^\d{4,12}$/', $cleanPin)) { + throw new InvalidArgumentException("Le code PIN messagerie doit contenir entre 4 et 12 chiffres."); + } + if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("Adresse e-mail de notification invalide."); + } + + if ($cleanPin !== '') { + $db->prepare('UPDATE extensions SET voicemail_pin = :pin, voicemail_email = :em, force_pin_change = 0 WHERE extension = :ext') + ->execute([':pin' => $cleanPin, ':em' => $cleanEmail, ':ext' => $ext]); + + $secFile = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/secret.conf"; + if (is_dir(dirname($secFile))) { + @file_put_contents($secFile, "[general]\npassword={$cleanPin}\n"); + @chown($secFile, 'asterisk'); + @chgrp($secFile, 'asterisk'); + @chmod($secFile, 0664); + } + } else { + $db->prepare('UPDATE extensions SET voicemail_email = :em WHERE extension = :ext') + ->execute([':em' => $cleanEmail, ':ext' => $ext]); + } + + // Régénération ciblée de voicemail.custom.conf + rechargement à chaud + $gen = new ConfigGenerator($db); + $vmContent = $gen->generateVoicemailConf(); + file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent); + @chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk'); + @chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664); + + self::reloadVoicemailQuiet($db); + } + + private static function reloadVoicemailQuiet(PDO $db): void + { + try { + AmiClient::fromDatabase($db)->runCommand('voicemail reload'); + } catch (\Throwable) {} + } + + private static function parseVoicemailTxt(string $path): array + { + $data = []; + $lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + foreach ($lines as $line) { + if (str_starts_with($line, ';') || str_starts_with($line, '[')) { + continue; + } + if (str_contains($line, '=')) { + [$k, $v] = explode('=', $line, 2); + $data[trim(strtolower($k))] = trim($v); + } + } + return $data; + } + + private static function findVoicemailAudioFile(string $dir, string $msgId): ?string + { + foreach (['wav', 'WAV', 'gsm'] as $audioExt) { + $candidate = "{$dir}/{$msgId}.{$audioExt}"; + if (is_file($candidate)) { + return $candidate; + } + } + return null; + } + + private static function formatVmCallerId(string $raw): string + { + $clean = trim($raw, " \t\n\r\0\x0B\""); + if ($clean === '' || $clean === '<>') { + return 'Appelant inconnu'; + } + return $clean; + } + + // ========================================================================= + // 4. JOURNAL D'APPELS PERSONNEL & ANNUAIRE / PROFIL + // ========================================================================= + + public static function getPersonalCdr(PDO $db, array $allowedExts, array $filters = []): array + { + $extSet = array_map(fn($e) => (string)$e['extension'], $allowedExts); + if (empty($extSet)) { + return ['calls' => [], 'stats' => ['total' => 0, 'answered' => 0, 'missed' => 0]]; + } + + $rawDisp = strtoupper(trim((string)($filters['disposition'] ?? 'all'))); + $cdrDisp = match ($rawDisp) { + 'ANSWERED' => 'answered', + default => 'all', + }; + + $res = CdrManager::getCalls($db, [ + 'limit' => 500, + 'direction' => (string)($filters['direction'] ?? 'all'), + 'disposition' => $cdrDisp, + 'period' => (string)($filters['period'] ?? 'all'), + 'search' => (string)($filters['search'] ?? ''), + ]); + + $filtered = []; + $answered = 0; + $missed = 0; + + $extractExt = static function (string $chan): string { + if (preg_match('/^(?:PJSIP|SIP)\/([^-\/@]+)/i', trim($chan), $m)) { + return (string)$m[1]; + } + return ''; + }; + + foreach ($res['calls'] as $call) { + $callerNum = (string)($call['caller_number'] ?? $call['src'] ?? ''); + $calleeNum = (string)($call['callee_number'] ?? $call['dst'] ?? ''); + $srcChanEp = $extractExt((string)($call['channel'] ?? '')); + $dstChanEp = $extractExt((string)($call['dstchannel'] ?? '')); + + $isMine = in_array($callerNum, $extSet, true) + || in_array($calleeNum, $extSet, true) + || ($srcChanEp !== '' && in_array($srcChanEp, $extSet, true)) + || ($dstChanEp !== '' && in_array($dstChanEp, $extSet, true)); + + if (!$isMine) { + continue; + } + + $disp = strtoupper((string)($call['disposition'] ?? 'NO ANSWER')); + if ($rawDisp === 'MISSED' && $disp === 'ANSWERED') { + continue; + } + + if ($disp === 'ANSWERED') { + $answered++; + } else { + $missed++; + } + + $filtered[] = $call; + if (count($filtered) >= 120) { + break; + } + } + + return [ + 'calls' => $filtered, + 'stats' => [ + 'total' => count($filtered), + 'answered' => $answered, + 'missed' => $missed, + ], + ]; + } + + public static function getPhonebookForUser(PDO $db, array $allowedExts): array + { + $allowedExtIds = array_map(fn($e) => (int)$e['id'], $allowedExts); + $allowedMap = []; + foreach ($allowedExts as $e) { + $allowedMap[(int)$e['id']] = (string)$e['extension']; + } + + $contacts = PhonebookManager::getContacts($db); + foreach ($contacts as &$c) { + $eid = isset($c['extension_id']) ? (int)$c['extension_id'] : 0; + $c['is_mine'] = ($eid > 0 && in_array($eid, $allowedExtIds, true)); + $c['mine_extension'] = $c['is_mine'] ? $allowedMap[$eid] : null; + } + unset($c); + + return $contacts; + } + + public static function updateMyContactEmail(PDO $db, array $allowedExts, string $ext, string $email): void + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + $cleanEmail = trim($email); + if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("Adresse e-mail invalide."); + } + + PhonebookManager::syncExtensions($db); + $extId = self::getExtensionId($allowedExts, $ext); + + // Maintenir phonebook_contacts et extensions.voicemail_email alignés pour éviter un écrasement lors des syncExtensions() + $db->prepare('UPDATE extensions SET voicemail_email = :em WHERE id = :eid') + ->execute([':em' => $cleanEmail, ':eid' => $extId]); + $db->prepare('UPDATE phonebook_contacts SET email = :em, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1') + ->execute([':em' => $cleanEmail, ':eid' => $extId]); + + $gen = new ConfigGenerator($db); + $vmContent = $gen->generateVoicemailConf(); + file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent); + @chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk'); + @chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664); + self::reloadVoicemailQuiet($db); + } + + public static function uploadMyAvatar(PDO $db, array $allowedExts, string $ext, array $file): string + { + $ext = self::assertAllowedExtension($allowedExts, $ext); + + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new RuntimeException("Erreur lors de l'envoi de la photo."); + } + if (($file['size'] ?? 0) > 5 * 1024 * 1024) { + throw new InvalidArgumentException("La photo ne doit pas dépasser 5 Mo."); + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file((string)$file['tmp_name']); + $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp']; + if (!isset($allowed[$mime])) { + throw new InvalidArgumentException("Format non supporté. Utilisez JPG, PNG ou WebP."); + } + + PhonebookManager::initDirectory(); + PhonebookManager::syncExtensions($db); + + // Nettoyer l'ancien avatar UCP de ce poste + foreach (glob(PhonebookManager::AVATARS_DIR . "/avatar_ucp_{$ext}_*") ?: [] as $oldFile) { + @unlink($oldFile); + } + + $fileExt = $allowed[$mime]; + $filename = "avatar_ucp_{$ext}_" . time() . ".{$fileExt}"; + $target = PhonebookManager::AVATARS_DIR . '/' . $filename; + + if (!move_uploaded_file((string)$file['tmp_name'], $target)) { + throw new RuntimeException("Impossible d'enregistrer la photo sur le serveur."); + } + @chmod($target, 0664); + + $avatarUrl = "/avatars/{$filename}"; + $extId = self::getExtensionId($allowedExts, $ext); + + $db->prepare('UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1') + ->execute([':url' => $avatarUrl, ':eid' => $extId]); + + return $avatarUrl; + } + + public static function resolveAvatarPath(string $filename): ?string + { + $base = basename($filename); + if (!preg_match('/^[a-zA-Z0-9._\-]+\.(jpg|jpeg|png|webp)$/i', $base)) { + return null; + } + $full = PhonebookManager::AVATARS_DIR . '/' . $base; + return is_file($full) && is_readable($full) ? $full : null; + } + + /** + * Retourne les codes de fonctions téléphoniques actifs pour la notice UCP. + */ + public static function getFeatureCodesForUser(PDO $db): array + { + try { + $stmt = $db->query(' + SELECT feature_key, code, default_code, name, category, description + FROM feature_codes + WHERE enabled = 1 + ORDER BY id ASC + '); + return $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (\Throwable) { + return []; + } + } + + // ========================================================================= + // 7. PROVISIONNEMENT XML ACROBITS GROUNDWIRE + // ========================================================================= + + /** + * Récupère ou initialise la clé secrète HMAC utilisée pour signer les URL XML GroundWire sans session. + */ + public static function getGroundwireSecret(PDO $db): string + { + $secret = trim((string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '')); + if ($secret === '') { + $secret = bin2hex(random_bytes(32)); + $db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $secret]); + } + return $secret; + } + + public static function buildGroundwireToken(PDO $db, int $extId, int $ucpUserId): string + { + return hash_hmac('sha256', "{$extId}-{$ucpUserId}", self::getGroundwireSecret($db)); + } + + public static function verifyGroundwireToken(PDO $db, int $extId, int $ucpUserId, string $token): bool + { + if ($extId <= 0 || $ucpUserId <= 0 || !preg_match('/^[a-f0-9]{64}$/i', $token)) { + return false; + } + return hash_equals(self::buildGroundwireToken($db, $extId, $ucpUserId), strtolower($token)); + } + + public static function buildGroundwireXmlUrl(PDO $db, int $extId, int $ucpUserId): string + { + $rawHost = (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''); + $host = trim(explode(',', $rawHost)[0] ?? ''); + if ($host === '') { + return ''; + } + + $fwdProto = strtolower(trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')); + $isHttps = ($fwdProto === 'https') + || (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443); + $scheme = $isHttps ? 'https' : 'http'; + + $scriptDir = rtrim(str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/api.php'))), '/'); + $path = $scriptDir . '/download_groundwire.php'; + $token = self::buildGroundwireToken($db, $extId, $ucpUserId); + + return "{$scheme}://{$host}{$path}?ext_id={$extId}&uid={$ucpUserId}&token={$token}"; + } + + /** + * Génère à la volée le XML pour Acrobits GroundWire à partir des paramètres de l'extension. + * + * @return array{filename: string, xml: string} + */ + public static function buildGroundwireXml(PDO $db, int $extId): array + { + $stmt = $db->prepare(' + SELECT id, extension, name, secret, transport, encryption, enabled, groundwire_enabled + FROM extensions + WHERE id = :id + LIMIT 1 + '); + $stmt->execute([':id' => $extId]); + $extRow = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$extRow || (int)$extRow['enabled'] !== 1 || (int)$extRow['groundwire_enabled'] !== 1) { + throw new InvalidArgumentException("Provisionnement GroundWire non activé pour ce poste."); + } + + $settings = $db->query(' + SELECT domain, external_ip, letsencrypt_domain, udp_port, tls_port, external_udp_port, external_tls_port + FROM settings + WHERE id = 1 + ')->fetch(PDO::FETCH_ASSOC) ?: []; + + $haVip = ''; + try { + $cl = $db->query('SELECT enabled, virtual_ip FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: []; + if (!empty($cl['enabled']) && !empty($cl['virtual_ip'])) { + $haVip = trim(explode('/', (string)$cl['virtual_ip'])[0] ?? ''); + } + } catch (\Throwable) {} + + $isTls = (($extRow['transport'] ?? 'udp') === 'tls'); + $transport = $isTls ? 'tls' : 'udp'; + $localPort = $isTls ? ((int)($settings['tls_port'] ?? 5061) ?: 5061) : ((int)($settings['udp_port'] ?? 5060) ?: 5060); + + $natHost = trim((string)($settings['external_ip'] ?? '')); + $natHost = preg_replace('#^(?:https?|sips?)://#i', '', $natHost); + $natHost = trim((string)$natHost, "/ \t\n\r\0\x0B"); + + if ($natHost !== '') { + // Mode NAT renseigné : adresse NAT + port externe NAT (ou port interne par défaut) + $sipHost = $natHost; + $sipPort = $isTls + ? (((int)($settings['external_tls_port'] ?? 0) > 0) ? (int)$settings['external_tls_port'] : $localPort) + : (((int)($settings['external_udp_port'] ?? 0) > 0) ? (int)$settings['external_udp_port'] : $localPort); + $hostPort = str_contains($sipHost, ':') ? $sipHost : "{$sipHost}:{$sipPort}"; + } else { + // Mode local : VIP HA si cluster actif, sinon IP locale du PBX + port d'écoute local + $sipHost = ''; + if ($haVip !== '') { + $sipHost = $haVip; + } + if ($sipHost === '') { + $rawHttpHost = trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''))[0] ?? ''); + if ($rawHttpHost !== '') { + $parsedHost = parse_url("http://{$rawHttpHost}", PHP_URL_HOST); + if (is_string($parsedHost) && $parsedHost !== '' && !in_array($parsedHost, ['127.0.0.1', '::1', 'localhost'], true)) { + $sipHost = $parsedHost; + } + } + } + if ($sipHost === '') { + $serverAddr = trim((string)($_SERVER['SERVER_ADDR'] ?? '')); + if ($serverAddr !== '' && !in_array($serverAddr, ['127.0.0.1', '::1'], true)) { + $sipHost = $serverAddr; + } + } + if ($sipHost === '') { + $ips = preg_split('/\s+/', trim((string)@shell_exec('hostname -I 2>/dev/null'))) ?: []; + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && !str_starts_with($ip, '127.')) { + $sipHost = $ip; + break; + } + } + } + if ($sipHost === '') { + $sipHost = trim((string)($settings['domain'] ?? '')) ?: '127.0.0.1'; + } + + $hostPort = "{$sipHost}:{$localPort}"; + } + + $encMode = (int)($extRow['encryption'] ?? 0); + $sdes = ($isTls || $encMode > 0) ? 'enabled' : 'disabled'; + + $vmCode = '*97'; + try { + $fc = $db->query("SELECT code FROM feature_codes WHERE feature_key = 'voicemail_own' AND enabled = 1 LIMIT 1")->fetchColumn(); + if (is_string($fc) && trim($fc) !== '') { + $vmCode = trim($fc); + } + } catch (\Throwable) {} + + $esc = static fn(string $v): string => htmlspecialchars($v, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + + $extNum = (string)$extRow['extension']; + $lineName = trim((string)($extRow['name'] ?? '')) ?: $extNum; + $password = (string)($extRow['secret'] ?? ''); + + $xml = "\n" + . "\n" + . " " . $esc($lineName) . "\n" + . " " . $esc($extNum) . "\n" + . " " . $esc($password) . "\n" + . " " . $esc($hostPort) . "\n" + . " " . $esc($transport) . "\n" + . " " . $esc($sdes) . "\n" + . " " . $esc($sdes) . "\n" + . " 1\n" + . " " . $esc($vmCode) . "\n" + . " 1\n" + . " off\n" + . " auto\n" + . "\n"; + + $safeExt = preg_replace('/[^a-zA-Z0-9_-]/', '', $extNum) ?: (string)$extId; + + return [ + 'filename' => "groundwire-{$safeExt}.xml", + 'xml' => $xml, + ]; + } + + // ========================================================================= + // HELPERS VALIDATION + // ========================================================================= + + private static function assertAllowedExtension(array $allowedExts, string $ext): string + { + $clean = trim($ext); + foreach ($allowedExts as $e) { + if ((string)$e['extension'] === $clean) { + return $clean; + } + } + throw new InvalidArgumentException("Accès refusé à ce poste SIP ({$clean})."); + } + + private static function getExtensionId(array $allowedExts, string $ext): int + { + foreach ($allowedExts as $e) { + if ((string)$e['extension'] === $ext) { + return (int)$e['id']; + } + } + throw new InvalidArgumentException("Poste introuvable."); + } + + private static function assertValidVmFolder(string $folder): string + { + if (!in_array($folder, ['INBOX', 'Old'], true)) { + throw new InvalidArgumentException("Dossier messagerie invalide."); + } + return $folder; + } + + private static function assertValidMsgId(string $msgId): string + { + if (!preg_match('/^msg\d{4}$/', $msgId)) { + throw new InvalidArgumentException("Identifiant de message vocal invalide."); + } + return $msgId; + } +} diff --git a/ucp/api.php b/ucp/api.php new file mode 100644 index 0000000..a99be63 --- /dev/null +++ b/ucp/api.php @@ -0,0 +1,344 @@ + false, 'message' => 'Le portail utilisateur UCP est actuellement désactivé par l’administrateur.']); + exit; + } + + // Lecture du payload JSON si applicable + $payload = $_POST; + $contentType = (string)($_SERVER['CONTENT_TYPE'] ?? ''); + if (str_contains($contentType, 'application/json')) { + $raw = file_get_contents('php://input'); + $decoded = json_decode((string)$raw, true); + if (is_array($decoded)) { + $payload = $decoded; + } + } + + // ------------------------------------------------------------------------- + // 1. Authentification (routes publiques de session) + // ------------------------------------------------------------------------- + if ($action === 'login') { + $username = (string)($payload['username'] ?? ''); + $password = (string)($payload['password'] ?? ''); + + if (UcpAuth::login($db, $username, $password)) { + $user = UcpAuth::getCurrentUser($db); + echo json_encode([ + 'success' => true, + 'username' => $user['username'] ?? $username, + 'csrf_token' => UcpAuth::getCsrfToken(), + 'message' => 'Connexion réussie.', + ]); + } else { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'message' => 'Identifiant ou mot de passe incorrect.', + ]); + } + exit; + } + + if ($action === 'logout') { + UcpAuth::logout(); + echo json_encode(['success' => true, 'message' => 'Déconnexion effectuée.']); + exit; + } + + // ------------------------------------------------------------------------- + // 2. Barrière d'authentification UCP obligatoire + // ------------------------------------------------------------------------- + $currentUser = UcpAuth::getCurrentUser($db); + if (!$currentUser) { + http_response_code(401); + echo json_encode(['success' => false, 'message' => 'Session expirée ou non authentifiée.']); + exit; + } + + // Protection CSRF sur toutes les actions modificatrices POST + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $csrfHeader = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $payload['csrf_token'] ?? ''); + if (!UcpAuth::verifyCsrfToken($csrfHeader)) { + http_response_code(403); + echo json_encode(['success' => false, 'message' => 'Jeton de sécurité CSRF invalide. Rechargez la page.']); + exit; + } + } + + $ucpUserId = (int)$currentUser['id']; + $allowedExts = UcpAuth::getAllowedExtensions($db, $ucpUserId); + + switch ($action) { + case 'get_state': + $branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: []; + echo json_encode([ + 'success' => true, + 'data' => [ + 'user' => [ + 'id' => $ucpUserId, + 'username' => (string)$currentUser['username'], + 'display_name' => (string)$currentUser['display_name'], + 'email' => (string)$currentUser['email'], + 'last_login_at' => $currentUser['last_login_at'], + ], + 'branding' => [ + 'company_name' => (string)($branding['company_name'] ?? ''), + 'company_logo' => (string)($branding['company_logo'] ?? ''), + ], + 'csrf_token' => UcpAuth::getCsrfToken(), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId), + 'forward_targets' => UcpManager::getForwardTargets($db), + 'feature_codes' => UcpManager::getFeatureCodesForUser($db), + ], + ]); + break; + + case 'save_call_features': + $ext = (string)($payload['extension'] ?? ''); + UcpManager::setCallFeatures($db, $allowedExts, $ext, [ + 'dnd' => !empty($payload['dnd']), + 'cf' => (string)($payload['cf'] ?? ''), + 'cfu' => (string)($payload['cfu'] ?? ''), + ]); + echo json_encode([ + 'success' => true, + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId), + 'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.", + ]); + break; + + case 'get_voicemails': + $ext = (string)($_GET['extension'] ?? $payload['extension'] ?? ''); + if ($ext === '' && !empty($allowedExts)) { + $ext = (string)$allowedExts[0]['extension']; + } + $messages = $ext !== '' ? UcpManager::getVoicemails($allowedExts, $ext) : []; + echo json_encode([ + 'success' => true, + 'messages' => $messages, + ]); + break; + + case 'stream_voicemail': + $ext = (string)($_GET['extension'] ?? ''); + $folder = (string)($_GET['folder'] ?? ''); + $msgId = (string)($_GET['id'] ?? ''); + $download = !empty($_GET['download']); + + $filePath = UcpManager::getVoicemailAudioPath($allowedExts, $ext, $folder, $msgId); + $size = filesize($filePath); + $fileExt = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $mime = ($fileExt === 'gsm') ? 'audio/gsm' : 'audio/wav'; + + while (ob_get_level()) { + ob_end_clean(); + } + + header("Content-Type: {$mime}"); + header('Accept-Ranges: bytes'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + if ($download) { + $dlName = "voicemail_{$ext}_{$msgId}.{$fileExt}"; + header("Content-Disposition: attachment; filename=\"{$dlName}\""); + } + + if (isset($_SERVER['HTTP_RANGE'])) { + [$param, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2); + if (strtolower(trim($param)) === 'bytes') { + [$from, $to] = explode('-', $range, 2); + $from = intval($from); + $to = ($to === '') ? ($size - 1) : intval($to); + if ($from > $to || $from >= $size) { + http_response_code(416); + exit; + } + $length = $to - $from + 1; + http_response_code(206); + header("Content-Range: bytes {$from}-{$to}/{$size}"); + header("Content-Length: {$length}"); + $fp = fopen($filePath, 'rb'); + fseek($fp, $from); + $remaining = $length; + while ($remaining > 0 && !feof($fp)) { + $chunk = min(8192, $remaining); + echo fread($fp, $chunk); + $remaining -= $chunk; + flush(); + } + fclose($fp); + exit; + } + } + + header("Content-Length: {$size}"); + readfile($filePath); + exit; + + case 'toggle_voicemail_read': + $ext = (string)($payload['extension'] ?? ''); + $folder = (string)($payload['folder'] ?? ''); + $msgId = (string)($payload['id'] ?? ''); + UcpManager::moveVoicemailFolder($db, $allowedExts, $ext, $folder, $msgId); + echo json_encode([ + 'success' => true, + 'messages' => UcpManager::getVoicemails($allowedExts, $ext), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'message' => ($folder === 'INBOX') ? 'Message marqué comme lu (archivé).' : 'Message marqué comme non lu.', + ]); + break; + + case 'delete_voicemail': + $ext = (string)($payload['extension'] ?? ''); + $folder = (string)($payload['folder'] ?? ''); + $msgId = (string)($payload['id'] ?? ''); + UcpManager::deleteVoicemail($db, $allowedExts, $ext, $folder, $msgId); + echo json_encode([ + 'success' => true, + 'messages' => UcpManager::getVoicemails($allowedExts, $ext), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'message' => 'Message vocal supprimé définitivement.', + ]); + break; + + case 'save_voicemail_prefs': + $ext = (string)($payload['extension'] ?? ''); + $pin = (string)($payload['pin'] ?? ''); + $email = (string)($payload['email'] ?? ''); + UcpManager::updateVoicemailPrefs($db, $allowedExts, $ext, $pin, $email); + $freshAllowed = UcpAuth::getAllowedExtensions($db, $ucpUserId); + echo json_encode([ + 'success' => true, + 'extensions' => UcpManager::getExtensionsState($db, $freshAllowed), + 'message' => 'Préférences messagerie vocale enregistrées et appliquées.', + ]); + break; + + case 'get_personal_cdr': + $data = UcpManager::getPersonalCdr($db, $allowedExts, [ + 'direction' => $_GET['direction'] ?? 'all', + 'disposition' => $_GET['disposition'] ?? 'all', + 'period' => $_GET['period'] ?? 'all', + 'search' => $_GET['search'] ?? '', + ]); + echo json_encode([ + 'success' => true, + 'data' => $data, + ]); + break; + + case 'get_phonebook': + echo json_encode([ + 'success' => true, + 'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts), + ]); + break; + + case 'save_my_email': + $ext = (string)($payload['extension'] ?? ''); + $email = (string)($payload['email'] ?? ''); + UcpManager::updateMyContactEmail($db, $allowedExts, $ext, $email); + echo json_encode([ + 'success' => true, + 'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'message' => "E-mail annuaire du poste {$ext} mis à jour.", + ]); + break; + + case 'upload_my_avatar': + $ext = (string)($_POST['extension'] ?? ''); + if (empty($_FILES['avatar'])) { + throw new InvalidArgumentException("Aucun fichier image reçu."); + } + $avatarUrl = UcpManager::uploadMyAvatar($db, $allowedExts, $ext, $_FILES['avatar']); + echo json_encode([ + 'success' => true, + 'photo_url' => $avatarUrl, + 'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts), + 'extensions' => UcpManager::getExtensionsState($db, $allowedExts), + 'message' => "Photo de profil du poste {$ext} mise à jour.", + ]); + break; + + case 'avatar': + $file = (string)($_GET['file'] ?? ''); + $path = UcpManager::resolveAvatarPath($file); + if (!$path) { + http_response_code(404); + exit; + } + $extName = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mime = match ($extName) { + 'png' => 'image/png', + 'webp' => 'image/webp', + default => 'image/jpeg', + }; + while (ob_get_level()) { + ob_end_clean(); + } + header("Content-Type: {$mime}"); + header('Cache-Control: public, max-age=3600'); + readfile($path); + exit; + + case 'change_password': + $cur = (string)($payload['current_password'] ?? ''); + $new = (string)($payload['new_password'] ?? ''); + UcpAuth::updateOwnPassword($db, $ucpUserId, $cur, $new); + echo json_encode([ + 'success' => true, + 'message' => 'Mot de passe UCP modifié avec succès.', + ]); + break; + + default: + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Action UCP inconnue.']); + break; + } +} catch (InvalidArgumentException $e) { + http_response_code(400); + if ($isBinaryEndpoint) { + header('Content-Type: text/plain; charset=utf-8'); + echo $e->getMessage(); + } else { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} catch (\Throwable $e) { + http_response_code(500); + if ($isBinaryEndpoint) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'Erreur serveur.'; + } else { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} + diff --git a/ucp/assets/favicon.png b/ucp/assets/favicon.png new file mode 100644 index 0000000..9f612de Binary files /dev/null and b/ucp/assets/favicon.png differ diff --git a/ucp/assets/login-bg.jpg b/ucp/assets/login-bg.jpg new file mode 100644 index 0000000..e011774 Binary files /dev/null and b/ucp/assets/login-bg.jpg differ diff --git a/ucp/assets/logo.png b/ucp/assets/logo.png new file mode 100644 index 0000000..57465ea Binary files /dev/null and b/ucp/assets/logo.png differ diff --git a/ucp/assets/ucp.js b/ucp/assets/ucp.js new file mode 100644 index 0000000..db18f80 --- /dev/null +++ b/ucp/assets/ucp.js @@ -0,0 +1,1207 @@ +'use strict'; + +let ucpState = { + user: null, + extensions: [], + forward_targets: [], + feature_codes: [], + voicemailsByExt: {}, + selectedVmExt: '', + vmFolderFilter: 'all', + phonebook: [], + selectedPbGroup: '__all__', + activeTab: 'extensions', +}; + +let cdrTimer = null; +let pollTimer = null; + +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function ucpIcon(name, cls = 'w-4 h-4') { + const paths = { + voicemail: '', + bolt: '', + calendar: '', + clock: '', + download: '', + trash: '', + phone: '', + mobile: '', + mail: '', + pencil: '', + forward: '', + pickup: '', + dnd: '', + shield: '', + wrench: '', + tag: '', + }; + return `${paths[name] || paths.tag}`; +} + +function showUcpToast(message, type = 'info') { + const container = document.getElementById('ucp-toast-container'); + if (!container) return; + + const palette = { + success: 'bg-emerald-50 border-emerald-200 text-emerald-800', + error: 'bg-rose-50 border-rose-200 text-rose-800', + info: 'bg-white border-slate-200 text-slate-800', + }[type] || 'bg-white border-slate-200 text-slate-800'; + + const el = document.createElement('div'); + el.className = `pointer-events-auto rounded-xl border px-4 py-3 text-xs font-semibold shadow-lg transition-all duration-200 ${palette}`; + el.textContent = message; + container.appendChild(el); + + setTimeout(() => { + el.style.opacity = '0'; + setTimeout(() => el.remove(), 220); + }, 3500); +} + +function resolveUcpAvatarSrc(photoUrl) { + const raw = String(photoUrl || '').trim(); + if (!raw) return ''; + if (raw.startsWith('/avatars/')) { + const file = raw.slice('/avatars/'.length); + return `/api.php?action=avatar&file=${encodeURIComponent(file)}`; + } + return raw; +} + +async function ucpApiCall(action, method = 'GET', body = null) { + const opts = { + method, + credentials: 'same-origin', + headers: {}, + }; + let url = `/api.php?action=${encodeURIComponent(action)}`; + + if (method === 'GET' && body && typeof body === 'object') { + const params = new URLSearchParams(body); + url += `&${params.toString()}`; + } else if (method === 'POST') { + opts.headers['X-CSRF-Token'] = window.UCP_CSRF_TOKEN || ''; + if (body instanceof FormData) { + opts.body = body; + } else { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body || {}); + } + } + + const res = await fetch(url, opts); + if (res.status === 401 && action !== 'login') { + window.location.reload(); + return null; + } + + const json = await res.json(); + if (!res.ok || !json.success) { + throw new Error(json.message || 'Erreur lors de la requête.'); + } + return json; +} + +// ----------------------------------------------------------------------------- +// AUTHENTIFICATION +// ----------------------------------------------------------------------------- + +const loginForm = document.getElementById('ucp-login-form'); +if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const errBox = document.getElementById('ucp-login-error'); + const btn = document.getElementById('ucp-login-btn'); + if (errBox) errBox.classList.add('hidden'); + if (btn) btn.disabled = true; + + try { + const res = await ucpApiCall('login', 'POST', { + username: loginForm.elements['username'].value.trim(), + password: loginForm.elements['password'].value, + }); + if (res && res.csrf_token) { + window.UCP_CSRF_TOKEN = res.csrf_token; + } + window.location.reload(); + } catch (err) { + if (errBox) { + errBox.textContent = err.message; + errBox.classList.remove('hidden'); + } + } finally { + if (btn) btn.disabled = false; + } + }); +} + +async function ucpLogout() { + try { + await ucpApiCall('logout', 'POST', {}); + } catch (_) {} + window.location.reload(); +} + +// ----------------------------------------------------------------------------- +// NAVIGATION & ÉTAT GLOBAL +// ----------------------------------------------------------------------------- + +function switchUcpTab(tabName) { + ucpState.activeTab = tabName; + + document.querySelectorAll('.ucp-panel').forEach(p => p.classList.add('hidden')); + const activePanel = document.getElementById(`ucp-panel-${tabName}`); + if (activePanel) activePanel.classList.remove('hidden'); + + document.querySelectorAll('.ucp-tab-btn').forEach(btn => { + const isMe = btn.dataset.ucpTab === tabName; + btn.className = isMe + ? 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700' + : 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100'; + if (isMe && typeof btn.scrollIntoView === 'function') { + btn.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' }); + } + }); + setTimeout(updateUcpNavScrollHints, 120); + + if (tabName === 'voicemail') { + loadVoicemailsForSelected(); + } else if (tabName === 'cdr') { + loadPersonalCdr(); + } else if (tabName === 'phonebook') { + loadPhonebook(); + } else if (tabName === 'guide') { + renderFeatureGuide(); + } +} + +async function refreshUcpState(manual = false) { + try { + const res = await ucpApiCall('get_state', 'GET'); + if (!res || !res.data) return; + + ucpState.user = res.data.user; + ucpState.extensions = res.data.extensions || []; + ucpState.forward_targets = res.data.forward_targets || []; + ucpState.feature_codes = res.data.feature_codes || []; + if (res.data.csrf_token) { + window.UCP_CSRF_TOKEN = res.data.csrf_token; + } + + // Sélection par défaut du poste pour la messagerie vocale + const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1); + if (!ucpState.selectedVmExt || !vmExts.some(e => e.extension === ucpState.selectedVmExt)) { + ucpState.selectedVmExt = vmExts.length ? vmExts[0].extension : (ucpState.extensions[0]?.extension || ''); + } + + updateBadges(); + renderExtensionsGrid(); + renderVmExtPills(); + syncVmPrefsForm(); + renderFeatureGuide(); + + const accExts = document.getElementById('ucp-account-exts'); + if (accExts) { + accExts.textContent = ucpState.extensions.map(e => `${e.extension} (${e.name})`).join(', ') || 'Aucun poste lié'; + } + const guideExts = document.getElementById('ucp-guide-user-exts'); + if (guideExts) { + guideExts.textContent = ucpState.extensions.map(e => e.extension).join(', ') || 'Aucun'; + } + + if (manual) { + showUcpToast('État actualisé.', 'info'); + if (ucpState.activeTab === 'voicemail') await loadVoicemailsForSelected(); + if (ucpState.activeTab === 'cdr') await loadPersonalCdr(); + if (ucpState.activeTab === 'phonebook') await loadPhonebook(); + } + } catch (err) { + if (manual) showUcpToast(err.message, 'error'); + } +} + +function updateBadges() { + const extBadge = document.getElementById('ucp-badge-exts'); + if (extBadge) extBadge.textContent = String(ucpState.extensions.length); + + const totalNewVm = ucpState.extensions.reduce((acc, e) => acc + Number(e.vm_inbox_count || 0), 0); + const vmBadge = document.getElementById('ucp-badge-vm'); + if (vmBadge) { + vmBadge.textContent = String(totalNewVm); + vmBadge.className = totalNewVm > 0 + ? 'px-1.5 py-0.5 rounded-full bg-rose-100 text-rose-700 text-[10px] font-bold' + : 'px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold'; + } +} + +// ----------------------------------------------------------------------------- +// 1. MES POSTES & REDIRECTIONS (DND / CF / CFU) +// ----------------------------------------------------------------------------- + +function buildForwardOptions(extObj, selectedValue) { + const val = String(selectedValue || ''); + let html = ``; + + if (Number(extObj.voicemail_enabled) === 1) { + const vmVal = `vm-${extObj.extension}`; + html += ``; + } + + const others = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension); + if (others.length > 0) { + html += ``; + others.forEach(t => { + html += ``; + }); + html += ``; + } + + const otherVms = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension && Number(t.voicemail_enabled) === 1); + if (otherVms.length > 0) { + html += ``; + otherVms.forEach(t => { + const vVal = `vm-${t.extension}`; + html += ``; + }); + html += ``; + } + + return html; +} + +function renderExtensionsGrid() { + const grid = document.getElementById('ucp-extensions-grid'); + if (!grid) return; + + if (!ucpState.extensions.length) { + grid.innerHTML = ` +
+ Aucun poste SIP n'est encore lié à votre compte. Contactez votre administrateur. +
+ `; + return; + } + + grid.innerHTML = ucpState.extensions.map(ext => { + const avatarSrc = resolveUcpAvatarSrc(ext.photo_url); + const initials = (ext.name || ext.extension).slice(0, 2).toUpperCase(); + const statusPill = ext.online + ? ` + + En ligne ${ext.rtt_ms !== null ? `• ${ext.rtt_ms} ms` : ''} + ` + : ` + + Hors ligne + `; + + const vmPill = Number(ext.voicemail_enabled) === 1 + ? `` + : ''; + + return ` +
+
+
+ ${avatarSrc + ? `` + : `
${escapeHtml(initials)}
`} +
+
+ ${escapeHtml(ext.extension)} + ${escapeHtml(ext.name)} +
+
+ ${statusPill} + ${vmPill} +
+ ${ext.ip ? `
${escapeHtml(ext.ip)} ${ext.user_agent ? '• ' + escapeHtml(ext.user_agent) : ''}
` : ''} +
+
+
+ + +
+
+
Ne Pas Déranger (DND)
+
+ ${ext.dnd ? 'Activé — les appels entrants sont refusés / envoyés en messagerie' : 'Désactivé — le poste sonne normalement'} +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ Renvoi limité aux postes internes & répondeurs + +
+
+ ${(Number(ext.groundwire_enabled) === 1 && ext.groundwire_deeplink_url && ext.groundwire_xml_url) ? ` + + ` : ''} +
+ `; + }).join(''); +} + +async function saveExtensionFeatures(extNum) { + const dndEl = document.getElementById(`ucp-dnd-${extNum}`); + const cfEl = document.getElementById(`ucp-cf-${extNum}`); + const cfuEl = document.getElementById(`ucp-cfu-${extNum}`); + + try { + const res = await ucpApiCall('save_call_features', 'POST', { + extension: extNum, + dnd: dndEl ? dndEl.checked : false, + cf: cfEl ? cfEl.value : '', + cfu: cfuEl ? cfuEl.value : '', + }); + if (res && res.extensions) { + ucpState.extensions = res.extensions; + renderExtensionsGrid(); + } + showUcpToast(res.message || 'Enregistré.', 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + refreshUcpState(false); + } +} + +function openExtensionVoicemail(extNum) { + ucpState.selectedVmExt = extNum; + switchUcpTab('voicemail'); +} + +// ----------------------------------------------------------------------------- +// 2. MESSAGERIE VOCALE +// ----------------------------------------------------------------------------- + +function renderVmExtPills() { + const container = document.getElementById('ucp-vm-ext-pills'); + if (!container) return; + + const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1); + if (!vmExts.length) { + container.innerHTML = `Aucune boîte vocale activée sur vos postes`; + return; + } + + container.innerHTML = vmExts.map(e => { + const active = e.extension === ucpState.selectedVmExt; + return ` + + `; + }).join(''); +} + +function selectVmExtension(extNum) { + ucpState.selectedVmExt = extNum; + renderVmExtPills(); + syncVmPrefsForm(); + loadVoicemailsForSelected(); +} + +function syncVmPrefsForm() { + const form = document.getElementById('ucp-vm-prefs-form'); + const sub = document.getElementById('ucp-vm-prefs-subtitle'); + if (!form) return; + + const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt); + if (!extObj || Number(extObj.voicemail_enabled) !== 1) { + if (sub) sub.textContent = 'Aucune boîte vocale sélectionnée'; + form.classList.add('opacity-50', 'pointer-events-none'); + return; + } + + form.classList.remove('opacity-50', 'pointer-events-none'); + if (sub) sub.textContent = `Poste ${extObj.extension} — ${extObj.name}`; + form.elements['pin'].value = ''; + form.elements['email'].value = extObj.voicemail_email || ''; +} + +function setVmFolderFilter(folder) { + ucpState.vmFolderFilter = folder; + ['all', 'INBOX', 'Old'].forEach(f => { + const btn = document.getElementById(`vm-filter-${f}`); + if (!btn) return; + btn.className = f === folder + ? 'px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700' + : 'px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50'; + }); + renderVoicemailsList(); +} + +async function loadVoicemailsForSelected() { + const listEl = document.getElementById('ucp-vm-list'); + const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt); + + if (!extObj || Number(extObj.voicemail_enabled) !== 1) { + if (listEl) { + listEl.innerHTML = `
La messagerie vocale n'est activée sur aucun de vos postes liés.
`; + } + return; + } + + try { + const res = await ucpApiCall('get_voicemails', 'GET', { extension: extObj.extension }); + ucpState.voicemailsByExt[extObj.extension] = res.messages || []; + renderVoicemailsList(); + } catch (err) { + if (listEl) { + listEl.innerHTML = `
${escapeHtml(err.message)}
`; + } + } +} + +function formatDuration(sec) { + const s = Math.max(0, Number(sec || 0)); + const m = Math.floor(s / 60); + const r = s % 60; + return `${m}:${String(r).padStart(2, '0')}`; +} + +function renderVoicemailsList() { + const listEl = document.getElementById('ucp-vm-list'); + if (!listEl) return; + + const all = ucpState.voicemailsByExt[ucpState.selectedVmExt] || []; + const filtered = ucpState.vmFolderFilter === 'all' + ? all + : all.filter(m => m.folder === ucpState.vmFolderFilter); + + if (!filtered.length) { + listEl.innerHTML = ` +
+ Aucun message vocal dans ce dossier. +
+ `; + return; + } + + listEl.innerHTML = filtered.map(msg => { + const streamUrl = `/api.php?action=stream_voicemail&extension=${encodeURIComponent(msg.extension)}&folder=${encodeURIComponent(msg.folder)}&id=${encodeURIComponent(msg.id)}`; + const dlUrl = `${streamUrl}&download=1`; + const badge = msg.is_new + ? `Nouveau` + : `Lu / Archivé`; + + return ` +
+
+
+
+ ${escapeHtml(msg.callerid)} + ${badge} +
+
+ ${ucpIcon('calendar', 'w-3.5 h-3.5 text-slate-400')} ${escapeHtml(msg.date_human)} + • + ${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${formatDuration(msg.duration)} +
+
+
+ + ${ucpIcon('download', 'w-3.5 h-3.5 text-slate-500')} + Télécharger + + + +
+
+ +
+ `; + }).join(''); +} + +async function toggleVmRead(ext, folder, id) { + try { + const res = await ucpApiCall('toggle_voicemail_read', 'POST', { extension: ext, folder, id }); + ucpState.voicemailsByExt[ext] = res.messages || []; + if (res.extensions) { + ucpState.extensions = res.extensions; + updateBadges(); + renderVmExtPills(); + renderExtensionsGrid(); + } + renderVoicemailsList(); + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } +} + +async function deleteVm(ext, folder, id) { + if (!confirm('Supprimer définitivement ce message vocal ?')) return; + try { + const res = await ucpApiCall('delete_voicemail', 'POST', { extension: ext, folder, id }); + ucpState.voicemailsByExt[ext] = res.messages || []; + if (res.extensions) { + ucpState.extensions = res.extensions; + updateBadges(); + renderVmExtPills(); + renderExtensionsGrid(); + } + renderVoicemailsList(); + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } +} + +const vmPrefsForm = document.getElementById('ucp-vm-prefs-form'); +if (vmPrefsForm) { + vmPrefsForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!ucpState.selectedVmExt) return; + const btn = document.getElementById('ucp-vm-prefs-btn'); + if (btn) btn.disabled = true; + + try { + const res = await ucpApiCall('save_voicemail_prefs', 'POST', { + extension: ucpState.selectedVmExt, + pin: vmPrefsForm.elements['pin'].value.trim(), + email: vmPrefsForm.elements['email'].value.trim(), + }); + if (res.extensions) { + ucpState.extensions = res.extensions; + syncVmPrefsForm(); + } + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } finally { + if (btn) btn.disabled = false; + } + }); +} + +// ----------------------------------------------------------------------------- +// 3. JOURNAL D'APPELS PERSONNEL +// ----------------------------------------------------------------------------- + +function debounceLoadCdr() { + clearTimeout(cdrTimer); + cdrTimer = setTimeout(loadPersonalCdr, 300); +} + +async function loadPersonalCdr() { + const tbody = document.getElementById('ucp-cdr-tbody'); + const mobileBox = document.getElementById('ucp-cdr-mobile-list'); + if (!tbody && !mobileBox) return; + + try { + const res = await ucpApiCall('get_personal_cdr', 'GET', { + direction: document.getElementById('ucp-cdr-direction')?.value || 'all', + disposition: document.getElementById('ucp-cdr-disposition')?.value || 'all', + period: document.getElementById('ucp-cdr-period')?.value || 'all', + search: document.getElementById('ucp-cdr-search')?.value || '', + }); + const data = res.data || {}; + const calls = data.calls || []; + const stats = data.stats || { total: 0, answered: 0, missed: 0 }; + + document.getElementById('ucp-cdr-total').textContent = String(stats.total); + document.getElementById('ucp-cdr-answered').textContent = String(stats.answered); + document.getElementById('ucp-cdr-missed').textContent = String(stats.missed); + + if (!calls.length) { + if (tbody) tbody.innerHTML = `Aucun appel correspondant.`; + if (mobileBox) mobileBox.innerHTML = `
Aucun appel correspondant.
`; + return; + } + + const dirMap = { + inbound: '↘ Entrant', + outbound: '↗ Sortant', + internal: '⇄ Interne', + }; + + const formatParty = (name, num) => { + if (name && num && name !== num) { + return `
${escapeHtml(name)}
${escapeHtml(num)}
`; + } + return `${escapeHtml(num || name || '—')}`; + }; + + const formatInlineParty = (name, num) => { + if (name && num && name !== num) { + return `${escapeHtml(name)} (${escapeHtml(num)})`; + } + return `${escapeHtml(num || name || '—')}`; + }; + + const rowsHtml = []; + const cardsHtml = []; + + calls.forEach(c => { + const ok = c.disposition === 'ANSWERED'; + const stBadge = ok + ? `${escapeHtml(c.status_label || 'Répondu')}` + : `${escapeHtml(c.status_label || 'Manqué')}`; + + const dirBadge = dirMap[c.direction] || escapeHtml(c.direction_label || c.direction); + const srcNum = String(c.caller_number ?? c.src ?? ''); + const srcName = String(c.caller_name ?? ''); + const dstNum = String(c.callee_number ?? c.dst ?? ''); + const dstName = String(c.callee_name ?? ''); + const durText = escapeHtml(c.billsec_formatted || formatDuration(c.billsec || c.duration || 0)); + const dtText = escapeHtml(c.datetime_formatted || c.datetime || ''); + + rowsHtml.push(` + + ${dtText} + ${dirBadge} + ${formatParty(srcName, srcNum)} + ${formatParty(dstName, dstNum)} + ${durText} + ${stBadge} + + `); + + cardsHtml.push(` +
+
+
+ ${dirBadge} + ${stBadge} +
+ ${ucpIcon('clock', 'w-3.5 h-3.5 text-slate-400')} ${durText} +
+
+ ${formatInlineParty(srcName, srcNum)} + → + ${formatInlineParty(dstName, dstNum)} +
+
${dtText}
+
+ `); + }); + + if (tbody) tbody.innerHTML = rowsHtml.join(''); + if (mobileBox) mobileBox.innerHTML = cardsHtml.join(''); + } catch (err) { + if (tbody) tbody.innerHTML = `${escapeHtml(err.message)}`; + if (mobileBox) mobileBox.innerHTML = `
${escapeHtml(err.message)}
`; + } +} + +// ----------------------------------------------------------------------------- +// 4. ANNUAIRE & PROFIL UTILISATEUR +// ----------------------------------------------------------------------------- + +async function loadPhonebook() { + try { + const res = await ucpApiCall('get_phonebook', 'GET'); + ucpState.phonebook = res.contacts || []; + renderPhonebookGrid(); + } catch (err) { + showUcpToast(err.message, 'error'); + } +} + +function getContactGroups(contact) { + if (Array.isArray(contact?.groups) && contact.groups.length > 0) { + return contact.groups.map(g => String(g || '').trim()).filter(Boolean); + } + const split = String(contact?.group_name || '') + .split(',') + .map(g => g.trim()) + .filter(Boolean); + return split.length > 0 ? Array.from(new Set(split)) : ['Général']; +} + +function renderPhonebookGroupTabs(allContacts) { + const tabsEl = document.getElementById('ucp-pb-group-tabs'); + if (!tabsEl) return; + + const counts = {}; + for (const c of allContacts) { + for (const grp of getContactGroups(c)) { + counts[grp] = (counts[grp] || 0) + 1; + } + } + + const groups = Object.keys(counts).sort((a, b) => { + if (a === 'Général') return -1; + if (b === 'Général') return 1; + return a.localeCompare(b, 'fr', { sensitivity: 'base' }); + }); + + if (ucpState.selectedPbGroup !== '__all__' && !counts[ucpState.selectedPbGroup]) { + ucpState.selectedPbGroup = '__all__'; + } + + if (groups.length <= 1 && allContacts.length === 0) { + tabsEl.innerHTML = ''; + return; + } + + const renderPill = (key, label, count) => { + const isActive = ucpState.selectedPbGroup === key; + const btnCls = isActive + ? 'bg-indigo-600 text-white border-indigo-600 shadow-sm' + : 'bg-white hover:bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300'; + const badgeCls = isActive + ? 'bg-white/20 text-white' + : 'bg-slate-100 text-slate-600'; + return ` + + `; + }; + + tabsEl.innerHTML = [ + renderPill('__all__', 'Tous', allContacts.length), + ...groups.map(g => renderPill(g, g, counts[g])), + ].join(''); + + tabsEl.querySelectorAll('.ucp-pb-group-btn').forEach(btn => { + btn.addEventListener('click', () => { + ucpState.selectedPbGroup = btn.getAttribute('data-pb-group') || '__all__'; + renderPhonebookGrid(); + }); + }); +} + +function renderPhonebookGrid() { + const grid = document.getElementById('ucp-pb-grid'); + if (!grid) return; + + const allContacts = ucpState.phonebook || []; + renderPhonebookGroupTabs(allContacts); + + const activeGroup = ucpState.selectedPbGroup || '__all__'; + const q = (document.getElementById('ucp-pb-search')?.value || '').trim().toLowerCase(); + const list = allContacts.filter(c => { + if (activeGroup !== '__all__' && !getContactGroups(c).includes(activeGroup)) return false; + if (!q) return true; + return [c.display_name, c.internal_number, c.mobile_number, c.other_number, c.email, c.group_name] + .some(v => String(v || '').toLowerCase().includes(q)); + }); + + if (!list.length) { + grid.innerHTML = `
Aucun contact trouvé.
`; + return; + } + + grid.innerHTML = list.map(c => { + const avatarSrc = resolveUcpAvatarSrc(c.photo_url); + const initials = (c.display_name || '?').slice(0, 2).toUpperCase(); + const mineBadge = c.is_mine + ? `Mon poste` + : ''; + + return ` +
+
+ ${avatarSrc + ? `` + : `
${escapeHtml(initials)}
`} +
+
+ ${escapeHtml(c.display_name)} + ${mineBadge} +
+
${escapeHtml(c.group_name || 'Général')}
+
+ ${c.internal_number ? `
${ucpIcon('phone', 'w-3.5 h-3.5 text-indigo-500')} Interne : ${escapeHtml(c.internal_number)}
` : ''} + ${c.mobile_number ? `
${ucpIcon('mobile', 'w-3.5 h-3.5 text-emerald-500')} Mobile : ${escapeHtml(c.mobile_number)}
` : ''} + ${c.other_number ? `
${ucpIcon('phone', 'w-3.5 h-3.5 text-slate-400')} Autre : ${escapeHtml(c.other_number)}
` : ''} + ${c.email ? `
${ucpIcon('mail', 'w-3.5 h-3.5 text-slate-400')} ${escapeHtml(c.email)}
` : ''} +
+
+
+ ${c.is_mine ? ` + + ` : ''} +
+ `; + }).join(''); +} + +function openProfileModal(extNum) { + const contact = (ucpState.phonebook || []).find(c => c.is_mine && String(c.mine_extension) === String(extNum)); + if (!contact) return; + + document.getElementById('ucp-profile-ext').value = extNum; + document.getElementById('ucp-profile-modal-sub').textContent = `Poste ${extNum} — ${contact.display_name}`; + document.getElementById('ucp-profile-email-input').value = contact.email || ''; + + updateProfileModalPreview(contact.photo_url, contact.display_name); + document.getElementById('ucp-profile-modal').classList.remove('hidden'); +} + +function updateProfileModalPreview(photoUrl, name) { + const img = document.getElementById('ucp-profile-preview'); + const init = document.getElementById('ucp-profile-initials'); + const src = resolveUcpAvatarSrc(photoUrl); + + if (src) { + img.src = src; + img.classList.remove('hidden'); + init.classList.add('hidden'); + } else { + img.classList.add('hidden'); + init.textContent = (name || '?').slice(0, 2).toUpperCase(); + init.classList.remove('hidden'); + } +} + +function closeProfileModal() { + document.getElementById('ucp-profile-modal').classList.add('hidden'); +} + +async function uploadSelectedAvatar() { + const input = document.getElementById('ucp-avatar-input'); + const ext = document.getElementById('ucp-profile-ext').value; + if (!input || !input.files || !input.files[0] || !ext) return; + + const fd = new FormData(); + fd.append('extension', ext); + fd.append('avatar', input.files[0]); + + try { + const res = await ucpApiCall('upload_my_avatar', 'POST', fd); + if (res.contacts) ucpState.phonebook = res.contacts; + if (res.extensions) ucpState.extensions = res.extensions; + updateProfileModalPreview(res.photo_url, ext); + renderPhonebookGrid(); + renderExtensionsGrid(); + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } finally { + input.value = ''; + } +} + +const profileEmailForm = document.getElementById('ucp-profile-email-form'); +if (profileEmailForm) { + profileEmailForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const ext = document.getElementById('ucp-profile-ext').value; + const email = document.getElementById('ucp-profile-email-input').value.trim(); + + try { + const res = await ucpApiCall('save_my_email', 'POST', { extension: ext, email }); + if (res.contacts) ucpState.phonebook = res.contacts; + if (res.extensions) ucpState.extensions = res.extensions; + renderPhonebookGrid(); + closeProfileModal(); + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } + }); +} + +// ----------------------------------------------------------------------------- +// 5. NOTICE D'UTILISATION, CODES DE FONCTION & EXPORT PDF +// ----------------------------------------------------------------------------- + +function printFeatureGuide() { + if (ucpState.activeTab !== 'guide') { + switchUcpTab('guide'); + } + const dateEl = document.getElementById('ucp-guide-print-date'); + if (dateEl) { + dateEl.textContent = new Date().toLocaleDateString('fr-FR'); + } + setTimeout(() => window.print(), 80); +} + +function renderFeatureGuide() { + const quickBox = document.getElementById('ucp-guide-quickcards'); + const catBox = document.getElementById('ucp-guide-categories'); + if (!quickBox || !catBox) return; + + const allCodes = ucpState.feature_codes || []; + const byKey = new Map(allCodes.map(c => [String(c.feature_key), c])); + const codeOf = (key, fallback = '') => escapeHtml(byKey.get(key)?.code || fallback); + + // 1. Encarts "Notice pratique pas à pas" + const vmOwn = byKey.get('voicemail_own'); + const vmOther = byKey.get('voicemail_general'); + const cfOn = byKey.get('cf_activate'); + const cfOff = byKey.get('cf_deactivate'); + const cfuOn = byKey.get('cfu_activate'); + const dndTog = byKey.get('dnd_toggle'); + const dndOn = byKey.get('dnd_activate'); + const pkBlf = byKey.get('pickup_blf'); + const pkGrp = byKey.get('pickup_group'); + + quickBox.innerHTML = ` +
+
+ ${ucpIcon('voicemail', 'w-4 h-4 text-indigo-600')} + Utiliser la Messagerie Vocale +
+
    + ${vmOwn ? `
  • • Depuis votre poste : composez ${codeOf('voicemail_own')} (accès direct sans PIN).
  • ` : ''} + ${vmOther ? `
  • • Depuis un autre poste : composez ${codeOf('voicemail_general')} + n° de poste + code PIN.
  • ` : ''} +
  • • Menu vocal : 1 Écouter • 2 Changer de dossier • 3 Options / Supprimer.
  • +
  • • Depuis l'UCP : écoutez, téléchargez (.wav) et changez votre PIN sans appeler.
  • +
+
+ +
+
+ ${ucpIcon('forward', 'w-4 h-4 text-amber-600')} + Gérer Renvois & Ne Pas Déranger +
+
    + ${cfOn ? `
  • • Renvoi immédiat : composez ${codeOf('cf_activate')} + N°${cfOff ? ` (annuler : ${codeOf('cf_deactivate')})` : ''}.
  • ` : ''} + ${cfuOn ? `
  • • Renvoi si absent : composez ${codeOf('cfu_activate')} + N°.
  • ` : ''} + ${dndTog || dndOn ? `
  • • Mode DND (Ne Pas Déranger) : composez ${dndTog ? codeOf('dnd_toggle') : codeOf('dnd_activate')}.
  • ` : ''} +
  • • Toutes ces options sont synchronisées en direct avec l'onglet Mes Postes.
  • +
+
+ +
+
+ ${ucpIcon('pickup', 'w-4 h-4 text-emerald-600')} + Interception & Prise d'Appel +
+
    + ${pkBlf ? `
  • • Poste qui clignote (BLF) : appuyez sur sa touche ou composez ${codeOf('pickup_blf')} + Poste.
  • ` : ''} + ${pkGrp ? `
  • • Intercepter un poste du groupe : composez ${codeOf('pickup_group')}.
  • ` : ''} +
  • • En appel : utilisez la touche Transfert de votre téléphone + n° du collègue.
  • +
+
+ `; + + // 2. Regroupement des codes actifs par catégorie + const q = (document.getElementById('ucp-guide-search')?.value || '').trim().toLowerCase(); + const filtered = allCodes.filter(c => { + if (!q) return true; + return String(c.code || '').toLowerCase().includes(q) + || String(c.name || '').toLowerCase().includes(q) + || String(c.description || '').toLowerCase().includes(q) + || String(c.category || '').toLowerCase().includes(q); + }); + + if (!filtered.length) { + catBox.innerHTML = `
Aucun code de fonction correspondant à « ${escapeHtml(q)} ».
`; + return; + } + + const catMeta = { + voicemail: { label: 'Messagerie Vocale', icon: ucpIcon('voicemail', 'w-4 h-4 text-indigo-600'), badge: 'bg-indigo-50 text-indigo-700 border-indigo-200', header: 'bg-indigo-50/60 border-indigo-100 text-indigo-950' }, + forward: { label: "Renvois d'Appel", icon: ucpIcon('forward', 'w-4 h-4 text-amber-600'), badge: 'bg-amber-50 text-amber-800 border-amber-200', header: 'bg-amber-50/60 border-amber-100 text-amber-950' }, + dnd: { label: 'Ne Pas Déranger (DND)', icon: ucpIcon('dnd', 'w-4 h-4 text-rose-600'), badge: 'bg-rose-50 text-rose-700 border-rose-200', header: 'bg-rose-50/60 border-rose-100 text-rose-950' }, + pickup: { label: "Interception d'Appels", icon: ucpIcon('pickup', 'w-4 h-4 text-emerald-600'), badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', header: 'bg-emerald-50/60 border-emerald-100 text-emerald-950' }, + security: { label: 'Liste Noire & Blocage Appels', icon: ucpIcon('shield', 'w-4 h-4 text-slate-600'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-100/80 border-slate-200 text-slate-900' }, + tools: { label: 'Outils, Tests Audio & Services', icon: ucpIcon('wrench', 'w-4 h-4 text-sky-600'), badge: 'bg-sky-50 text-sky-700 border-sky-200', header: 'bg-sky-50/60 border-sky-100 text-sky-950' }, + }; + + const orderedCats = ['voicemail', 'forward', 'dnd', 'pickup', 'security', 'tools']; + const grouped = new Map(); + filtered.forEach(c => { + const key = c.category || 'tools'; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(c); + }); + + const catKeys = [ + ...orderedCats.filter(k => grouped.has(k)), + ...[...grouped.keys()].filter(k => !orderedCats.includes(k)), + ]; + + catBox.innerHTML = catKeys.map(catKey => { + const meta = catMeta[catKey] || { label: catKey, icon: ucpIcon('tag', 'w-4 h-4 text-slate-500'), badge: 'bg-slate-100 text-slate-700 border-slate-300', header: 'bg-slate-50 border-slate-200 text-slate-900' }; + const items = grouped.get(catKey) || []; + + const rows = items.map(item => ` +
+
+ + ${escapeHtml(item.code)} + +
+
+
${escapeHtml(item.name)}
+

${escapeHtml(item.description || '')}

+
+
+ `).join(''); + + return ` +
+
+
+ ${meta.icon} + ${escapeHtml(meta.label)} +
+ ${items.length} code(s) +
+
${rows}
+
+ `; + }).join(''); +} + +// ----------------------------------------------------------------------------- +// 6. CHANGEMENT DE MOT DE PASSE UCP +// ----------------------------------------------------------------------------- + +const passwordForm = document.getElementById('ucp-password-form'); +if (passwordForm) { + passwordForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const cur = passwordForm.elements['current_password'].value; + const nw = passwordForm.elements['new_password'].value; + const conf = passwordForm.elements['confirm_password'].value; + + if (nw !== conf) { + showUcpToast('Les deux nouveaux mots de passe ne correspondent pas.', 'error'); + return; + } + + try { + const res = await ucpApiCall('change_password', 'POST', { + current_password: cur, + new_password: nw, + }); + passwordForm.reset(); + showUcpToast(res.message, 'success'); + } catch (err) { + showUcpToast(err.message, 'error'); + } + }); +} + +// ----------------------------------------------------------------------------- +// INITIALISATION +// ----------------------------------------------------------------------------- + +if (window.UCP_IS_AUTH) { + refreshUcpState(false); + initUcpNavScrollHints(); + pollTimer = setInterval(() => { + if (!document.hidden) { + refreshUcpState(false); + } + }, 15000); +} + +function updateUcpNavScrollHints() { + const scroller = document.getElementById('ucp-nav-scroller'); + const leftHint = document.getElementById('ucp-nav-hint-left'); + const rightHint = document.getElementById('ucp-nav-hint-right'); + if (!scroller || !leftHint || !rightHint) return; + + const hasLeft = scroller.scrollLeft > 4; + const hasRight = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 4; + + leftHint.classList.toggle('hidden', !hasLeft); + leftHint.classList.toggle('flex', hasLeft); + rightHint.classList.toggle('hidden', !hasRight); + rightHint.classList.toggle('flex', hasRight); +} + +function scrollUcpNav(direction) { + const scroller = document.getElementById('ucp-nav-scroller'); + if (!scroller) return; + scroller.scrollBy({ left: direction * 170, behavior: 'smooth' }); +} + +function initUcpNavScrollHints() { + const scroller = document.getElementById('ucp-nav-scroller'); + if (!scroller) return; + scroller.addEventListener('scroll', updateUcpNavScrollHints, { passive: true }); + window.addEventListener('resize', updateUcpNavScrollHints); + requestAnimationFrame(updateUcpNavScrollHints); + setTimeout(updateUcpNavScrollHints, 250); +} + diff --git a/ucp/download_groundwire.php b/ucp/download_groundwire.php new file mode 100644 index 0000000..ef61c2a --- /dev/null +++ b/ucp/download_groundwire.php @@ -0,0 +1,89 @@ + 0 && $uid > 0) { + if (UcpManager::verifyGroundwireToken($db, $extId, $uid, $token)) { + $allowed = UcpAuth::getAllowedExtensions($db, $uid); + foreach ($allowed as $row) { + if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) { + $authorized = true; + break; + } + } + } + } + + // 2. Sinon : utilisateur UCP connecté en session et propriétaire de l'extension + if (!$authorized && $extId > 0) { + UcpAuth::startSession(); + $sessionUser = UcpAuth::getCurrentUser($db); + if ($sessionUser !== null) { + $allowed = UcpAuth::getAllowedExtensions($db, (int)$sessionUser['id']); + foreach ($allowed as $row) { + if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) { + $authorized = true; + break; + } + } + } + } + + if (!$authorized) { + http_response_code(403); + header('Content-Type: text/plain; charset=utf-8'); + echo "Accès refusé ou lien de provisionnement invalide."; + exit; + } + + $bundle = UcpManager::buildGroundwireXml($db, $extId); + $xmlContent = $bundle['xml']; + + while (ob_get_level()) { + ob_end_clean(); + } + + header('Content-Type: application/xml; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $bundle['filename'] . '"'); + header('Content-Length: ' . strlen($xmlContent)); + header('Cache-Control: no-store, no-cache, must-revalidate'); + + echo $xmlContent; +} catch (\InvalidArgumentException $e) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo $e->getMessage(); +} catch (\Throwable $e) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + echo "Erreur interne lors de la génération du profil XML."; +} diff --git a/ucp/index.php b/ucp/index.php new file mode 100644 index 0000000..64b9a02 --- /dev/null +++ b/ucp/index.php @@ -0,0 +1,576 @@ +query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: []; +$companyName = trim((string)($branding['company_name'] ?? '')); +$companyLogo = trim((string)($branding['company_logo'] ?? '')); +$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : []; +$appVersion = $appVerData['version'] ?? '2.5.0'; +$csrfToken = UcpAuth::getCsrfToken(); +?> + + + + + + <?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Portail Utilisateur (UCP) + + + + + + + + +
+ + +
+
+
+ +
+

Portail Utilisateur désactivé

+

Ce service a été temporairement désactivé par votre administrateur système.

+
+
+ + +
+
+
+
+
+ + Logo + + NexusPBX + + + Portail Utilisateur • UCP + +

+

Connectez-vous pour gérer vos postes, renvois, messagerie et annuaire

+
+ +
+
+ + +
+
+ + +
+ + +
+ +
+ Espace utilisateur isolé • Protégé par Fail2ban • NexusPBX v +
+
+
+ + +
+ +
+
+
+ + Logo + + NexusPBX + +
+
+ + +
+

+
+
+ +
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ + +
+
+
+

Mes Postes & Redirections

+

Activez le mode Ne Pas Déranger (DND) ou configurez vos renvois vers un autre poste ou votre messagerie vocale.

+
+
+
+
Chargement de vos postes…
+
+
+ + + + + + + + + + + + + + + +
+
+ + + + + + + + + + diff --git a/version.json b/version.json index d0d9a6c..2e427c2 100644 --- a/version.json +++ b/version.json @@ -1,18 +1,33 @@ { - "version": "2.0.0", - "codename": "Pulsar", - "release_date": "2026-11-11", + "version": "2.6.7", + "codename": "Pulsar UCP", + "release_date": "2026-11-12", "min_php_version": "8.2", "changelog": [ - "NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience", - "Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP", - "HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média", - "HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover", - "HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master", - "HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)", - "Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks", - "Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-) en débordement, SVI et routes entrantes", - "Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée", - "Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs" + { + "version": "2.6.7", + "date": "2026-11-12", + "changes": [ + "UI Admin & UCP : Remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes (style Heroicons), sans modifier le menu latéral Admin", + "Correction d'un problème de syntaxe JS sur le badge d'avertissement TLS dans app.js" + ] + }, + "UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)", + "UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique", + "Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)", + "UI v2.6.3 : onglets dynamiques par groupe (Tous, Général, Paris, New-York...) avec compteurs dans l'Annuaire UCP", + "Correctif v2.6.2 : sélection stricte de GroundWire (NAT si renseigné, sinon VIP HA si cluster actif, sinon IP locale + port d'écoute local)", + "UI v2.6.1 : suppression de l'icône smartphone sur les éléments GroundWire et refonte des boutons d'action du tableau Postes (Redémarrer / Modifier / Supprimer)", + "Nouveauté v2.6.0 : provisionnement dynamique XML Acrobits GroundWire par poste (option Admin « GroundWire XML », deep link mobile provlinkbs:, téléchargement XML signé HMAC sans session, liens App Store / Google Play dans l'UCP)", + "UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur", + "Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)", + "Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP", + "Correctif v2.5.4 : correction du journal personnel UCP « Mes Appels » (correspondance caller_number/callee_number/canaux PJSIP et filtres Répondus/Manqués)", + "Nouveauté v2.5.3 : onglet UCP « Notice & Codes » avec mémo pas-à-pas, codes de fonction dynamiques et export PDF / impression A4 corporate (logo, encarts, poste utilisateur)", + "Correctif v2.5.2 : purge systématique try/finally + auto-nettoyage des dossiers temporaires HA /tmp (élimine la saturation tmpfs 100% qui bloquait la réplication)", + "Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel", + "Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp", + "Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page", + "NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)" ] }