2388 lines
116 KiB
PHP
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.8';
|
|
|
|
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/systemctl restart fail2ban 2>&1 || 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(),
|
|
]);
|
|
}
|
|
|