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..d259945 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); @@ -210,6 +209,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 +273,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'); @@ -2446,6 +2453,32 @@ try { echo json_encode($res); break; + case 'get_ucp_users': + echo json_encode([ + 'success' => true, + 'users' => UcpManager::listUsers($db), + ]); + break; + + case 'save_ucp_user': + $users = UcpManager::saveUser($db, $payload); + echo json_encode([ + 'success' => true, + '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, + '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..5ead48f 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -211,6 +211,7 @@ function renderAll() { renderFeatureCodes(); renderSipSettings(); renderCertificates(); + renderUcpTab(); renderSettingsAndDiagnostics(); } @@ -356,6 +357,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; @@ -3629,6 +3632,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 +3713,11 @@ function switchTab(tabId) { loadCluster(); } + // Si onglet Portail Utilisateur (UCP), actualiser l'affichage + if (tabId === 'ucp') { + renderUcpTab(); + } + toggleMobileSidebar(false); } @@ -4346,19 +4355,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,7 +5398,12 @@ 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 ` @@ -5424,6 +5448,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; + } } } @@ -6798,4 +6837,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(`🔒 ${escapeHtml(url)}`); + } + if (httpPort > 0) { + const url = `http://${host}:${httpPort}/`; + links.push(`🌐 ${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 || []; + 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 || []; + 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..6ee4b7f 100644 --- a/public/index.php +++ b/public/index.php @@ -338,6 +338,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') • Annuaire d'Entreprise 0 + @@ -2199,6 +2204,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') + + + + + +
+
+
+

Comptes Portail Utilisateur

+

Chaque compte ne peut gérer que les extensions SIP qui lui sont explicitement assignées.

+
+
+
+ + + + + + + + + + + + + + + + +
IdentifiantNom affiché & EmailExtensions liéesStatutDernière connexionActions
Chargement des utilisateurs UCP...
+
+
+ + @@ -5458,6 +5587,83 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') + + + + + diff --git a/scripts/nexus-firewall b/scripts/nexus-firewall index 55326d1..1a119a5 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"; diff --git a/scripts/update.sh b/scripts/update.sh index bbea215..4ed4ca9 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -151,7 +151,7 @@ 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 @@ -163,14 +163,48 @@ which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/n (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 } -# 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}" +# 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 @@ -211,8 +245,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..a0fb721 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -548,6 +548,9 @@ class ClusterManager 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 astdb_cluster_sync.sqlite3"; } @@ -747,6 +750,17 @@ class ClusterManager 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.sqlite3'; if (file_exists($extractedAstdb)) { @@ -754,7 +768,7 @@ class ClusterManager @unlink($extractedAstdb); } - @rmdir($tmpExtractDir); + shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); @unlink($tmpArchive); // Réouverture de la connexion DB fraîche @@ -809,6 +823,35 @@ 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 { + $fw = FirewallManager::getSettings(); + FirewallManager::ensureUcpFail2banArtifacts(); + FirewallManager::applyNginxConfig( + (int)($fw['admin_https_port'] ?? 8443), + (int)($fw['certbot_strict_port80'] ?? 0), + (int)($fw['ucp_enabled'] ?? 1), + (int)($fw['ucp_https_port'] ?? 8444), + (int)($fw['ucp_http_port'] ?? 8084) + ); + FirewallManager::generateJailLocal( + (string)($fw['fail2ban_whitelist_ips'] ?? ''), + (int)($fw['fail2ban_sip_maxretry'] ?? 3), + (int)($fw['fail2ban_sip_findtime'] ?? 600), + (int)($fw['fail2ban_sip_bantime'] ?? 3600), + (int)($fw['fail2ban_web_maxretry'] ?? 3), + (int)($fw['fail2ban_web_bantime'] ?? 3600), + (int)($fw['admin_https_port'] ?? 8443), + (int)($fw['ucp_enabled'] ?? 1), + (int)($fw['ucp_https_port'] ?? 8444), + (int)($fw['ucp_http_port'] ?? 8084), + (int)($fw['fail2ban_ucp_maxretry'] ?? 3), + (int)($fw['fail2ban_ucp_bantime'] ?? 3600) + ); + } 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); 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..c352ace 100644 --- a/src/Database.php +++ b/src/Database.php @@ -48,7 +48,8 @@ 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 FROM settings LIMIT 1"); + self::$instance->query("SELECT id FROM ucp_users LIMIT 1"); } catch (\Throwable $e) { self::initializeSchema(); } @@ -338,6 +339,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,6 +434,11 @@ 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", ]; foreach ($extraSettings as $colName => $colDef) { if (!in_array($colName, $existing, true)) { diff --git a/src/FirewallManager.php b/src/FirewallManager.php index 55c2de2..d9d801d 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,159 @@ 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 ? << 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 +788,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 +825,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/UcpAuth.php b/src/UcpAuth.php new file mode 100644 index 0000000..7a295fc --- /dev/null +++ b/src/UcpAuth.php @@ -0,0 +1,196 @@ + 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, 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..2927432 --- /dev/null +++ b/src/UcpManager.php @@ -0,0 +1,787 @@ +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): 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] ?? []; + + $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'] ?? ''), + ]; + } + + 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]]; + } + + $res = CdrManager::getCalls($db, [ + 'limit' => 500, + 'direction' => (string)($filters['direction'] ?? 'all'), + 'disposition' => (string)($filters['disposition'] ?? 'all'), + 'period' => (string)($filters['period'] ?? 'all'), + 'search' => (string)($filters['search'] ?? ''), + ]); + + $filtered = []; + $answered = 0; + $missed = 0; + + foreach ($res['calls'] as $call) { + $srcMatch = in_array((string)($call['src'] ?? ''), $extSet, true); + $dstMatch = in_array((string)($call['dst'] ?? ''), $extSet, true); + if (!$srcMatch && !$dstMatch) { + continue; + } + if (($call['disposition'] ?? '') === '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; + } + + // ========================================================================= + // 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..9cfe27d --- /dev/null +++ b/ucp/api.php @@ -0,0 +1,342 @@ + 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), + 'forward_targets' => UcpManager::getForwardTargets($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), + '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/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..5461a26 --- /dev/null +++ b/ucp/assets/ucp.js @@ -0,0 +1,831 @@ +'use strict'; + +let ucpState = { + user: null, + extensions: [], + forward_targets: [], + voicemailsByExt: {}, + selectedVmExt: '', + vmFolderFilter: 'all', + phonebook: [], + activeTab: 'extensions', +}; + +let cdrTimer = null; +let pollTimer = null; + +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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 (tabName === 'voicemail') { + loadVoicemailsForSelected(); + } else if (tabName === 'cdr') { + loadPersonalCdr(); + } else if (tabName === 'phonebook') { + loadPhonebook(); + } +} + +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 || []; + 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(); + + const accExts = document.getElementById('ucp-account-exts'); + if (accExts) { + accExts.textContent = ucpState.extensions.map(e => `${e.extension} (${e.name})`).join(', ') || 'Aucun poste lié'; + } + + 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 + +
+
+
+ `; + }).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} +
+
+ 📅 ${escapeHtml(msg.date_human)} • ⏱️ ${formatDuration(msg.duration)} +
+
+
+ + ⬇️ 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'); + if (!tbody) 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) { + tbody.innerHTML = `Aucun appel correspondant.`; + return; + } + + tbody.innerHTML = calls.map(c => { + const dirMap = { + inbound: '↘ Entrant', + outbound: '↗ Sortant', + internal: '⇄ Interne', + }; + const ok = c.disposition === 'ANSWERED'; + const stBadge = ok + ? 'Répondu' + : 'Manqué'; + + return ` + + ${escapeHtml(c.date_human || c.calldate || '')} + ${dirMap[c.direction] || escapeHtml(c.direction)} + ${escapeHtml(c.src_label || c.src || '')} + ${escapeHtml(c.dst_label || c.dst || '')} + ${formatDuration(c.billsec || c.duration || 0)} + ${stBadge} + + `; + }).join(''); + } catch (err) { + tbody.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 renderPhonebookGrid() { + const grid = document.getElementById('ucp-pb-grid'); + if (!grid) return; + + const q = (document.getElementById('ucp-pb-search')?.value || '').trim().toLowerCase(); + const list = (ucpState.phonebook || []).filter(c => { + 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 ? `
📱 Interne : ${escapeHtml(c.internal_number)}
` : ''} + ${c.mobile_number ? `
📲 Mobile : ${escapeHtml(c.mobile_number)}
` : ''} + ${c.other_number ? `
☎️ Autre : ${escapeHtml(c.other_number)}
` : ''} + ${c.email ? `
✉️ ${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. 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); + pollTimer = setInterval(() => { + if (!document.hidden) { + refreshUcpState(false); + } + }, 15000); +} diff --git a/ucp/index.php b/ucp/index.php new file mode 100644 index 0000000..f29b522 --- /dev/null +++ b/ucp/index.php @@ -0,0 +1,414 @@ +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 + +
+
+ + +
+

Espace personnel de

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

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..5e6e727 100644 --- a/version.json +++ b/version.json @@ -1,18 +1,16 @@ { - "version": "2.0.0", - "codename": "Pulsar", + "version": "2.5.0", + "codename": "Pulsar UCP", "release_date": "2026-11-11", "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" + "NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)", + "Sécurité & Isolation Nginx : VirtualHosts UCP dédiés (/var/www/nexus-pbx/ucp) séparés physiquement de l'Administration", + "Ports UCP configurables depuis l'interface Pare-feu : HTTPS (8444) pour accès direct et HTTP (8084) pour Reverse Proxy, avec interrupteur Activer/Désactiver", + "Fail2ban dédié Portail Utilisateur : nouvelle jail [nexus-ucp], filtre nexus-ucp.conf et journal isolé /var/log/nexus_ucp_security.log (résolution X-Real-IP derrière Proxy)", + "Gestion Admin des Comptes UCP : création/édition/désactivation d'utilisateurs avec liaison d'une ou plusieurs extensions SIP", + "Portail Employé Mode Clair : suivi des postes temps réel, activation NPD (DND) + mise à jour BLF, renvois internes (CF/CFU) et vers répondeur (vm-) sans numéro externe", + "Messagerie Vocale Web : écoute HTML5 (Accept-Ranges), téléchargement WAV, marquage Lu/Non lu (synchro MWI), suppression et modification PIN/email", + "Journal d'appels personnel filtré par extensions liées, annuaire complet avec édition photo (avatar) et email pour ses propres postes, et synchro HA des avatars" ] }