Files
NexusPBX/public/api.php
T

2388 lines
116 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/Auth.php';
require_once __DIR__ . '/../src/ConfigGenerator.php';
require_once __DIR__ . '/../src/AmiClient.php';
require_once __DIR__ . '/../src/SystemChecker.php';
require_once __DIR__ . '/../src/SystemMetrics.php';
require_once __DIR__ . '/../src/CertificateManager.php';
require_once __DIR__ . '/../src/BackupManager.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
use App\Database;
use App\Auth;
use App\ConfigGenerator;
use App\AmiClient;
use App\SystemChecker;
use App\SystemMetrics;
use App\CertificateManager;
use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
try {
$db = Database::getConnection();
Auth::startSession();
$action = trim((string)($_GET['action'] ?? ''));
$rawBody = file_get_contents('php://input');
$payload = $rawBody ? (json_decode($rawBody, true) ?? []) : [];
if (empty($payload) && !empty($_POST)) {
$payload = $_POST;
}
// Actions publiques (Login / Logout)
if ($action === 'login') {
$username = trim((string)($payload['username'] ?? ''));
$password = (string)($payload['password'] ?? '');
if (Auth::login($db, $username, $password)) {
echo json_encode([
'success' => true,
'username' => Auth::getUsername(),
'message' => 'Connexion réussie.',
]);
} 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]);
}
$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);
http_response_code(401);
echo json_encode([
'success' => false,
'message' => 'Identifiants incorrects.',
]);
}
exit;
}
if ($action === 'logout') {
Auth::logout();
echo json_encode(['success' => true, 'message' => 'Session fermée.']);
exit;
}
// Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) {
http_response_code(401);
echo json_encode([
'success' => false,
'unauthenticated' => true,
'message' => 'Session expirée ou non authentifiée.',
]);
exit;
}
$generator = new ConfigGenerator($db);
switch ($action) {
case 'get_state':
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$pagingGroups = $db->query('SELECT * FROM paging_groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$extensionGroups = $db->query('SELECT * FROM extension_groups ORDER BY id ASC')->fetchAll();
$trunks = $db->query('SELECT * FROM trunks ORDER BY id ASC')->fetchAll();
$ivrs = $db->query('SELECT * FROM ivrs ORDER BY id ASC')->fetchAll();
$outboundRoutes = $db->query('SELECT * FROM outbound_routes ORDER BY priority ASC, id ASC')->fetchAll();
$sounds = $db->query('SELECT * FROM sounds ORDER BY site_category ASC, usage_type ASC, id ASC')->fetchAll();
$soundSites = $db->query('SELECT * FROM sound_sites ORDER BY id ASC')->fetchAll();
foreach ($groups as &$g) {
$g['members'] = json_decode((string)$g['members'], true) ?: [];
}
unset($g);
foreach ($pagingGroups as &$pg) {
$pg['members'] = json_decode((string)$pg['members'], true) ?: [];
}
unset($pg);
foreach ($extensionGroups as &$eg) {
$eg['members'] = json_decode((string)$eg['members'], true) ?: [];
}
unset($eg);
foreach ($trunks as &$t) {
$t['did_routes'] = ConfigGenerator::normalizeInboundRoutes((string)($t['did_routes_json'] ?? '[]'));
}
unset($t);
foreach ($ivrs as &$i) {
$i['options'] = json_decode((string)$i['options_json'], true) ?: [];
}
unset($i);
foreach ($outboundRoutes as &$or) {
$or['trunk_sequence'] = json_decode((string)($or['trunk_sequence_json'] ?? '[]'), true) ?: [];
$or['dial_patterns'] = json_decode((string)($or['dial_patterns_json'] ?? '[]'), true) ?: [];
$or['allowed_groups'] = json_decode((string)($or['allowed_groups_json'] ?? '[]'), true) ?: [];
$or['allowed_extensions'] = json_decode((string)($or['allowed_extensions_json'] ?? '[]'), true) ?: [];
}
unset($or);
$settings['local_networks'] = json_decode((string)($settings['local_networks_json'] ?? '[]'), true) ?: [];
if (empty($settings['local_networks']) && !empty($settings['local_net'])) {
$settings['local_networks'] = [$settings['local_net']];
}
$appDir = dirname(__DIR__);
$installedVerData = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: [];
$installedVer = $installedVerData['version'] ?? '1.4.5';
echo json_encode([
'success' => true,
'version' => $installedVer,
'user' => Auth::getUsername(),
'settings' => $settings,
'extensions' => $extensions,
'groups' => $groups,
'paging_groups' => $pagingGroups,
'extension_groups' => $extensionGroups,
'trunks' => $trunks,
'ivrs' => $ivrs,
'outbound_routes' => $outboundRoutes,
'sounds' => $sounds,
'sound_sites' => $soundSites,
'feature_codes' => $db->query('SELECT * FROM feature_codes ORDER BY category, id')->fetchAll(),
'certificates' => CertificateManager::getAll($db),
'active_certificate' => CertificateManager::getDefault($db),
'diagnostics' => SystemChecker::runDiagnostics($db),
'backups_count' => count(BackupManager::listBackups($db)),
'phonebook_contacts' => PhonebookManager::getContacts($db),
'phonebook_groups' => PhonebookManager::getGroups($db),
'metrics' => SystemMetrics::collect($db),
'previews' => [
'pjsip' => $generator->generatePjsipConf(),
'extensions' => $generator->generateExtensionsConf(),
'moh' => $generator->generateMusiconholdConf(),
'rtp' => $generator->generateRtpConf(),
'voicemail' => $generator->generateVoicemailConf(),
],
]);
break;
case 'get_metrics':
echo json_encode([
'success' => true,
'metrics' => SystemMetrics::collect($db),
]);
break;
case 'get_security_events':
$events = [];
$logFile = '/var/log/asterisk/security.log';
if (is_readable($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if (!str_contains($line, 'ChallengeResponseFailed') && !str_contains($line, 'InvalidAccountID') && !str_contains($line, 'FailedAuth')) {
continue;
}
preg_match('/\[([^\]]+)\]/', $line, $tm);
preg_match('/SecurityEvent="([^"]+)"/', $line, $em);
preg_match('/Service="([^"]+)"/', $line, $sm);
preg_match('/AccountID="([^"]+)"/', $line, $am);
preg_match('/RemoteAddress="IPV[46]\/[A-Z]+\/([^\/]+)/', $line, $rm);
$events[] = [
'time' => $tm[1] ?? date('M d H:i:s'),
'event' => $em[1] ?? 'FailedAuth',
'service' => $sm[1] ?? 'PJSIP',
'account' => $am[1] ?? '-',
'ip' => $rm[1] ?? '-',
];
if (count($events) >= 50) break;
}
}
echo json_encode([
'success' => true,
'events' => $events,
]);
break;
case 'clear_security_log':
$logFile = '/var/log/asterisk/security.log';
if (file_exists($logFile)) {
@file_put_contents($logFile, '');
@chown($logFile, 'asterisk');
@chgrp($logFile, 'asterisk');
@chmod($logFile, 0664);
}
$webLog = '/var/log/nexus_security.log';
if (file_exists($webLog)) {
@file_put_contents($webLog, '');
@chown($webLog, 'www-data');
@chgrp($webLog, 'asterisk');
@chmod($webLog, 0664);
}
@unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/fail2ban-client reload 2>&1');
echo json_encode([
'success' => true,
'message' => 'Le journal des événements de sécurité a été vidé avec succès.',
]);
break;
case 'save_extension':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
$name = trim((string)($payload['name'] ?? ''));
$secret = trim((string)($payload['secret'] ?? ''));
$transport = ($payload['transport'] ?? 'udp') === 'tls' ? 'tls' : 'udp';
$encryption = max(0, min(2, (int)($payload['encryption'] ?? ($transport === 'tls' ? 1 : 0))));
$codecs = preg_replace('/[^a-z0-9,]/', '', strtolower((string)($payload['codecs'] ?? 'alaw,ulaw,opus')));
$max_contacts = max(1, min(10, (int)($payload['max_contacts'] ?? 2)));
$enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1;
$voicemail_enabled = isset($payload['voicemail_enabled']) ? (int)!empty($payload['voicemail_enabled']) : 0;
$force_pin_change = isset($payload['force_pin_change']) ? (int)!empty($payload['force_pin_change']) : 0;
$voicemail_pin = ($force_pin_change === 1) ? '' : preg_replace('/[^\d]/', '', (string)($payload['voicemail_pin'] ?? ''));
$voicemail_skip_pin = isset($payload['voicemail_skip_pin']) ? (int)!empty($payload['voicemail_skip_pin']) : 0;
$voicemail_email = trim((string)($payload['voicemail_email'] ?? ''));
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
if ($force_pin_change === 1) {
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
if (file_exists($secFile)) {
@unlink($secFile);
}
}
if (!preg_match('/^\d{2,6}$/', $extension)) {
throw new InvalidArgumentException("Le numéro de poste doit comporter entre 2 et 6 chiffres.");
}
if ($name === '') {
throw new InvalidArgumentException("Le nom du poste est obligatoire.");
}
if (strlen($secret) < 8) {
throw new InvalidArgumentException("Le secret SIP doit contenir au moins 8 caractères.");
}
if ($codecs === '') {
$codecs = 'alaw,ulaw';
}
if ($id) {
$stmt = $db->prepare('
UPDATE extensions
SET extension = :ext, name = :name, secret = :secret, transport = :transport,
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
voicemail_delete = :vmd, ring_time = :rt
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':ext' => $extension,
':name' => $name,
':secret' => $secret,
':transport' => $transport,
':encryption' => $encryption,
':codecs' => $codecs,
':max_contacts' => $max_contacts,
':enabled' => $enabled,
':vme' => $voicemail_enabled,
':vmp' => $voicemail_pin,
':fpc' => $force_pin_change,
':vsp' => $voicemail_skip_pin,
':vme_mail' => $voicemail_email,
':vma' => $voicemail_attach,
':vmd' => $voicemail_delete,
':rt' => $ring_time,
]);
} else {
$stmt = $db->prepare('
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
voicemail_email, voicemail_attach, voicemail_delete, ring_time)
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt)
ON CONFLICT(extension) DO UPDATE SET
name = excluded.name,
secret = excluded.secret,
transport = excluded.transport,
encryption = excluded.encryption,
codecs = excluded.codecs,
max_contacts = excluded.max_contacts,
enabled = excluded.enabled,
voicemail_enabled = excluded.voicemail_enabled,
voicemail_pin = excluded.voicemail_pin,
force_pin_change = excluded.force_pin_change,
voicemail_skip_pin = excluded.voicemail_skip_pin,
voicemail_email = excluded.voicemail_email,
voicemail_attach = excluded.voicemail_attach,
voicemail_delete = excluded.voicemail_delete,
ring_time = excluded.ring_time
');
$stmt->execute([
':ext' => $extension,
':name' => $name,
':secret' => $secret,
':transport' => $transport,
':encryption' => $encryption,
':codecs' => $codecs,
':max_contacts' => $max_contacts,
':enabled' => $enabled,
':vme' => $voicemail_enabled,
':vmp' => $voicemail_pin,
':fpc' => $force_pin_change,
':vsp' => $voicemail_skip_pin,
':vme_mail' => $voicemail_email,
':vma' => $voicemail_attach,
':vmd' => $voicemail_delete,
':rt' => $ring_time,
]);
}
// Synchronisation de l'appartenance aux Groupes d'Extensions (Sites / Droits de routes : Nice, Paris...)
if (isset($payload['member_extension_groups']) && is_array($payload['member_extension_groups'])) {
$selectedExtGroupIds = array_map('intval', $payload['member_extension_groups']);
$allExtGroups = $db->query('SELECT id, members FROM extension_groups')->fetchAll();
$updEg = $db->prepare('UPDATE extension_groups SET members = :m WHERE id = :id');
foreach ($allExtGroups as $egRow) {
$mArr = json_decode((string)$egRow['members'], true) ?: [];
$mArr = array_values(array_filter(array_map('strval', $mArr), fn($x) => $x !== $extension));
if (in_array((int)$egRow['id'], $selectedExtGroupIds, true)) {
$mArr[] = $extension;
}
$updEg->execute([
':m' => json_encode(array_values(array_unique($mArr))),
':id' => (int)$egRow['id'],
]);
}
}
// Synchronisation automatique de l'annuaire d'entreprise
PhonebookManager::syncExtensions($db);
// Régénération automatique des fichiers Asterisk et rechargement PJSIP/Dialplan en direct
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
$modeBadge = $transport === 'tls' ? 'Distant (TLS + SRTP)' : ($encryption ? 'Interne (UDP + SRTP)' : 'Interne (UDP)');
echo json_encode([
'success' => true,
'message' => "Poste {$extension} ({$name}) enregistré en mode {$modeBadge}.",
]);
break;
case 'delete_extension':
$id = (int)($payload['id'] ?? 0);
$stmt = $db->prepare('DELETE FROM extensions WHERE id = :id');
$stmt->execute([':id' => $id]);
PhonebookManager::syncExtensions($db);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => 'Poste téléphonique supprimé de la base SQLite.',
]);
break;
case 'save_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
$name = trim((string)($payload['name'] ?? ''));
$strategy = in_array($payload['strategy'] ?? '', ['ringall', 'hunt', 'random'], true) ? $payload['strategy'] : 'ringall';
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
$members = is_array($payload['members'] ?? null) ? array_values(array_filter(array_map('trim', $payload['members']))) : [];
$failover = trim((string)($payload['failover_dest'] ?? 'hangup')) ?: 'hangup';
if (!preg_match('/^\d{2,6}$/', $extension)) {
throw new InvalidArgumentException("Le numéro du groupe doit comporter entre 2 et 6 chiffres.");
}
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire.");
}
if ($id) {
$stmt = $db->prepare('
UPDATE groups
SET extension = :ext, name = :name, strategy = :strategy,
ring_time = :ring_time, members = :members, failover_dest = :failover
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':ext' => $extension,
':name' => $name,
':strategy' => $strategy,
':ring_time' => $ring_time,
':members' => json_encode($members),
':failover' => $failover,
]);
} else {
$stmt = $db->prepare('
INSERT INTO groups (extension, name, strategy, ring_time, members, failover_dest)
VALUES (:ext, :name, :strategy, :ring_time, :members, :failover)
ON CONFLICT(extension) DO UPDATE SET
name = excluded.name,
strategy = excluded.strategy,
ring_time = excluded.ring_time,
members = excluded.members,
failover_dest = excluded.failover_dest
');
$stmt->execute([
':ext' => $extension,
':name' => $name,
':strategy' => $strategy,
':ring_time' => $ring_time,
':members' => json_encode($members),
':failover' => $failover,
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.",
]);
break;
case 'delete_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de sonnerie supprimé.",
]);
break;
case 'save_paging_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
$name = trim((string)($payload['name'] ?? ''));
$desc = trim((string)($payload['description'] ?? ''));
$members = is_array($payload['members'] ?? null) ? array_values(array_filter(array_map('trim', $payload['members']))) : [];
$duplex = !empty($payload['duplex']) ? 1 : 0;
$busyMode = in_array($payload['busy_mode'] ?? '', ['skip', 'force'], true) ? $payload['busy_mode'] : 'skip';
$playBeep = isset($payload['play_beep']) ? (!empty($payload['play_beep']) ? 1 : 0) : 1;
$timeout = max(10, min(600, (int)($payload['timeout'] ?? 120)));
$enabled = isset($payload['enabled']) ? (!empty($payload['enabled']) ? 1 : 0) : 1;
if (!preg_match('/^\d{2,6}$/', $extension)) {
throw new InvalidArgumentException("Le numéro du groupe de téléavertissement doit comporter entre 2 et 6 chiffres.");
}
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe de téléavertissement est obligatoire.");
}
if (empty($members)) {
throw new InvalidArgumentException("Veuillez sélectionner au moins un poste membre pour ce groupe de téléavertissement.");
}
// Vérification de conflit avec un poste SIP existant
$chkExt = $db->prepare('SELECT COUNT(*) FROM extensions WHERE extension = :ext');
$chkExt->execute([':ext' => $extension]);
if ((int)$chkExt->fetchColumn() > 0) {
throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un poste SIP individuel.");
}
// Vérification de conflit avec un groupe de sonnerie
$chkGrp = $db->prepare('SELECT COUNT(*) FROM groups WHERE extension = :ext');
$chkGrp->execute([':ext' => $extension]);
if ((int)$chkGrp->fetchColumn() > 0) {
throw new InvalidArgumentException("Le numéro {$extension} est déjà assigné à un groupe de sonnerie.");
}
if ($id) {
$stmt = $db->prepare('
UPDATE paging_groups
SET extension = :ext, name = :name, description = :desc,
members = :members, duplex = :duplex, busy_mode = :busy_mode,
play_beep = :play_beep, timeout = :timeout, enabled = :enabled,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':ext' => $extension,
':name' => $name,
':desc' => $desc,
':members' => json_encode($members),
':duplex' => $duplex,
':busy_mode' => $busyMode,
':play_beep' => $playBeep,
':timeout' => $timeout,
':enabled' => $enabled,
]);
} else {
$stmt = $db->prepare('
INSERT INTO paging_groups (extension, name, description, members, duplex, busy_mode, play_beep, timeout, enabled)
VALUES (:ext, :name, :desc, :members, :duplex, :busy_mode, :play_beep, :timeout, :enabled)
ON CONFLICT(extension) DO UPDATE SET
name = excluded.name,
description = excluded.description,
members = excluded.members,
duplex = excluded.duplex,
busy_mode = excluded.busy_mode,
play_beep = excluded.play_beep,
timeout = excluded.timeout,
enabled = excluded.enabled,
updated_at = CURRENT_TIMESTAMP
');
$stmt->execute([
':ext' => $extension,
':name' => $name,
':desc' => $desc,
':members' => json_encode($members),
':duplex' => $duplex,
':busy_mode' => $busyMode,
':play_beep' => $playBeep,
':timeout' => $timeout,
':enabled' => $enabled,
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.",
]);
break;
case 'delete_paging_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement supprimé.",
]);
break;
case 'save_extension_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$name = trim((string)($payload['name'] ?? ''));
$description = trim((string)($payload['description'] ?? ''));
$color = trim((string)($payload['color'] ?? 'cyan')) ?: 'cyan';
$membersRaw = is_array($payload['members'] ?? null) ? $payload['members'] : [];
$members = array_values(array_unique(array_filter(array_map('strval', $membersRaw))));
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'extensions est obligatoire (ex: Groupe Nice, Groupe Paris).");
}
if ($id) {
$stmt = $db->prepare('
UPDATE extension_groups
SET name = :name, description = :description, color = :color, members = :members
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':name' => $name,
':description' => $description,
':color' => $color,
':members' => json_encode($members),
]);
} else {
$stmt = $db->prepare('
INSERT INTO extension_groups (name, description, color, members)
VALUES (:name, :description, :color, :members)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
color = excluded.color,
members = excluded.members
');
$stmt->execute([
':name' => $name,
':description' => $description,
':color' => $color,
':members' => json_encode($members),
]);
}
echo json_encode([
'success' => true,
'message' => "Groupe d'extensions « {$name} » sauvegardé avec " . count($members) . " membre(s).",
]);
break;
case 'delete_extension_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM extension_groups WHERE id = :id')->execute([':id' => $id]);
PhonebookManager::syncExtensions($db);
echo json_encode([
'success' => true,
'message' => "Groupe d'extensions supprimé.",
]);
break;
case 'save_phonebook_contact':
$cid = PhonebookManager::saveContact($db, $payload);
echo json_encode([
'success' => true,
'contact_id' => $cid,
'contacts' => PhonebookManager::getContacts($db),
'groups' => PhonebookManager::getGroups($db),
'message' => "Contact enregistré dans l'annuaire d'entreprise.",
]);
break;
case 'delete_phonebook_contact':
$cid = (int)($payload['id'] ?? 0);
PhonebookManager::deleteContact($db, $cid);
echo json_encode([
'success' => true,
'contacts' => PhonebookManager::getContacts($db),
'groups' => PhonebookManager::getGroups($db),
'message' => "Contact supprimé de l'annuaire.",
]);
break;
case 'sync_phonebook':
PhonebookManager::syncExtensions($db);
echo json_encode([
'success' => true,
'contacts' => PhonebookManager::getContacts($db),
'groups' => PhonebookManager::getGroups($db),
'message' => "Annuaire synchronisé avec succès.",
]);
break;
case 'upload_phonebook_avatar':
PhonebookManager::initDirectory();
if (empty($_FILES['avatar']['tmp_name'])) {
throw new InvalidArgumentException("Aucun fichier image reçu.");
}
$file = $_FILES['avatar'];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException("Erreur lors de l'envoi du fichier.");
}
if ($file['size'] > 5 * 1024 * 1024) {
throw new InvalidArgumentException("L'image ne doit pas dépasser 5 Mo.");
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($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.");
}
$ext = $allowed[$mime];
$cid = isset($_POST['contact_id']) ? preg_replace('/[^\w\-]/', '', (string)$_POST['contact_id']) : 'temp';
$filename = "avatar_{$cid}_" . time() . ".{$ext}";
$target = PhonebookManager::AVATARS_DIR . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException("Échec de l'enregistrement de l'avatar.");
}
@chmod($target, 0664);
$avatarUrl = "/avatars/{$filename}";
if (!empty($_POST['contact_id']) && is_numeric($_POST['contact_id'])) {
$db->prepare("UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
->execute([':url' => $avatarUrl, ':id' => (int)$_POST['contact_id']]);
}
echo json_encode([
'success' => true,
'photo_url' => $avatarUrl,
'message' => "Photo de profil mise à jour.",
]);
break;
case 'stream_sound':
$id = (int)($_GET['id'] ?? $payload['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM sounds WHERE id = :id');
$stmt->execute([':id' => $id]);
$sound = $stmt->fetch();
if (!$sound) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Fichier son introuvable.']);
exit;
}
$slug = $sound['slug'];
$mp3Path = "/var/www/nexus-pbx/storage/sounds/{$slug}.mp3";
$wavPath = "/var/lib/asterisk/sounds/custom/{$slug}.wav";
$filePath = file_exists($mp3Path) ? $mp3Path : (file_exists($wavPath) ? $wavPath : null);
if (!$filePath || !is_readable($filePath)) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Fichier audio non présent sur le disque.']);
exit;
}
$mime = str_ends_with($filePath, '.mp3') ? 'audio/mpeg' : 'audio/wav';
$size = filesize($filePath);
while (ob_get_level()) { ob_end_clean(); }
header("Content-Type: {$mime}");
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=86400');
if (isset($_SERVER['HTTP_RANGE'])) {
list($param, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
if (strtolower(trim($param)) === 'bytes') {
list($from, $to) = explode('-', $range, 2);
$from = intval($from);
$to = ($to === '') ? ($size - 1) : intval($to);
if ($from > $to || $from >= $size) {
http_response_code(416);
header("Content-Range: bytes */{$size}");
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);
$buffer = 1024 * 64;
while (!feof($fp) && ($pos = ftell($fp)) <= $to) {
if ($pos + $buffer > $to) {
$buffer = $to - $pos + 1;
}
echo fread($fp, $buffer);
flush();
}
fclose($fp);
exit;
}
}
header("Content-Length: {$size}");
readfile($filePath);
exit;
case 'upload_sound':
$name = trim((string)($payload['name'] ?? ''));
$siteCategory = trim((string)($payload['site_category'] ?? 'Global')) ?: 'Global';
$usageType = trim((string)($payload['usage_type'] ?? 'announcement'));
$description = trim((string)($payload['description'] ?? ''));
if ($name === '') {
throw new InvalidArgumentException("Le nom du fichier son est obligatoire.");
}
$allowedUsages = ['moh', 'announcement', 'ivr', 'congestion', 'custom'];
if (!in_array($usageType, $allowedUsages, true)) {
$usageType = 'announcement';
}
if (!isset($_FILES['audio_file']) || $_FILES['audio_file']['error'] !== UPLOAD_ERR_OK) {
$errCode = $_FILES['audio_file']['error'] ?? -1;
$errMap = [
UPLOAD_ERR_INI_SIZE => "Le fichier audio dépasse la limite maximale autorisée par le serveur (64 Mo).",
UPLOAD_ERR_FORM_SIZE => "Le fichier audio dépasse la taille maximale spécifiée.",
UPLOAD_ERR_PARTIAL => "Le fichier audio n'a été que partiellement téléversé.",
UPLOAD_ERR_NO_FILE => "Veuillez sélectionner un fichier audio à téléverser.",
UPLOAD_ERR_NO_TMP_DIR => "Dossier temporaire manquant sur le serveur.",
UPLOAD_ERR_CANT_WRITE => "Échec d'écriture du fichier audio sur le disque.",
];
$msg = $errMap[$errCode] ?? "Erreur lors du téléversement du fichier audio (code {$errCode}).";
throw new InvalidArgumentException($msg);
}
$fileInfo = $_FILES['audio_file'];
$origFileName = $fileInfo['name'];
$tmpPath = $fileInfo['tmp_name'];
$fileSize = (int)$fileInfo['size'];
if ($fileSize > 35 * 1024 * 1024) {
throw new InvalidArgumentException("Le fichier audio dépasse la taille maximale autorisée (35 Mo).");
}
// Génération d'un slug unique
$slugBase = preg_replace('/[^a-z0-9]+/i', '-', strtolower($name));
$slugBase = trim($slugBase, '-');
if ($slugBase === '') {
$slugBase = 'audio-' . time();
}
$slug = $slugBase;
$counter = 1;
while ((int)$db->query("SELECT COUNT(*) FROM sounds WHERE slug = " . $db->quote($slug))->fetchColumn() > 0) {
$counter++;
$slug = "{$slugBase}-{$counter}";
}
// Dossiers cibles
$customDir = '/var/lib/asterisk/sounds/custom';
$mohBase = '/var/lib/asterisk/moh';
$storeDir = '/var/www/nexus-pbx/storage/sounds';
@mkdir($customDir, 0775, true);
@mkdir($storeDir, 0775, true);
$targetWav = "{$customDir}/{$slug}.wav";
$targetAlaw = "{$customDir}/{$slug}.alaw";
$targetMp3 = "{$storeDir}/{$slug}.mp3";
// Fichiers de travail dans storage/sounds (garanti accessible en écriture par www-data)
$storeWav = "{$storeDir}/{$slug}.wav";
$storeAlaw = "{$storeDir}/{$slug}.alaw";
// 1. Conversion Asterisk WAV (8kHz mono 16-bit PCM)
$cmdWav = sprintf('ffmpeg -y -i %s -ar 8000 -ac 1 -c:a pcm_s16le %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($storeWav));
exec($cmdWav, $outWav, $retWav);
if ($retWav !== 0) {
@unlink($storeWav);
throw new RuntimeException("Erreur de conversion audio WAV Asterisk : " . implode("\n", $outWav));
}
// 2. Conversion Asterisk ALAW (G.711a 8kHz mono)
$cmdAlaw = sprintf('ffmpeg -y -i %s -ar 8000 -ac 1 -f alaw %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($storeAlaw));
exec($cmdAlaw, $outAlaw, $retAlaw);
// 3. Conversion MP3 Web preview
$cmdMp3 = sprintf('ffmpeg -y -i %s -c:a libmp3lame -b:a 128k %s 2>&1', escapeshellarg($tmpPath), escapeshellarg($targetMp3));
exec($cmdMp3, $outMp3, $retMp3);
// 4. Copie vers les dossiers Asterisk custom
@copy($storeWav, $targetWav);
if (file_exists($storeAlaw)) {
@copy($storeAlaw, $targetAlaw);
}
// 5. Si musique d'attente (MOH), copier dans le dossier MOH correspondant
if ($usageType === 'moh') {
$siteSlug = preg_replace('/[^a-z0-9]+/i', '-', strtolower($siteCategory));
$siteSlug = trim($siteSlug, '-') ?: 'default';
if ($siteSlug === 'global') $siteSlug = 'default';
$siteMohDir = "{$mohBase}/{$siteSlug}";
@mkdir($siteMohDir, 0775, true);
@copy($storeWav, "{$siteMohDir}/{$slug}.wav");
if (file_exists($storeAlaw)) {
@copy($storeAlaw, "{$siteMohDir}/{$slug}.alaw");
}
@chown($siteMohDir, 'www-data');
@chgrp($siteMohDir, 'asterisk');
}
// Calcul de la durée en secondes
$cmdDur = sprintf('ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>&1', escapeshellarg($storeWav));
$durStr = trim((string)shell_exec($cmdDur));
$durationSec = max(1, (int)round((float)$durStr));
// Permissions
@chown($storeWav, 'www-data'); @chgrp($storeWav, 'asterisk'); @chmod($storeWav, 0664);
@chown($targetWav, 'asterisk'); @chgrp($targetWav, 'asterisk'); @chmod($targetWav, 0664);
@chown($targetMp3, 'www-data'); @chgrp($targetMp3, 'asterisk'); @chmod($targetMp3, 0664);
$stmt = $db->prepare('
INSERT INTO sounds (name, slug, site_category, usage_type, description, filename, original_filename, duration_sec, file_size, format)
VALUES (:name, :slug, :site, :usage, :desc, :file, :orig, :dur, :size, :fmt)
');
$stmt->execute([
':name' => $name,
':slug' => $slug,
':site' => $siteCategory,
':usage' => $usageType,
':desc' => $description,
':file' => "{$slug}.wav",
':orig' => $origFileName,
':dur' => $durationSec,
':size' => file_exists($targetWav) ? filesize($targetWav) : $fileSize,
':fmt' => 'wav',
]);
// Régénération automatique si MoH
if ($usageType === 'moh') {
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
}
echo json_encode([
'success' => true,
'message' => "Fichier son « {$name} » téléversé et encodé au format Asterisk avec succès ({$durationSec}s).",
]);
break;
case 'update_sound':
$id = (int)($payload['id'] ?? 0);
$name = trim((string)($payload['name'] ?? ''));
$siteCategory = trim((string)($payload['site_category'] ?? 'Global')) ?: 'Global';
$usageType = trim((string)($payload['usage_type'] ?? 'announcement'));
$description = trim((string)($payload['description'] ?? ''));
if (!$id || $name === '') {
throw new InvalidArgumentException("ID et Nom du fichier son obligatoires.");
}
$stmt = $db->prepare('
UPDATE sounds
SET name = :name, site_category = :site, usage_type = :usage, description = :desc
WHERE id = :id
');
$stmt->execute([
':name' => $name,
':site' => $siteCategory,
':usage' => $usageType,
':desc' => $description,
':id' => $id,
]);
echo json_encode([
'success' => true,
'message' => "Informations du fichier son mises à jour.",
]);
break;
case 'delete_sound':
$id = (int)($payload['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM sounds WHERE id = :id');
$stmt->execute([':id' => $id]);
$sound = $stmt->fetch();
if (!$sound) {
throw new InvalidArgumentException("Fichier son introuvable.");
}
$slug = $sound['slug'];
@unlink("/var/lib/asterisk/sounds/custom/{$slug}.wav");
@unlink("/var/lib/asterisk/sounds/custom/{$slug}.alaw");
@unlink("/var/www/nexus-pbx/storage/sounds/{$slug}.mp3");
foreach (glob("/var/lib/asterisk/moh/*/{$slug}.*") ?: [] as $f) {
@unlink($f);
}
$soundDir = "/var/lib/asterisk/moh/sound_{$slug}";
if (is_dir($soundDir)) {
foreach (glob("{$soundDir}/*") ?: [] as $f) {
@unlink($f);
}
@rmdir($soundDir);
}
// Réinitialise les références si ce son était sélectionné par défaut ou sur un trunk
$db->prepare("UPDATE settings SET default_moh_sound_id = 0 WHERE default_moh_sound_id = :id")->execute([':id' => $id]);
$db->prepare("UPDATE trunks SET moh_sound_id = 0 WHERE moh_sound_id = :id")->execute([':id' => $id]);
$db->prepare('DELETE FROM sounds WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Fichier son « {$sound['name']} » supprimé avec succès.",
]);
break;
case 'save_sound_site':
$id = (int)($payload['id'] ?? 0);
$name = trim((string)($payload['name'] ?? ''));
$description = trim((string)($payload['description'] ?? ''));
if ($name === '') {
throw new InvalidArgumentException("Le nom de la catégorie de site est obligatoire (ex: Site Nice, Site Paris).");
}
if ($id > 0) {
// Récupère l'ancien nom pour mettre à jour les sons associés
$stmtOld = $db->prepare('SELECT name FROM sound_sites WHERE id = :id');
$stmtOld->execute([':id' => $id]);
$oldName = $stmtOld->fetchColumn();
$stmt = $db->prepare('UPDATE sound_sites SET name = :n, description = :d WHERE id = :id');
$stmt->execute([':n' => $name, ':d' => $description, ':id' => $id]);
if ($oldName && $oldName !== $name) {
$updSounds = $db->prepare('UPDATE sounds SET site_category = :newName WHERE site_category = :oldName');
$updSounds->execute([':newName' => $name, ':oldName' => $oldName]);
}
} else {
$stmt = $db->prepare('INSERT INTO sound_sites (name, description) VALUES (:n, :d)');
$stmt->execute([':n' => $name, ':d' => $description]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Catégorie de site « {$name} » enregistrée.",
]);
break;
case 'delete_sound_site':
$id = (int)($payload['id'] ?? 0);
$stmtSite = $db->prepare('SELECT name FROM sound_sites WHERE id = :id');
$stmtSite->execute([':id' => $id]);
$siteName = $stmtSite->fetchColumn();
if (!$siteName) {
throw new InvalidArgumentException("Catégorie de site introuvable.");
}
if (strtolower(trim($siteName)) === 'global') {
throw new InvalidArgumentException("La catégorie « Global » est le socle standard du système et ne peut pas être supprimée.");
}
// Réassigne les sons de ce site vers 'Global'
$db->prepare("UPDATE sounds SET site_category = 'Global' WHERE site_category = :old")->execute([':old' => $siteName]);
$db->prepare('DELETE FROM sound_sites WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Catégorie « {$siteName} » supprimée. Les sons associés ont été réassignés à « Global ».",
]);
break;
case 'set_default_moh':
$soundId = (int)($payload['sound_id'] ?? 0);
$stmtSnd = $db->prepare("SELECT * FROM sounds WHERE id = :id");
$stmtSnd->execute([':id' => $soundId]);
$snd = $stmtSnd->fetch();
if (!$snd) {
throw new InvalidArgumentException("Fichier son introuvable.");
}
if ($snd['usage_type'] !== 'moh') {
throw new InvalidArgumentException("Le fichier sélectionné doit avoir pour type d'usage « Musique d'attente ».");
}
$db->prepare("UPDATE settings SET default_moh_sound_id = :id WHERE id = 1")->execute([':id' => $soundId]);
// Copie vers /var/lib/asterisk/moh/default
$defDir = "/var/lib/asterisk/moh/default";
@mkdir($defDir, 0775, true);
@chown($defDir, 'www-data');
@chgrp($defDir, 'asterisk');
// Nettoyage des anciens fichiers par défaut
foreach (glob("{$defDir}/*") ?: [] as $f) {
@unlink($f);
}
$srcWav = "/var/lib/asterisk/sounds/custom/{$snd['slug']}.wav";
$srcAlaw = "/var/lib/asterisk/sounds/custom/{$snd['slug']}.alaw";
if (file_exists($srcWav)) @copy($srcWav, "{$defDir}/{$snd['slug']}.wav");
if (file_exists($srcAlaw)) @copy($srcAlaw, "{$defDir}/{$snd['slug']}.alaw");
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "« {$snd['name']} » est maintenant la Musique d'attente (MoH) générale par défaut du PBX.",
]);
break;
case 'save_trunk':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$name = trim((string)($payload['name'] ?? ''));
$host = trim((string)($payload['host'] ?? ''));
$port = max(1, min(65535, (int)($payload['port'] ?? 5060)));
$outbound_proxy = trim((string)($payload['outbound_proxy'] ?? ''));
$transportRaw = strtolower(trim((string)($payload['transport'] ?? 'udp')));
$transport = in_array($transportRaw, ['udp', 'tcp', 'tls'], true) ? $transportRaw : 'udp';
$auth_mode = ($payload['auth_mode'] ?? 'userpass') === 'ip' ? 'ip' : 'userpass';
$username = trim((string)($payload['username'] ?? ''));
$auth_username = trim((string)($payload['auth_username'] ?? ''));
$secret = trim((string)($payload['secret'] ?? ''));
$rawRegMode = strtolower(trim((string)($payload['registration_mode'] ?? '')));
if (in_array($rawRegMode, ['send', 'receive', 'none'], true)) {
$registration_mode = $rawRegMode;
} else {
$registration_mode = (!empty($payload['register']) && $auth_mode !== 'ip') ? 'send' : 'none';
}
$register = ($registration_mode === 'send') ? 1 : 0;
$server_uri = trim((string)($payload['server_uri'] ?? ''));
$client_uri = trim((string)($payload['client_uri'] ?? ''));
$contact_user = trim((string)($payload['contact_user'] ?? ''));
$expiration = max(60, min(86400, (int)($payload['expiration'] ?? 3600)));
$retry_interval = max(10, min(3600, (int)($payload['retry_interval'] ?? 60)));
$line_support = isset($payload['line_support']) ? (int)!empty($payload['line_support']) : 1;
$from_user = trim((string)($payload['from_user'] ?? ''));
$from_domain = trim((string)($payload['from_domain'] ?? ''));
$outbound_callerid = trim((string)($payload['outbound_callerid'] ?? ''));
$send_pai = isset($payload['send_pai']) ? (int)!empty($payload['send_pai']) : 1;
$send_rpid = (int)!empty($payload['send_rpid']);
$send_diversion = isset($payload['send_diversion']) ? (int)!empty($payload['send_diversion']) : 1;
$trust_id_inbound = isset($payload['trust_id_inbound']) ? (int)!empty($payload['trust_id_inbound']) : 1;
$max_retries = max(0, (int)($payload['max_retries'] ?? 10000));
$forbidden_retry_interval = max(0, (int)($payload['forbidden_retry_interval'] ?? 600));
$fatal_retry_interval = max(0, (int)($payload['fatal_retry_interval'] ?? 0));
$codecs = preg_replace('/[^a-z0-9,]/', '', strtolower((string)($payload['codecs'] ?? 'alaw,ulaw,g722'))) ?: 'alaw,ulaw';
$dtmf_mode = in_array($payload['dtmf_mode'] ?? '', ['rfc4733', 'inband', 'info', 'auto'], true) ? $payload['dtmf_mode'] : 'rfc4733';
$media_encryption = in_array($payload['media_encryption'] ?? '', ['no', 'sdes', 'dtls'], true) ? $payload['media_encryption'] : 'no';
$nat_symmetric = isset($payload['nat_symmetric']) ? (int)!empty($payload['nat_symmetric']) : 1;
$direct_media = (int)!empty($payload['direct_media']);
$qualify_frequency = max(0, min(3600, (int)($payload['qualify_frequency'] ?? 60)));
$max_channels = max(0, (int)($payload['max_channels'] ?? 0));
$timers = in_array($payload['timers'] ?? '', ['yes', 'no', 'required', 'always'], true) ? $payload['timers'] : 'yes';
$language = preg_replace('/[^a-zA-Z_]/', '', trim((string)($payload['language'] ?? 'fr'))) ?: 'fr';
$rtp_keepalive = max(0, min(3600, (int)($payload['rtp_keepalive'] ?? 15)));
$t38_udptl = in_array($payload['t38_udptl'] ?? '', ['no', 'yes', 'nat'], true) ? $payload['t38_udptl'] : 'no';
$fax_detect = (int)!empty($payload['fax_detect']);
$inband_progress = (int)!empty($payload['inband_progress']);
$match_ips = trim((string)($payload['match_ips'] ?? ''));
$context = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim((string)($payload['context'] ?? 'from-external'))) ?: 'from-external';
$inbound_dest = trim((string)($payload['inbound_dest'] ?? 'terminate-hangup')) ?: 'terminate-hangup';
$outbound_prefix = preg_replace('/[^0-9*#X._\[\]\-+]/', '', trim((string)($payload['outbound_prefix'] ?? '')));
$custom_endpoint_params = trim((string)($payload['custom_endpoint_params'] ?? ''));
$custom_reg_params = trim((string)($payload['custom_reg_params'] ?? ''));
$custom_aor_params = trim((string)($payload['custom_aor_params'] ?? ''));
$moh_sound_id = max(0, (int)($payload['moh_sound_id'] ?? 0));
$cid_name_prefix = trim((string)($payload['cid_name_prefix'] ?? ''));
$dial_options = preg_replace('/[^a-zA-Z0-9(),]/', '', trim((string)($payload['dial_options'] ?? 'tTR'))) ?: 'tTR';
$enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1;
$didRoutesRaw = is_array($payload['did_routes'] ?? null) ? $payload['did_routes'] : [];
$cleanDidRoutes = ConfigGenerator::normalizeInboundRoutes(json_encode($didRoutesRaw));
$did_routes_json = json_encode($cleanDidRoutes, JSON_UNESCAPED_UNICODE);
if ($name === '') {
throw new InvalidArgumentException("Le nom du Trunk est requis.");
}
if ($registration_mode === 'receive') {
if ($username === '' || $secret === '') {
throw new InvalidArgumentException("En mode Réception (ex: passerelle Grandstream HT813, Cisco SPA...), l'Identifiant (Username) et le Mot de passe SIP sont requis pour authentifier la passerelle.");
}
} else {
if ($host === '') {
throw new InvalidArgumentException("L'hôte SIP opérateur (Serveur SIP) est requis.");
}
}
$params = [
':name' => $name,
':host' => $host,
':port' => $port,
':outbound_proxy' => $outbound_proxy,
':transport' => $transport,
':auth_mode' => $auth_mode,
':username' => $username,
':auth_username' => $auth_username,
':secret' => $secret,
':registration_mode' => $registration_mode,
':register' => $register,
':server_uri' => $server_uri,
':client_uri' => $client_uri,
':contact_user' => $contact_user,
':expiration' => $expiration,
':retry_interval' => $retry_interval,
':max_retries' => $max_retries,
':forbidden_retry_interval' => $forbidden_retry_interval,
':fatal_retry_interval' => $fatal_retry_interval,
':line_support' => $line_support,
':from_user' => $from_user,
':from_domain' => $from_domain,
':outbound_callerid' => $outbound_callerid,
':send_pai' => $send_pai,
':send_rpid' => $send_rpid,
':send_diversion' => $send_diversion,
':trust_id_inbound' => $trust_id_inbound,
':codecs' => $codecs,
':dtmf_mode' => $dtmf_mode,
':media_encryption' => $media_encryption,
':nat_symmetric' => $nat_symmetric,
':direct_media' => $direct_media,
':qualify_frequency' => $qualify_frequency,
':max_channels' => $max_channels,
':timers' => $timers,
':language' => $language,
':rtp_keepalive' => $rtp_keepalive,
':t38_udptl' => $t38_udptl,
':fax_detect' => $fax_detect,
':inband_progress' => $inband_progress,
':match_ips' => $match_ips,
':context' => $context,
':inbound_dest' => $inbound_dest,
':did_routes_json' => $did_routes_json,
':outbound_prefix' => $outbound_prefix,
':custom_endpoint_params' => $custom_endpoint_params,
':custom_reg_params' => $custom_reg_params,
':custom_aor_params' => $custom_aor_params,
':moh_sound_id' => $moh_sound_id,
':cid_name_prefix' => $cid_name_prefix,
':dial_options' => $dial_options,
':enabled' => $enabled,
];
if ($id) {
$params[':id'] = $id;
$stmt = $db->prepare('
UPDATE trunks SET
name = :name, host = :host, port = :port, outbound_proxy = :outbound_proxy,
transport = :transport, auth_mode = :auth_mode, username = :username,
auth_username = :auth_username, secret = :secret,
registration_mode = :registration_mode, register = :register,
server_uri = :server_uri, client_uri = :client_uri, contact_user = :contact_user,
expiration = :expiration, retry_interval = :retry_interval,
max_retries = :max_retries, forbidden_retry_interval = :forbidden_retry_interval,
fatal_retry_interval = :fatal_retry_interval, line_support = :line_support,
from_user = :from_user, from_domain = :from_domain, outbound_callerid = :outbound_callerid,
send_pai = :send_pai, send_rpid = :send_rpid, send_diversion = :send_diversion,
trust_id_inbound = :trust_id_inbound, codecs = :codecs, dtmf_mode = :dtmf_mode,
media_encryption = :media_encryption, nat_symmetric = :nat_symmetric,
direct_media = :direct_media, qualify_frequency = :qualify_frequency,
max_channels = :max_channels, timers = :timers, language = :language,
rtp_keepalive = :rtp_keepalive, t38_udptl = :t38_udptl, fax_detect = :fax_detect,
inband_progress = :inband_progress, match_ips = :match_ips, context = :context,
inbound_dest = :inbound_dest, did_routes_json = :did_routes_json,
outbound_prefix = :outbound_prefix, custom_endpoint_params = :custom_endpoint_params,
custom_reg_params = :custom_reg_params, custom_aor_params = :custom_aor_params,
moh_sound_id = :moh_sound_id, cid_name_prefix = :cid_name_prefix,
dial_options = :dial_options,
enabled = :enabled
WHERE id = :id
');
$stmt->execute($params);
} else {
$stmt = $db->prepare('
INSERT INTO trunks (
name, host, port, outbound_proxy, transport, auth_mode, username, auth_username, secret,
registration_mode, register, server_uri, client_uri, contact_user, expiration, retry_interval,
max_retries, forbidden_retry_interval, fatal_retry_interval, line_support,
from_user, from_domain, outbound_callerid, send_pai, send_rpid, send_diversion,
trust_id_inbound, codecs, dtmf_mode, media_encryption, nat_symmetric, direct_media,
qualify_frequency, max_channels, timers, language, rtp_keepalive, t38_udptl,
fax_detect, inband_progress, match_ips, context, inbound_dest, did_routes_json,
outbound_prefix, custom_endpoint_params, custom_reg_params, custom_aor_params, moh_sound_id,
cid_name_prefix, dial_options, enabled
) VALUES (
:name, :host, :port, :outbound_proxy, :transport, :auth_mode, :username, :auth_username, :secret,
:registration_mode, :register, :server_uri, :client_uri, :contact_user, :expiration, :retry_interval,
:max_retries, :forbidden_retry_interval, :fatal_retry_interval, :line_support,
:from_user, :from_domain, :outbound_callerid, :send_pai, :send_rpid, :send_diversion,
:trust_id_inbound, :codecs, :dtmf_mode, :media_encryption, :nat_symmetric, :direct_media,
:qualify_frequency, :max_channels, :timers, :language, :rtp_keepalive, :t38_udptl,
:fax_detect, :inband_progress, :match_ips, :context, :inbound_dest, :did_routes_json,
:outbound_prefix, :custom_endpoint_params, :custom_reg_params, :custom_aor_params, :moh_sound_id,
:cid_name_prefix, :dial_options, :enabled
)
ON CONFLICT(name) DO UPDATE SET
host = excluded.host, port = excluded.port, outbound_proxy = excluded.outbound_proxy,
transport = excluded.transport, auth_mode = excluded.auth_mode, username = excluded.username,
auth_username = excluded.auth_username, secret = excluded.secret,
registration_mode = excluded.registration_mode, register = excluded.register,
server_uri = excluded.server_uri, client_uri = excluded.client_uri, contact_user = excluded.contact_user,
expiration = excluded.expiration, retry_interval = excluded.retry_interval,
max_retries = excluded.max_retries, forbidden_retry_interval = excluded.forbidden_retry_interval,
fatal_retry_interval = excluded.fatal_retry_interval, line_support = excluded.line_support,
from_user = excluded.from_user, from_domain = excluded.from_domain, outbound_callerid = excluded.outbound_callerid,
send_pai = excluded.send_pai, send_rpid = excluded.send_rpid, send_diversion = excluded.send_diversion,
trust_id_inbound = excluded.trust_id_inbound, codecs = excluded.codecs, dtmf_mode = excluded.dtmf_mode,
media_encryption = excluded.media_encryption, nat_symmetric = excluded.nat_symmetric,
direct_media = excluded.direct_media, qualify_frequency = excluded.qualify_frequency,
max_channels = excluded.max_channels, timers = excluded.timers, language = excluded.language,
rtp_keepalive = excluded.rtp_keepalive, t38_udptl = excluded.t38_udptl, fax_detect = excluded.fax_detect,
inband_progress = excluded.inband_progress, match_ips = excluded.match_ips, context = excluded.context,
inbound_dest = excluded.inbound_dest, did_routes_json = excluded.did_routes_json,
outbound_prefix = excluded.outbound_prefix, custom_endpoint_params = excluded.custom_endpoint_params,
custom_reg_params = excluded.custom_reg_params, custom_aor_params = excluded.custom_aor_params,
moh_sound_id = excluded.moh_sound_id,
cid_name_prefix = excluded.cid_name_prefix,
dial_options = excluded.dial_options,
enabled = excluded.enabled
');
$stmt->execute($params);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
$displayHost = $host !== '' ? "{$host}:{$port}" : "Passerelle locale / FXO";
echo json_encode([
'success' => true,
'message' => "Trunk SIP « {$name} » ({$displayHost}) sauvegardé avec tous ses paramètres PJSIP.",
]);
break;
case 'delete_trunk':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM trunks WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => 'Trunk SIP supprimé.',
]);
break;
case 'save_ivr':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$name = trim((string)($payload['name'] ?? ''));
$description = trim((string)($payload['description'] ?? ''));
$audio_file = trim((string)($payload['audio_file'] ?? ''));
$timeout = max(2, min(60, (int)($payload['timeout'] ?? 10)));
$direct_dial = isset($payload['direct_dial']) ? (int)!empty($payload['direct_dial']) : 1;
$invalid_retries = max(1, min(10, (int)($payload['invalid_retries'] ?? 3)));
$invalid_sound_id = max(0, (int)($payload['invalid_sound_id'] ?? 0));
$invalid_dest = trim((string)($payload['invalid_dest'] ?? 'hangup')) ?: 'hangup';
$timeout_retries = max(1, min(10, (int)($payload['timeout_retries'] ?? 2)));
$timeout_sound_id = max(0, (int)($payload['timeout_sound_id'] ?? 0));
$timeout_dest = trim((string)($payload['timeout_dest'] ?? 'hangup')) ?: 'hangup';
$options = is_array($payload['options'] ?? null) ? $payload['options'] : [];
if ($name === '') {
throw new InvalidArgumentException("Le nom du serveur vocal (IVR) est obligatoire.");
}
if ($audio_file === '') {
$audio_file = 'demo-instruct';
}
$cleanOptions = [];
foreach ($options as $digit => $dest) {
$d = preg_replace('/[^0-9*#]/', '', (string)$digit);
$v = trim((string)$dest);
if ($d !== '' && $v !== '') {
$cleanOptions[$d] = $v;
}
}
$params = [
':name' => $name,
':desc' => $description,
':audio' => $audio_file,
':timeout' => $timeout,
':options' => json_encode($cleanOptions, JSON_UNESCAPED_UNICODE),
':direct_dial' => $direct_dial,
':invalid_retries' => $invalid_retries,
':invalid_sound_id' => $invalid_sound_id,
':invalid_dest' => $invalid_dest,
':timeout_retries' => $timeout_retries,
':timeout_sound_id' => $timeout_sound_id,
':timeout_dest' => $timeout_dest,
];
if ($id) {
$params[':id'] = $id;
$stmt = $db->prepare('
UPDATE ivrs SET
name = :name,
description = :desc,
audio_file = :audio,
timeout = :timeout,
options_json = :options,
direct_dial = :direct_dial,
invalid_retries = :invalid_retries,
invalid_sound_id = :invalid_sound_id,
invalid_dest = :invalid_dest,
timeout_retries = :timeout_retries,
timeout_sound_id = :timeout_sound_id,
timeout_dest = :timeout_dest
WHERE id = :id
');
$stmt->execute($params);
} else {
$stmt = $db->prepare('
INSERT INTO ivrs (
name, description, audio_file, timeout, options_json,
direct_dial, invalid_retries, invalid_sound_id, invalid_dest,
timeout_retries, timeout_sound_id, timeout_dest
) VALUES (
:name, :desc, :audio, :timeout, :options,
:direct_dial, :invalid_retries, :invalid_sound_id, :invalid_dest,
:timeout_retries, :timeout_sound_id, :timeout_dest
)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
audio_file = excluded.audio_file,
timeout = excluded.timeout,
options_json = excluded.options_json,
direct_dial = excluded.direct_dial,
invalid_retries = excluded.invalid_retries,
invalid_sound_id = excluded.invalid_sound_id,
invalid_dest = excluded.invalid_dest,
timeout_retries = excluded.timeout_retries,
timeout_sound_id = excluded.timeout_sound_id,
timeout_dest = excluded.timeout_dest
');
$stmt->execute($params);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "SVI / IVR « {$name} » enregistré avec " . count($cleanOptions) . " touche(s) et configuré dans Asterisk.",
]);
break;
case 'delete_ivr':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM ivrs WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => 'Serveur vocal interactif supprimé.',
]);
break;
case 'save_outbound_route':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$name = trim((string)($payload['name'] ?? ''));
$priority = max(1, (int)($payload['priority'] ?? 10));
$outbound_callerid = trim((string)($payload['outbound_callerid'] ?? ''));
$override_ext_cid = (int)!empty($payload['override_ext_cid']);
$route_password = preg_replace('/[^0-9*#]/', '', trim((string)($payload['route_password'] ?? '')));
$route_type = in_array($payload['route_type'] ?? '', ['normal', 'emergency', 'intracompany'], true)
? (string)$payload['route_type']
: 'normal';
$moh_class = trim((string)($payload['moh_class'] ?? 'default')) ?: 'default';
$permission_mode = ($payload['permission_mode'] ?? 'all') === 'restricted' ? 'restricted' : 'all';
$continue_next_route = isset($payload['continue_next_route']) ? (int)!empty($payload['continue_next_route']) : 1;
$congestion_dest = trim((string)($payload['congestion_dest'] ?? 'terminate-congestion')) ?: 'terminate-congestion';
$record_mode = in_array($payload['record_mode'] ?? '', ['force', 'yes', 'dontcare', 'no', 'never'], true)
? (string)$payload['record_mode']
: 'dontcare';
$enabled = isset($payload['enabled']) ? (int)!empty($payload['enabled']) : 1;
if ($name === '') {
throw new InvalidArgumentException("Le nom de la Route Sortante est obligatoire (ex: National, Mobile, Secours, Emergency).");
}
$trunkSeqRaw = is_array($payload['trunk_sequence'] ?? null) ? $payload['trunk_sequence'] : [];
$cleanTrunks = [];
foreach ($trunkSeqRaw as $tName) {
$tName = trim((string)$tName);
if ($tName !== '' && !in_array($tName, $cleanTrunks, true)) {
$cleanTrunks[] = $tName;
}
}
if (empty($cleanTrunks)) {
throw new InvalidArgumentException("Veuillez sélectionner au moins un Trunk SIP dans la séquence de la route.");
}
$patternsRaw = is_array($payload['dial_patterns'] ?? null) ? $payload['dial_patterns'] : [];
$cleanPatterns = [];
foreach ($patternsRaw as $pat) {
if (!is_array($pat)) {
continue;
}
$mp = trim((string)($pat['match_pattern'] ?? ''));
if ($mp === '') {
continue;
}
$cleanPatterns[] = [
'prepend' => preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prepend'] ?? ''))),
'prefix' => preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prefix'] ?? ''))),
'match_pattern' => $mp,
'callerid' => trim((string)($pat['callerid'] ?? '')),
];
}
if (empty($cleanPatterns)) {
throw new InvalidArgumentException("Ajoutez au moins un masque de numérotation (Dial Pattern), par exemple 0[1-59]XXXXXXXX ou X.");
}
$allowGrpsRaw = is_array($payload['allowed_groups'] ?? null) ? $payload['allowed_groups'] : [];
$cleanGrps = array_values(array_unique(array_filter(array_map(fn($x) => trim((string)$x), $allowGrpsRaw))));
$allowExtsRaw = is_array($payload['allowed_extensions'] ?? null) ? $payload['allowed_extensions'] : [];
$cleanExts = array_values(array_unique(array_filter(array_map(fn($x) => trim((string)$x), $allowExtsRaw))));
$params = [
':priority' => $priority,
':name' => $name,
':cid' => $outbound_callerid,
':override_cid' => $override_ext_cid,
':pwd' => $route_password,
':rtype' => $route_type,
':moh' => $moh_class,
':trunks' => json_encode($cleanTrunks),
':patterns' => json_encode($cleanPatterns),
':pmode' => $permission_mode,
':agroups' => json_encode($cleanGrps),
':aexts' => json_encode($cleanExts),
':cont' => $continue_next_route,
':cdest' => $congestion_dest,
':rec' => $record_mode,
':enabled' => $enabled,
];
if ($id) {
$params[':id'] = $id;
$stmt = $db->prepare('
UPDATE outbound_routes SET
priority = :priority,
name = :name,
outbound_callerid = :cid,
override_ext_cid = :override_cid,
route_password = :pwd,
route_type = :rtype,
moh_class = :moh,
trunk_sequence_json = :trunks,
dial_patterns_json = :patterns,
permission_mode = :pmode,
allowed_groups_json = :agroups,
allowed_extensions_json = :aexts,
continue_next_route = :cont,
congestion_dest = :cdest,
record_mode = :rec,
enabled = :enabled
WHERE id = :id
');
$stmt->execute($params);
} else {
$stmt = $db->prepare('
INSERT INTO outbound_routes (
priority, name, outbound_callerid, override_ext_cid, route_password,
route_type, moh_class, trunk_sequence_json, dial_patterns_json,
permission_mode, allowed_groups_json, allowed_extensions_json,
continue_next_route, congestion_dest, record_mode, enabled
) VALUES (
:priority, :name, :cid, :override_cid, :pwd,
:rtype, :moh, :trunks, :patterns,
:pmode, :agroups, :aexts,
:cont, :cdest, :rec, :enabled
)
');
$stmt->execute($params);
}
echo json_encode([
'success' => true,
'message' => "Route Sortante « {$name} » (Priorité #{$priority}) enregistrée avec " . count($cleanTrunks) . " trunk(s) et " . count($cleanPatterns) . " masque(s).",
]);
break;
case 'delete_outbound_route':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM outbound_routes WHERE id = :id')->execute([':id' => $id]);
echo json_encode([
'success' => true,
'message' => 'Route Sortante supprimée.',
]);
break;
case 'reorder_outbound_route':
$id = (int)($payload['id'] ?? 0);
$direction = ($payload['direction'] ?? 'up') === 'down' ? 'down' : 'up';
$routes = $db->query('SELECT id, priority FROM outbound_routes ORDER BY priority ASC, id ASC')->fetchAll();
$idx = null;
foreach ($routes as $k => $r) {
if ((int)$r['id'] === $id) {
$idx = $k;
break;
}
}
if ($idx !== null) {
$swapIdx = $direction === 'up' ? $idx - 1 : $idx + 1;
if (isset($routes[$swapIdx])) {
$tmp = $routes[$idx];
$routes[$idx] = $routes[$swapIdx];
$routes[$swapIdx] = $tmp;
}
$upd = $db->prepare('UPDATE outbound_routes SET priority = :p WHERE id = :id');
foreach ($routes as $newOrder => $r) {
$upd->execute([':p' => $newOrder + 1, ':id' => (int)$r['id']]);
}
}
echo json_encode([
'success' => true,
'message' => 'Ordre de priorité des Routes Sortantes mis à jour.',
]);
break;
case 'save_company_branding':
$company_name = trim((string)($payload['company_name'] ?? ''));
$company_logo = trim((string)($payload['company_logo'] ?? ''));
if ($company_logo !== '' && !preg_match('/^data:image\/(png|jpeg|jpg|webp|svg\+xml|gif);base64,/i', $company_logo)) {
throw new InvalidArgumentException("Format d'image invalide (utilisez PNG, SVG, JPG ou WebP).");
}
if (strlen($company_logo) > 2 * 1024 * 1024) {
throw new InvalidArgumentException("Le logo est trop volumineux (max 1.5 Mo).");
}
if (array_key_exists('company_name', $payload) && array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name, ':cl' => $company_logo]);
} elseif (array_key_exists('company_logo', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_logo = :cl, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cl' => $company_logo]);
} elseif (array_key_exists('company_name', $payload)) {
$stmt = $db->prepare('UPDATE settings SET company_name = :cn, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':cn' => $company_name]);
}
echo json_encode([
'success' => true,
'message' => $company_logo === '' && !array_key_exists('company_name', $payload)
? 'Logo entreprise réinitialisé.'
: 'Logo et identité entreprise mis à jour sur NexusPBX !',
]);
break;
case 'save_settings':
$domain = trim((string)($payload['domain'] ?? 'pbx.local'));
$company_name = trim((string)($payload['company_name'] ?? ''));
$external_ip = trim((string)($payload['external_ip'] ?? ''));
$local_net = trim((string)($payload['local_net'] ?? '192.168.1.0/24'));
$udp_port = max(1024, min(65535, (int)($payload['udp_port'] ?? 5060)));
$tls_port = max(1024, min(65535, (int)($payload['tls_port'] ?? 5061)));
$letsencrypt_domain = trim((string)($payload['letsencrypt_domain'] ?? ''));
$ami_host = trim((string)($payload['ami_host'] ?? '127.0.0.1'));
$ami_port = max(1, min(65535, (int)($payload['ami_port'] ?? 5038)));
$ami_user = trim((string)($payload['ami_user'] ?? 'pbxgui'));
$ami_secret = trim((string)($payload['ami_secret'] ?? ''));
$wizard_completed = isset($payload['wizard_completed']) ? (int)!empty($payload['wizard_completed']) : 1;
$tls_verify_client = isset($payload['tls_verify_client']) ? (int)!empty($payload['tls_verify_client']) : 0;
$system_language = in_array(trim((string)($payload['system_language'] ?? 'fr')), ['fr', 'en'], true) ? trim((string)$payload['system_language']) : 'fr';
if ($ami_secret === '') {
throw new InvalidArgumentException("Le mot de passe AMI ne peut pas être vide.");
}
$stmt = $db->prepare('
UPDATE settings
SET domain = :domain,
company_name = :company_name,
external_ip = :external_ip,
local_net = :local_net,
udp_port = :udp_port,
tls_port = :tls_port,
letsencrypt_domain = :le_domain,
ami_host = :ami_host,
ami_port = :ami_port,
ami_user = :ami_user,
ami_secret = :ami_secret,
wizard_completed = :wiz,
tls_verify_client = :tvc,
system_language = :syslang,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
');
$stmt->execute([
':domain' => $domain,
':company_name' => $company_name,
':external_ip' => $external_ip,
':local_net' => $local_net,
':udp_port' => $udp_port,
':tls_port' => $tls_port,
':le_domain' => $letsencrypt_domain,
':ami_host' => $ami_host,
':ami_port' => $ami_port,
':ami_user' => $ami_user,
':ami_secret' => $ami_secret,
':wiz' => $wizard_completed,
':tvc' => $tls_verify_client,
':syslang' => $system_language,
]);
SystemChecker::ensureAsteriskIncludes();
if (!empty($payload['new_admin_password'])) {
$newPass = (string)$payload['new_admin_password'];
if (strlen($newPass) < 6) {
throw new InvalidArgumentException("Le nouveau mot de passe administrateur doit faire au moins 6 caractères.");
}
Auth::updatePassword($db, Auth::getUsername(), $newPass);
}
echo json_encode([
'success' => true,
'message' => 'Paramètres globaux enregistrés dans SQLite.',
]);
break;
case 'test_ami':
$ami = AmiClient::fromDatabase($db);
$result = $ami->testConnection();
$pjsip = $ami->runCommand('pjsip show endpoints');
echo json_encode([
'success' => true,
'message' => 'Liaison AMI opérationnelle : ' . ($result['version'] ?: $result['banner']),
'version' => $result['version'],
'endpoints' => $pjsip,
]);
break;
case 'apply_config':
$writeStats = $generator->writeAllConfigs();
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
echo json_encode([
'success' => true,
'message' => 'Configurations écrites, pile PJSIP rechargée et Re-Register SIP envoyé à tous les opérateurs !',
'details' => [
'files' => $writeStats,
'reload' => $reloadInfo,
],
]);
break;
case 'force_register_trunk':
$regName = trim((string)($payload['reg_name'] ?? '*all')) ?: '*all';
if ($regName !== '*all' && !preg_match('/^[a-zA-Z0-9_-]+$/', $regName)) {
throw new InvalidArgumentException("Identifiant de registration invalide.");
}
$ami = AmiClient::fromDatabase($db);
$out = $ami->forceRegister($regName);
echo json_encode([
'success' => true,
'message' => "Requête SIP REGISTER envoyée immédiatement ({$regName}) ! NexusPBX a repris la ligne opérateur.",
'output' => $out,
]);
break;
case 'save_custom_cert':
$name = trim((string)($payload['name'] ?? ($_POST['name'] ?? '')));
$certPem = trim((string)($payload['cert_pem'] ?? ($_POST['cert_pem'] ?? '')));
$keyPem = trim((string)($payload['key_pem'] ?? ($_POST['key_pem'] ?? '')));
$chainPem = trim((string)($payload['chain_pem'] ?? ($_POST['chain_pem'] ?? '')));
// Support upload direct de fichiers .crt, .pem, .key
if (!empty($_FILES['cert_file']['tmp_name']) && is_uploaded_file($_FILES['cert_file']['tmp_name'])) {
$certPem = (string)file_get_contents($_FILES['cert_file']['tmp_name']);
}
if (!empty($_FILES['key_file']['tmp_name']) && is_uploaded_file($_FILES['key_file']['tmp_name'])) {
$keyPem = (string)file_get_contents($_FILES['key_file']['tmp_name']);
}
if (!empty($_FILES['chain_file']['tmp_name']) && is_uploaded_file($_FILES['chain_file']['tmp_name'])) {
$chainPem = (string)file_get_contents($_FILES['chain_file']['tmp_name']);
}
$makeDefault = !empty($payload['is_default']) || (!empty($_POST['is_default']) && (string)$_POST['is_default'] === '1');
$cert = CertificateManager::saveCustomCert($db, $name, $certPem, $keyPem, $chainPem, $makeDefault);
if ($makeDefault) {
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable) {}
exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true');
exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true');
}
echo json_encode([
'success' => true,
'message' => 'Certificat SSL/TLS importé avec succès ! Pairage de clé vérifié.',
'cert' => $cert,
]);
break;
case 'request_letsencrypt_cert':
$name = trim((string)($payload['name'] ?? ''));
$domain = trim((string)($payload['domain'] ?? ''));
$email = trim((string)($payload['email'] ?? ''));
$makeDefault = !empty($payload['is_default']);
$staging = !empty($payload['staging']);
$cert = CertificateManager::requestLetsEncrypt($db, $name, $domain, $email, $makeDefault, $staging);
if ($makeDefault) {
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable) {}
exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true');
exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true');
}
echo json_encode([
'success' => true,
'message' => "Certificat Let's Encrypt émis avec succès pour {$domain} !",
'cert' => $cert,
]);
break;
case 'set_default_cert':
$id = (int)($payload['id'] ?? 0);
if ($id <= 0) {
throw new InvalidArgumentException("ID de certificat manquant.");
}
CertificateManager::setDefault($db, $id);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable) {}
exec('sudo /usr/bin/systemctl restart asterisk 2>/dev/null || true');
exec('sudo /usr/bin/systemctl reload nginx 2>/dev/null || true');
echo json_encode([
'success' => true,
'message' => 'Certificat actif défini par défaut pour le transport PJSIP TLS (5061) et SRTP.',
]);
break;
case 'delete_cert':
$id = (int)($payload['id'] ?? 0);
if ($id <= 0) {
throw new InvalidArgumentException("ID de certificat manquant.");
}
CertificateManager::delete($db, $id);
echo json_encode([
'success' => true,
'message' => 'Certificat supprimé avec succès.',
]);
break;
case 'renew_cert':
$id = (int)($payload['id'] ?? 0);
if ($id <= 0) {
throw new InvalidArgumentException("ID de certificat manquant.");
}
$cert = CertificateManager::renew($db, $id);
if ((int)$cert['is_default'] === 1) {
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable) {}
}
echo json_encode([
'success' => true,
'message' => "Certificat renouvelé avec succès (expire le {$cert['valid_to']}) !",
'cert' => $cert,
]);
break;
case 'set_tls_security':
$tlsVerifyClient = !empty($payload['tls_verify_client']) ? 1 : 0;
$stmt = $db->prepare('UPDATE settings SET tls_verify_client = :tvc, updated_at = CURRENT_TIMESTAMP WHERE id = 1');
$stmt->execute([':tvc' => $tlsVerifyClient]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable) {}
echo json_encode([
'success' => true,
'message' => $tlsVerifyClient === 1
? 'Mode Mutual TLS (mTLS) activé : certificat client X.509 désormais requis sur le port PJSIP TLS 5061.'
: 'Mode Mutual TLS désactivé : vérification standard par mot de passe SIP dans le tunnel TLS.',
]);
break;
case 'detect_external_ip':
$detectedIp = '';
$sources = [
'https://api.ipify.org',
'https://ifconfig.me/ip',
'https://icanhazip.com',
];
foreach ($sources as $url) {
$ctx = stream_context_create(['http' => ['timeout' => 3, 'user_agent' => 'curl/7.88.1']]);
$res = @file_get_contents($url, false, $ctx);
if ($res) {
$candidate = trim($res);
if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$detectedIp = $candidate;
break;
}
}
}
echo json_encode([
'success' => true,
'ip' => $detectedIp,
]);
break;
case 'save_sip_settings':
$externalIp = trim((string)($payload['external_ip'] ?? ''));
$externalUdpPort = max(0, min(65535, (int)($payload['external_udp_port'] ?? 0)));
$externalTlsPort = max(0, min(65535, (int)($payload['external_tls_port'] ?? 0)));
$udpPort = max(1024, min(65535, (int)($payload['udp_port'] ?? 5060)));
$tlsPort = max(1024, min(65535, (int)($payload['tls_port'] ?? 5061)));
$rtpStart = max(1024, min(65530, (int)($payload['rtp_start'] ?? 10000)));
$rtpEnd = max($rtpStart + 4, min(65535, (int)($payload['rtp_end'] ?? 20000)));
$rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0;
$rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0;
$rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30));
$rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15));
$allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0;
// Réseaux locaux (array)
$localNets = $payload['local_networks'] ?? [];
if (!is_array($localNets)) {
$localNets = array_filter(array_map('trim', explode("\n", (string)$localNets)));
}
$cleanNets = [];
foreach ($localNets as $net) {
$net = trim((string)$net);
if ($net !== '') {
$cleanNets[] = $net;
}
}
if (empty($cleanNets)) {
$cleanNets = ['10.10.0.0/16', '192.168.1.0/24'];
}
$localNetworksJson = json_encode(array_values(array_unique($cleanNets)));
$stmt = $db->prepare('
UPDATE settings
SET external_ip = :ext_ip,
external_udp_port = :ext_udp,
external_tls_port = :ext_tls,
udp_port = :udp_port,
tls_port = :tls_port,
local_networks_json = :local_nets,
local_net = :first_net,
rtp_start = :rtp_start,
rtp_end = :rtp_end,
rtp_checksums = :rtp_checksums,
rtp_strict = :rtp_strict,
rtp_timeout = :rtp_timeout,
rtp_keepalive = :rtp_keepalive,
allow_anonymous_sip = :anon,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
');
$stmt->execute([
':ext_ip' => $externalIp,
':ext_udp' => $externalUdpPort,
':ext_tls' => $externalTlsPort,
':udp_port' => $udpPort,
':tls_port' => $tlsPort,
':local_nets' => $localNetworksJson,
':first_net' => $cleanNets[0] ?? '192.168.1.0/24',
':rtp_start' => $rtpStart,
':rtp_end' => $rtpEnd,
':rtp_checksums' => $rtpChecksums,
':rtp_strict' => $rtpStrict,
':rtp_timeout' => $rtpTimeout,
':rtp_keepalive' => $rtpKeepalive,
':anon' => $allowAnonymousSip,
]);
$writeStats = $generator->writeAllConfigs();
$reloadInfo = [];
try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {
$reloadInfo['error'] = $e->getMessage();
}
echo json_encode([
'success' => true,
'message' => 'Paramètres SIP & NAT enregistrés et appliqués à Asterisk PJSIP & RTP !',
'details' => [
'files' => $writeStats,
'reload' => $reloadInfo,
],
]);
break;
case 'toggle_feature_code':
$codeId = (int)($payload['id'] ?? 0);
$enabled = !empty($payload['enabled']) ? 1 : 0;
if ($codeId <= 0) {
throw new InvalidArgumentException("ID de code de fonction manquant.");
}
$stmt = $db->prepare('UPDATE feature_codes SET enabled = :en, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$stmt->execute([':en' => $enabled, ':id' => $codeId]);
$writeStats = $generator->writeAllConfigs();
$reloadInfo = [];
try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {
$reloadInfo['error'] = $e->getMessage();
}
echo json_encode([
'success' => true,
'message' => 'Code de fonction ' . ($enabled ? 'activé' : 'désactivé') . ' et dialplan rechargé !',
'details' => ['files' => $writeStats, 'reload' => $reloadInfo],
]);
break;
case 'update_feature_code':
$codeId = (int)($payload['id'] ?? 0);
$newCode = trim((string)($payload['code'] ?? ''));
if ($codeId <= 0 || $newCode === '') {
throw new InvalidArgumentException("Code ou ID invalide.");
}
$stmt = $db->prepare('UPDATE feature_codes SET code = :code, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$stmt->execute([':code' => $newCode, ':id' => $codeId]);
$writeStats = $generator->writeAllConfigs();
$reloadInfo = [];
try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {
$reloadInfo['error'] = $e->getMessage();
}
echo json_encode([
'success' => true,
'message' => "Code de fonction mis à jour ({$newCode}) et dialplan rechargé !",
'details' => ['files' => $writeStats, 'reload' => $reloadInfo],
]);
break;
case 'reset_feature_codes':
$db->exec('UPDATE feature_codes SET code = default_code, enabled = 1, updated_at = CURRENT_TIMESTAMP');
$writeStats = $generator->writeAllConfigs();
$reloadInfo = [];
try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {
$reloadInfo['error'] = $e->getMessage();
}
echo json_encode([
'success' => true,
'message' => 'Tous les codes de fonctions ont été réinitialisés aux valeurs standard FreePBX/Asterisk !',
'details' => ['files' => $writeStats, 'reload' => $reloadInfo],
]);
break;
case 'get_backup_data':
$backupSettings = BackupManager::getBackupSettings();
$backups = BackupManager::listBackups();
echo json_encode([
'success' => true,
'settings' => $backupSettings,
'backups' => $backups,
]);
break;
case 'save_backup_settings':
BackupManager::saveBackupSettings($payload);
echo json_encode([
'success' => true,
'message' => 'Paramètres de sauvegarde enregistrés avec succès !',
'settings' => BackupManager::getBackupSettings(),
]);
break;
case 'test_ssh_backup':
$testRes = BackupManager::testSshConnection($payload);
echo json_encode($testRes);
break;
case 'create_backup':
$res = BackupManager::createBackup('manual');
echo json_encode([
'success' => true,
'message' => 'Sauvegarde complète du système générée avec succès !',
'data' => $res,
]);
break;
case 'list_backups':
$backups = BackupManager::listBackups();
echo json_encode([
'success' => true,
'backups' => $backups,
]);
break;
case 'delete_backup':
$filename = trim((string)($payload['filename'] ?? ''));
if ($filename === '') {
throw new InvalidArgumentException("Nom de fichier manquant.");
}
$deleted = BackupManager::deleteBackup($filename);
echo json_encode([
'success' => $deleted,
'message' => $deleted ? 'Sauvegarde supprimée avec succès.' : 'Impossible de supprimer cette sauvegarde.',
]);
break;
case 'restore_backup':
$filename = trim((string)($payload['filename'] ?? ''));
if ($filename === '') {
throw new InvalidArgumentException("Nom de fichier manquant.");
}
$localPath = BackupManager::BACKUP_DIR . '/' . basename($filename);
$tempDownload = false;
if (!file_exists($localPath)) {
$bSettings = BackupManager::getBackupSettings();
if ($bSettings['backup_storage_type'] !== 'ssh' || empty($bSettings['backup_ssh_host'])) {
throw new RuntimeException("Sauvegarde introuvable localement et aucun accès SSH configuré.");
}
$remoteFile = rtrim($bSettings['backup_ssh_remote_path'], '/') . '/' . basename($filename);
$localPath = '/tmp/restore_' . uniqid() . '_' . basename($filename);
// Utiliser la méthode de téléchargement depuis SSH
$ref = new \ReflectionClass(BackupManager::class);
$fetchMethod = $ref->getMethod('fetchFromSsh');
$fetchMethod->setAccessible(true);
$fRes = $fetchMethod->invoke(null, $remoteFile, $localPath, $bSettings);
if (!$fRes['success'] || !file_exists($localPath)) {
throw new RuntimeException("Impossible de rapatrier l'archive distante : " . ($fRes['message'] ?? 'Erreur SCP'));
}
$tempDownload = true;
}
try {
$res = BackupManager::restoreBackup($localPath);
echo json_encode($res);
} finally {
if ($tempDownload && file_exists($localPath)) {
@unlink($localPath);
}
}
break;
case 'upload_restore_backup':
if (empty($_FILES['backup_file']['tmp_name']) || !is_uploaded_file($_FILES['backup_file']['tmp_name'])) {
$errCode = $_FILES['backup_file']['error'] ?? null;
$errMessages = [
UPLOAD_ERR_INI_SIZE => "L'archive dépasse la taille maximale autorisée par le serveur (upload_max_filesize).",
UPLOAD_ERR_FORM_SIZE => "L'archive dépasse la taille maximale spécifiée dans le formulaire.",
UPLOAD_ERR_PARTIAL => "Le fichier de sauvegarde n'a été que partiellement téléversé.",
UPLOAD_ERR_NO_FILE => "Aucun fichier de sauvegarde n'a été sélectionné.",
UPLOAD_ERR_NO_TMP_DIR => "Dossier temporaire manquant sur le serveur.",
UPLOAD_ERR_CANT_WRITE => "Échec d'écriture du fichier temporaire sur le disque.",
UPLOAD_ERR_EXTENSION => "Une extension PHP a arrêté le téléversement de l'archive.",
];
$msg = $errMessages[$errCode] ?? "Aucun fichier de sauvegarde valide n'a été téléversé (erreur de transfert ou fichier trop volumineux).";
throw new InvalidArgumentException($msg);
}
$origName = $_FILES['backup_file']['name'] ?? 'backup.tar.gz';
if (!str_ends_with(strtolower($origName), '.tar.gz') && !str_ends_with(strtolower($origName), '.tgz')) {
throw new InvalidArgumentException("Format d'archive non supporté. Veuillez sélectionner un fichier .tar.gz");
}
$tmpUploadPath = '/tmp/upload_restore_' . uniqid() . '.tar.gz';
if (!move_uploaded_file($_FILES['backup_file']['tmp_name'], $tmpUploadPath)) {
throw new RuntimeException("Échec du déplacement du fichier téléversé.");
}
try {
$res = BackupManager::restoreBackup($tmpUploadPath);
echo json_encode($res);
} finally {
if (file_exists($tmpUploadPath)) {
@unlink($tmpUploadPath);
}
}
break;
case 'download_backup':
$filename = trim((string)($_GET['filename'] ?? ''));
if ($filename === '') {
http_response_code(400);
die("Paramètre filename manquant.");
}
BackupManager::downloadBackup($filename);
exit;
case 'get_firewall_status':
$status = FirewallManager::getStatus();
echo json_encode([
'success' => true,
'data' => $status,
]);
break;
case 'save_firewall_settings':
$res = FirewallManager::saveSettings($payload);
echo json_encode($res);
break;
case 'unban_ip':
$ip = trim((string)($payload['ip'] ?? ''));
$jail = trim((string)($payload['jail'] ?? ''));
$res = FirewallManager::unbanIp($ip, $jail);
echo json_encode($res);
break;
case 'ban_ip':
$ip = trim((string)($payload['ip'] ?? ''));
$jail = trim((string)($payload['jail'] ?? 'asterisk'));
$res = FirewallManager::banIp($ip, $jail);
echo json_encode($res);
break;
case 'check_update':
$appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
$localVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: ['version' => '1.3.8', 'changelog' => []];
$currentVer = $localVerJson['version'] ?? '1.3.8';
$remoteVer = $currentVer;
$remoteChangelog = [];
$remoteDate = '';
// 1. Interrogation directe via HTTP Raw avec cache-busting strict (bypass OpenResty/Cloudflare max-age)
$ts = time();
$rawHttp = @shell_exec("curl -k -s -H 'Cache-Control: no-cache, no-store' -H 'Pragma: no-cache' --connect-timeout 4 \"https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}\" 2>/dev/null");
if (empty($rawHttp)) {
$ctx = stream_context_create([
'http' => [
'timeout' => 4,
'header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"
],
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]
]);
$rawHttp = @file_get_contents("https://git.sorcier-research.com/sorcier/NexusPBX/raw/branch/main/version.json?_t={$ts}", false, $ctx);
}
if (!empty($rawHttp)) {
$remoteData = json_decode((string)$rawHttp, true);
if (is_array($remoteData) && !empty($remoteData['version'])) {
$remoteVer = (string)$remoteData['version'];
$remoteChangelog = $remoteData['changelog'] ?? [];
$remoteDate = $remoteData['release_date'] ?? '';
}
} else {
// 2. Fallback local Git si indisponibilité du serveur HTTP
exec("git --git-dir={$appDir}/.git fetch origin main --quiet 2>/dev/null", $output, $code);
$remoteJsonRaw = shell_exec("git --git-dir={$appDir}/.git show origin/main:version.json 2>/dev/null");
if ($remoteJsonRaw) {
$remoteData = json_decode($remoteJsonRaw, true);
if (is_array($remoteData) && !empty($remoteData['version'])) {
$remoteVer = (string)$remoteData['version'];
$remoteChangelog = $remoteData['changelog'] ?? [];
$remoteDate = $remoteData['release_date'] ?? '';
}
}
}
$hasUpdate = version_compare($remoteVer, $currentVer, '>');
echo json_encode([
'success' => true,
'current_version' => $currentVer,
'latest_version' => $remoteVer,
'has_update' => $hasUpdate,
'changelog' => $hasUpdate ? $remoteChangelog : ($localVerJson['changelog'] ?? []),
'release_date' => $remoteDate ?: ($localVerJson['release_date'] ?? ''),
]);
break;
case 'run_update':
$output = [];
$code = 0;
exec("sudo /var/www/nexus-pbx/scripts/update.sh 2>&1", $output, $code);
$outputStr = implode("\n", $output);
$appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
if (function_exists('opcache_reset')) {
@opcache_reset();
}
$newVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: ['version' => '1.4.3'];
while (ob_get_level() > 0) {
@ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
echo json_encode([
'success' => ($code === 0),
'new_version' => $newVerJson['version'] ?? '1.4.3',
'output' => $outputStr,
'message' => ($code === 0) ? "Mise à jour réussie !" : "Erreur lors de la mise à jour",
]);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
break;
default:
http_response_code(400);
echo json_encode([
'success' => false,
'message' => "Action AJAX inconnue : {$action}",
]);
}
} catch (Throwable $e) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
]);
}