feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)
This commit is contained in:
1 parent
7922e338e3
commit
72e43f1a9a
18 files changed
+3499
-62
No files matched your search
+44
-1
@@ -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);
|
||||
|
||||
@@ -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[] = "";
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -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)) {
|
||||
|
||||
+294
-26
@@ -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 = <<<EOF
|
||||
# Fichier généré automatiquement par NexusPBX
|
||||
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
|
||||
port = {$portsWeb}
|
||||
protocol = tcp
|
||||
|
||||
[nexus-ucp]
|
||||
enabled = {$ucpJailEnabled}
|
||||
filter = nexus-ucp
|
||||
logpath = /var/log/nexus_ucp_security.log
|
||||
maxretry = {$ucpMaxRetry}
|
||||
findtime = 600
|
||||
bantime = {$ucpBanTime}
|
||||
port = {$portsUcp}
|
||||
protocol = tcp
|
||||
|
||||
EOF;
|
||||
|
||||
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
|
||||
@@ -339,10 +424,22 @@ EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME uniquement si strict) et écouter sur le port d'admin.
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME), le port d'admin et les VHosts isolés de l'UCP.
|
||||
*/
|
||||
public static function applyNginxConfig(int $adminHttpsPort, int $strictPort80): void
|
||||
{
|
||||
public static function applyNginxConfig(
|
||||
int $adminHttpsPort,
|
||||
int $strictPort80,
|
||||
?int $ucpEnabled = null,
|
||||
?int $ucpHttpsPort = null,
|
||||
?int $ucpHttpPort = null
|
||||
): void {
|
||||
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null) {
|
||||
$st = self::getSettings();
|
||||
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
|
||||
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
|
||||
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
|
||||
}
|
||||
|
||||
$phpSock = '/run/php/php8.4-fpm.sock';
|
||||
if (!file_exists($phpSock)) {
|
||||
$found = glob('/run/php/php*-fpm.sock');
|
||||
@@ -485,7 +582,19 @@ server {
|
||||
}
|
||||
EOF;
|
||||
|
||||
$fullNginxConfig = $port80Block . "\n\n" . $phonebookBlock . "\n\n" . $httpsBlock . "\n";
|
||||
$blocks = [$port80Block, $phonebookBlock, $httpsBlock];
|
||||
|
||||
// VirtualHosts Nginx totalement isolés pour l'UCP (/var/www/nexus-pbx/ucp)
|
||||
if ($ucpEnabled === 1 && is_dir(self::UCP_ROOT_DIR)) {
|
||||
if ($ucpHttpsPort > 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 ? <<<SSL
|
||||
|
||||
ssl_certificate /etc/asterisk/keys/active_cert.crt;
|
||||
ssl_certificate_key /etc/asterisk/keys/active_key.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
SSL : '';
|
||||
|
||||
$logPrefix = $ssl ? 'nexus-ucp.ssl' : 'nexus-ucp';
|
||||
|
||||
return <<<EOF
|
||||
server {
|
||||
{$listenDirectives}
|
||||
server_name _;{$sslDirectives}
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
root {$ucpRoot};
|
||||
index index.php;
|
||||
|
||||
access_log /var/log/nginx/{$logPrefix}.access.log;
|
||||
error_log /var/log/nginx/{$logPrefix}.error.log;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.php?\$query_string;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:{$phpSock};
|
||||
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le filtre et le fichier de log Fail2ban dédiés au portail UCP.
|
||||
*/
|
||||
public static function ensureUcpFail2banArtifacts(): void
|
||||
{
|
||||
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
|
||||
if (!file_exists(self::UCP_FILTER_PATH) || file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
|
||||
@file_put_contents(self::UCP_FILTER_PATH, $filterContent);
|
||||
@chmod(self::UCP_FILTER_PATH, 0644);
|
||||
}
|
||||
if (!file_exists(self::UCP_SECURITY_LOG)) {
|
||||
@touch(self::UCP_SECURITY_LOG);
|
||||
}
|
||||
@chown(self::UCP_SECURITY_LOG, 'www-data');
|
||||
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
|
||||
@chmod(self::UCP_SECURITY_LOG, 0664);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
|
||||
*/
|
||||
private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void
|
||||
{
|
||||
$oldPorts = [];
|
||||
if (!empty($oldSettings['ucp_enabled'])) {
|
||||
if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port'];
|
||||
if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port'];
|
||||
}
|
||||
$newPorts = [];
|
||||
if (!empty($newSettings['ucp_enabled'])) {
|
||||
if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port'];
|
||||
if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port'];
|
||||
}
|
||||
|
||||
$toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts));
|
||||
$toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts));
|
||||
|
||||
// UFW si installé et actif
|
||||
$ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null'));
|
||||
if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
foreach ($newPorts as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT)
|
||||
$iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1"));
|
||||
if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
foreach ($toAdd as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance.
|
||||
*/
|
||||
public static function resolveClientIp(): string
|
||||
{
|
||||
$remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
|
||||
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
// Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback
|
||||
$isTrustedProxy = (
|
||||
filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
|
||||
);
|
||||
|
||||
if ($isTrustedProxy) {
|
||||
$candidates = [];
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) {
|
||||
$candidates[] = trim($part);
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $cand) {
|
||||
if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $remote;
|
||||
}
|
||||
|
||||
private static function getJailDetails(string $jail): array
|
||||
{
|
||||
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
|
||||
@@ -526,7 +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),
|
||||
};
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class UcpAuth
|
||||
{
|
||||
public const SESSION_NAME = 'NEXUS_UCP_SESS';
|
||||
|
||||
public static function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name(self::SESSION_NAME);
|
||||
session_start([
|
||||
'cookie_httponly' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class UcpManager
|
||||
{
|
||||
public const VOICEMAIL_SPOOL_BASE = '/var/spool/asterisk/voicemail/default';
|
||||
|
||||
// =========================================================================
|
||||
// 1. GESTION ADMIN DES UTILISATEURS UCP
|
||||
// =========================================================================
|
||||
|
||||
public static function listUsers(PDO $db): array
|
||||
{
|
||||
$users = $db->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-<ext>).
|
||||
*/
|
||||
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<ext>.
|
||||
*/
|
||||
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<ext> 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-<ext>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user