feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)

This commit is contained in:
sorcier committed 2026-11-11 18:47:25 +01:00
1 parent 7922e338e3
commit 72e43f1a9a
18 files changed
+3499 -62

No files matched your search

+787
View File
@@ -0,0 +1,787 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use InvalidArgumentException;
use RuntimeException;
class UcpManager
{
public const VOICEMAIL_SPOOL_BASE = '/var/spool/asterisk/voicemail/default';
// =========================================================================
// 1. GESTION ADMIN DES UTILISATEURS UCP
// =========================================================================
public static function listUsers(PDO $db): array
{
$users = $db->query('
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip, created_at, updated_at
FROM ucp_users
ORDER BY username ASC
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$extRows = $db->query('SELECT extension, name, enabled FROM extensions ORDER BY extension ASC')->fetchAll(PDO::FETCH_ASSOC) ?: [];
$extMap = [];
foreach ($extRows as $e) {
$extMap[(string)$e['extension']] = [
'extension' => (string)$e['extension'],
'name' => (string)$e['name'],
'enabled' => (int)$e['enabled'],
];
}
foreach ($users as &$u) {
$exts = json_decode((string)($u['extensions_json'] ?? '[]'), true);
$u['extensions'] = is_array($exts) ? array_values(array_map('strval', $exts)) : [];
$u['extensions_meta'] = array_values(array_filter(array_map(
fn(string $num) => $extMap[$num] ?? ['extension' => $num, 'name' => 'Poste inconnu', 'enabled' => 0],
$u['extensions']
)));
$u['enabled'] = (int)$u['enabled'];
unset($u['extensions_json']);
}
unset($u);
return $users;
}
public static function saveUser(PDO $db, array $data): array
{
$id = isset($data['id']) && (int)$data['id'] > 0 ? (int)$data['id'] : null;
$username = trim((string)($data['username'] ?? ''));
$displayName = trim((string)($data['display_name'] ?? ''));
$email = trim((string)($data['email'] ?? ''));
$password = (string)($data['password'] ?? '');
$enabled = isset($data['enabled']) ? (!empty($data['enabled']) ? 1 : 0) : 1;
$rawExts = is_array($data['extensions'] ?? null) ? $data['extensions'] : [];
if (!preg_match('/^[a-zA-Z0-9._@\-]{2,64}$/', $username)) {
throw new InvalidArgumentException("L'identifiant UCP doit contenir 2 à 64 caractères (lettres, chiffres, . _ @ -).");
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Adresse e-mail invalide.");
}
if (!$id && strlen($password) < 6) {
throw new InvalidArgumentException("Le mot de passe initial doit contenir au moins 6 caractères.");
}
if ($id && $password !== '' && strlen($password) < 6) {
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
}
// Ne conserver que des extensions valides existantes
$validExts = $db->query('SELECT extension FROM extensions')->fetchAll(PDO::FETCH_COLUMN) ?: [];
$validSet = array_map('strval', $validExts);
$cleanExts = [];
foreach ($rawExts as $ext) {
$extStr = trim((string)$ext);
if ($extStr !== '' && in_array($extStr, $validSet, true) && !in_array($extStr, $cleanExts, true)) {
$cleanExts[] = $extStr;
}
}
if (empty($cleanExts)) {
throw new InvalidArgumentException("Veuillez lier au moins un poste SIP à cet utilisateur UCP.");
}
$extJson = json_encode($cleanExts);
// Vérification d'unicité du username
$dupStmt = $db->prepare('SELECT id FROM ucp_users WHERE username = :u' . ($id ? ' AND id != :id' : ''));
$dupParams = [':u' => $username];
if ($id) {
$dupParams[':id'] = $id;
}
$dupStmt->execute($dupParams);
if ($dupStmt->fetchColumn()) {
throw new InvalidArgumentException("Un compte UCP avec l'identifiant « {$username} » existe déjà.");
}
if ($id) {
if ($password !== '') {
$stmt = $db->prepare('
UPDATE ucp_users
SET username = :u, display_name = :dn, email = :em, password_hash = :ph,
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':u' => $username,
':dn' => $displayName ?: $username,
':em' => $email,
':ph' => password_hash($password, PASSWORD_DEFAULT),
':exts' => $extJson,
':en' => $enabled,
]);
} else {
$stmt = $db->prepare('
UPDATE ucp_users
SET username = :u, display_name = :dn, email = :em,
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':u' => $username,
':dn' => $displayName ?: $username,
':em' => $email,
':exts' => $extJson,
':en' => $enabled,
]);
}
} else {
$stmt = $db->prepare('
INSERT INTO ucp_users (username, display_name, email, password_hash, extensions_json, enabled)
VALUES (:u, :dn, :em, :ph, :exts, :en)
');
$stmt->execute([
':u' => $username,
':dn' => $displayName ?: $username,
':em' => $email,
':ph' => password_hash($password, PASSWORD_DEFAULT),
':exts' => $extJson,
':en' => $enabled,
]);
}
return self::listUsers($db);
}
public static function deleteUser(PDO $db, int $id): array
{
$db->prepare('DELETE FROM ucp_users WHERE id = :id')->execute([':id' => $id]);
return self::listUsers($db);
}
// =========================================================================
// 2. ÉTAT TEMPS RÉEL DES POSTES & RENVOIS (DND / CF / CFU)
// =========================================================================
/**
* Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées.
*/
public static function getExtensionsState(PDO $db, array $allowedExts): array
{
$extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts);
if (empty($extNums)) {
return [];
}
$contactsByExt = [];
$dndMap = [];
$cfMap = [];
$cfuMap = [];
try {
$ami = AmiClient::fromDatabase($db);
// Contacts PJSIP & métadonnées AstDB
$rawContacts = $ami->runCommand('pjsip show contacts');
$rawRegMeta = $ami->runCommand('database show registrar/contact');
$contactMeta = [];
foreach (preg_split('/\r?\n/', $rawRegMeta) ?: [] as $dbLine) {
if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) {
$parsed = json_decode($dbM[2], true);
if ($parsed) {
$contactMeta[$dbM[1]] = $parsed;
}
}
}
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
$extNum = $m[1];
if (!in_array($extNum, $extNums, true)) {
continue;
}
$status = $m[4];
$rttRaw = $m[5] ?? null;
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
$meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ('sip:' . $m[2]);
$ip = '';
if (preg_match('/@([^:;>]+)/', $fullUri, $ipM)) {
$ip = $ipM[1];
}
$contactsByExt[$extNum] = [
'online' => $isAvail,
'status' => $status,
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
'ip' => $ip,
'user_agent' => (string)($meta['user_agent'] ?? ''),
];
}
}
// États DND / CF / CFU depuis AstDB
$dndMap = self::parseAstDbPrefix($ami->runCommand('database show DND'), '/DND/');
$cfMap = self::parseAstDbPrefix($ami->runCommand('database show CF'), '/CF/');
$cfuMap = self::parseAstDbPrefix($ami->runCommand('database show CFU'), '/CFU/');
} catch (\Throwable) {
// Asterisk indisponible : on renvoie les métadonnées locales sans bloquer l'interface
}
// Photo & email annuaire liés
$pbByExtId = [];
try {
$pbRows = $db->query('SELECT extension_id, email, photo_url FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL')
->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($pbRows as $p) {
$pbByExtId[(int)$p['extension_id']] = $p;
}
} catch (\Throwable) {}
$result = [];
foreach ($allowedExts as $ext) {
$num = (string)$ext['extension'];
$extId = (int)$ext['id'];
$cInfo = $contactsByExt[$num] ?? ['online' => false, 'status' => 'Unavailable', 'rtt_ms' => null, 'ip' => '', 'user_agent' => ''];
$vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0];
$pb = $pbByExtId[$extId] ?? [];
$result[] = [
'id' => $extId,
'extension' => $num,
'name' => (string)$ext['name'],
'transport' => (string)$ext['transport'],
'online' => $cInfo['online'],
'status_label' => $cInfo['status'],
'rtt_ms' => $cInfo['rtt_ms'],
'ip' => $cInfo['ip'],
'user_agent' => $cInfo['user_agent'],
'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES',
'cf' => (string)($cfMap[$num] ?? ''),
'cfu' => (string)($cfuMap[$num] ?? ''),
'voicemail_enabled' => (int)$ext['voicemail_enabled'],
'voicemail_email' => (string)($ext['voicemail_email'] ?? ''),
'vm_inbox_count' => $vmCounts['inbox'],
'vm_old_count' => $vmCounts['old'],
'profile_email' => (string)($pb['email'] ?? ''),
'photo_url' => (string)($pb['photo_url'] ?? ''),
];
}
return $result;
}
/**
* Retourne la liste des cibles de renvoi autorisées (postes internes + répondeurs vm-<ext>).
*/
public static function getForwardTargets(PDO $db): array
{
$rows = $db->query('SELECT extension, name, voicemail_enabled FROM extensions WHERE enabled = 1 ORDER BY extension ASC')
->fetchAll(PDO::FETCH_ASSOC) ?: [];
$targets = [];
foreach ($rows as $r) {
$targets[] = [
'extension' => (string)$r['extension'],
'name' => (string)$r['name'],
'voicemail_enabled' => (int)$r['voicemail_enabled'],
];
}
return $targets;
}
/**
* Applique DND, CF et CFU sur une extension autorisée via AMI + mise à jour BLF *76<ext>.
*/
public static function setCallFeatures(PDO $db, array $allowedExts, string $ext, array $features): void
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$dnd = !empty($features['dnd']);
$cf = trim((string)($features['cf'] ?? ''));
$cfu = trim((string)($features['cfu'] ?? ''));
self::validateForwardTarget($db, $ext, $cf, 'Renvoi inconditionnel (CF)');
self::validateForwardTarget($db, $ext, $cfu, 'Renvoi sur non-réponse (CFU)');
$ami = AmiClient::fromDatabase($db);
// DND + mise à jour état BLF *76<ext> en direct
if ($dnd) {
$ami->runCommand("database put DND {$ext} YES");
$ami->runCommand("devstate change Custom:DND{$ext} BUSY");
} else {
$ami->runCommand("database del DND {$ext}");
$ami->runCommand("devstate change Custom:DND{$ext} NOT_INUSE");
}
// CF (Renvoi inconditionnel)
if ($cf !== '') {
$ami->runCommand("database put CF {$ext} {$cf}");
} else {
$ami->runCommand("database del CF {$ext}");
}
// CFU (Renvoi non-réponse / indisponible)
if ($cfu !== '') {
$ami->runCommand("database put CFU {$ext} {$cfu}");
} else {
$ami->runCommand("database del CFU {$ext}");
}
}
private static function validateForwardTarget(PDO $db, string $sourceExt, string $target, string $label): void
{
if ($target === '') {
return;
}
// Cible répondeur direct vm-<ext>
if (str_starts_with($target, 'vm-')) {
$vmExt = substr($target, 3);
if (!preg_match('/^\d{2,6}$/', $vmExt)) {
throw new InvalidArgumentException("{$label} : boîte vocale cible invalide.");
}
$stmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
$stmt->execute([':ext' => $vmExt]);
if (!(int)$stmt->fetchColumn()) {
throw new InvalidArgumentException("{$label} : le répondeur du poste {$vmExt} n'est pas activé.");
}
return;
}
// Cible poste interne uniquement (sécurité anti-fraude UCP)
if (!preg_match('/^\d{2,6}$/', $target)) {
throw new InvalidArgumentException("{$label} : seuls les postes internes ou répondeurs sont autorisés.");
}
if ($target === $sourceExt) {
throw new InvalidArgumentException("{$label} : impossible de renvoyer un poste vers lui-même.");
}
$stmt = $db->prepare('SELECT id FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
$stmt->execute([':ext' => $target]);
if (!$stmt->fetchColumn()) {
throw new InvalidArgumentException("{$label} : le poste interne {$target} n'existe pas ou est désactivé.");
}
}
private static function parseAstDbPrefix(string $rawOutput, string $prefix): array
{
$map = [];
foreach (preg_split('/\r?\n/', $rawOutput) ?: [] as $line) {
$pos = strpos($line, $prefix);
if ($pos === 0 && str_contains($line, ':')) {
$rest = substr($line, strlen($prefix));
[$k, $v] = explode(':', $rest, 2);
$k = trim($k);
$v = trim($v);
if ($k !== '' && !str_contains($k, ' ')) {
$map[$k] = $v;
}
}
}
return $map;
}
// =========================================================================
// 3. MESSAGERIE VOCALE (LECTURE, TÉLÉCHARGEMENT, ARCHIVAGE, SUPPRESSION, PIN)
// =========================================================================
public static function countVoicemails(string $ext): array
{
return [
'inbox' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/INBOX/msg*.txt") ?: []),
'old' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/Old/msg*.txt") ?: []),
];
}
public static function getVoicemails(array $allowedExts, string $ext): array
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$messages = [];
foreach (['INBOX', 'Old'] as $folder) {
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
if (!is_dir($dir)) {
continue;
}
$txtFiles = glob($dir . '/msg[0-9][0-9][0-9][0-9].txt') ?: [];
foreach ($txtFiles as $txtFile) {
$msgId = basename($txtFile, '.txt');
if (!preg_match('/^msg\d{4}$/', $msgId)) {
continue;
}
$meta = self::parseVoicemailTxt($txtFile);
$audioPath = self::findVoicemailAudioFile($dir, $msgId);
if (!$audioPath) {
continue;
}
$origTime = (int)($meta['origtime'] ?? @filemtime($audioPath));
$messages[] = [
'id' => $msgId,
'extension' => $ext,
'folder' => $folder,
'is_new' => ($folder === 'INBOX'),
'callerid' => self::formatVmCallerId((string)($meta['callerid'] ?? 'Inconnu')),
'origtime' => $origTime,
'date_iso' => $origTime > 0 ? date('c', $origTime) : '',
'date_human' => $origTime > 0 ? date('d/m/Y H:i', $origTime) : '—',
'duration' => (int)($meta['duration'] ?? 0),
'size_bytes' => (int)@filesize($audioPath),
'format' => strtolower(pathinfo($audioPath, PATHINFO_EXTENSION)),
];
}
}
usort($messages, fn($a, $b) => $b['origtime'] <=> $a['origtime']);
return $messages;
}
public static function getVoicemailAudioPath(array $allowedExts, string $ext, string $folder, string $msgId): string
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$folder = self::assertValidVmFolder($folder);
$msgId = self::assertValidMsgId($msgId);
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
$path = self::findVoicemailAudioFile($dir, $msgId);
if (!$path || !is_readable($path)) {
throw new RuntimeException("Message vocal introuvable sur le disque.");
}
return $path;
}
public static function moveVoicemailFolder(PDO $db, array $allowedExts, string $ext, string $fromFolder, string $msgId): void
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$fromFolder = self::assertValidVmFolder($fromFolder);
$msgId = self::assertValidMsgId($msgId);
$toFolder = ($fromFolder === 'INBOX') ? 'Old' : 'INBOX';
$srcDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$fromFolder}";
$dstDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$toFolder}";
if (!is_dir($dstDir)) {
@mkdir($dstDir, 0775, true);
@chown($dstDir, 'asterisk');
@chgrp($dstDir, 'asterisk');
}
$srcFiles = glob("{$srcDir}/{$msgId}.*") ?: [];
if (empty($srcFiles)) {
throw new RuntimeException("Message vocal {$msgId} introuvable dans {$fromFolder}.");
}
// Trouver le prochain index msgNNNN libre dans le dossier cible
$nextIdx = 0;
while (
!empty(glob(sprintf("{$dstDir}/msg%04d.*", $nextIdx)))
&& $nextIdx < 9999
) {
$nextIdx++;
}
$newMsgId = sprintf('msg%04d', $nextIdx);
foreach ($srcFiles as $srcFile) {
$fileExt = pathinfo($srcFile, PATHINFO_EXTENSION);
$dstFile = "{$dstDir}/{$newMsgId}.{$fileExt}";
if (!@rename($srcFile, $dstFile)) {
if (@copy($srcFile, $dstFile)) {
@unlink($srcFile);
} else {
throw new RuntimeException("Impossible de déplacer le fichier vocal ({$fileExt}).");
}
}
@chown($dstFile, 'asterisk');
@chgrp($dstFile, 'asterisk');
@chmod($dstFile, 0664);
}
self::reloadVoicemailQuiet($db);
}
public static function deleteVoicemail(PDO $db, array $allowedExts, string $ext, string $folder, string $msgId): void
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$folder = self::assertValidVmFolder($folder);
$msgId = self::assertValidMsgId($msgId);
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
$files = glob("{$dir}/{$msgId}.*") ?: [];
if (empty($files)) {
throw new RuntimeException("Message vocal {$msgId} introuvable.");
}
foreach ($files as $f) {
@unlink($f);
}
self::reloadVoicemailQuiet($db);
}
public static function updateVoicemailPrefs(PDO $db, array $allowedExts, string $ext, string $newPin, string $vmEmail): void
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$cleanPin = trim($newPin);
$cleanEmail = trim($vmEmail);
if ($cleanPin !== '' && !preg_match('/^\d{4,12}$/', $cleanPin)) {
throw new InvalidArgumentException("Le code PIN messagerie doit contenir entre 4 et 12 chiffres.");
}
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Adresse e-mail de notification invalide.");
}
if ($cleanPin !== '') {
$db->prepare('UPDATE extensions SET voicemail_pin = :pin, voicemail_email = :em, force_pin_change = 0 WHERE extension = :ext')
->execute([':pin' => $cleanPin, ':em' => $cleanEmail, ':ext' => $ext]);
$secFile = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/secret.conf";
if (is_dir(dirname($secFile))) {
@file_put_contents($secFile, "[general]\npassword={$cleanPin}\n");
@chown($secFile, 'asterisk');
@chgrp($secFile, 'asterisk');
@chmod($secFile, 0664);
}
} else {
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE extension = :ext')
->execute([':em' => $cleanEmail, ':ext' => $ext]);
}
// Régénération ciblée de voicemail.custom.conf + rechargement à chaud
$gen = new ConfigGenerator($db);
$vmContent = $gen->generateVoicemailConf();
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
self::reloadVoicemailQuiet($db);
}
private static function reloadVoicemailQuiet(PDO $db): void
{
try {
AmiClient::fromDatabase($db)->runCommand('voicemail reload');
} catch (\Throwable) {}
}
private static function parseVoicemailTxt(string $path): array
{
$data = [];
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
if (str_starts_with($line, ';') || str_starts_with($line, '[')) {
continue;
}
if (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$data[trim(strtolower($k))] = trim($v);
}
}
return $data;
}
private static function findVoicemailAudioFile(string $dir, string $msgId): ?string
{
foreach (['wav', 'WAV', 'gsm'] as $audioExt) {
$candidate = "{$dir}/{$msgId}.{$audioExt}";
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private static function formatVmCallerId(string $raw): string
{
$clean = trim($raw, " \t\n\r\0\x0B\"");
if ($clean === '' || $clean === '<>') {
return 'Appelant inconnu';
}
return $clean;
}
// =========================================================================
// 4. JOURNAL D'APPELS PERSONNEL & ANNUAIRE / PROFIL
// =========================================================================
public static function getPersonalCdr(PDO $db, array $allowedExts, array $filters = []): array
{
$extSet = array_map(fn($e) => (string)$e['extension'], $allowedExts);
if (empty($extSet)) {
return ['calls' => [], 'stats' => ['total' => 0, 'answered' => 0, 'missed' => 0]];
}
$res = CdrManager::getCalls($db, [
'limit' => 500,
'direction' => (string)($filters['direction'] ?? 'all'),
'disposition' => (string)($filters['disposition'] ?? 'all'),
'period' => (string)($filters['period'] ?? 'all'),
'search' => (string)($filters['search'] ?? ''),
]);
$filtered = [];
$answered = 0;
$missed = 0;
foreach ($res['calls'] as $call) {
$srcMatch = in_array((string)($call['src'] ?? ''), $extSet, true);
$dstMatch = in_array((string)($call['dst'] ?? ''), $extSet, true);
if (!$srcMatch && !$dstMatch) {
continue;
}
if (($call['disposition'] ?? '') === 'ANSWERED') {
$answered++;
} else {
$missed++;
}
$filtered[] = $call;
if (count($filtered) >= 120) {
break;
}
}
return [
'calls' => $filtered,
'stats' => [
'total' => count($filtered),
'answered' => $answered,
'missed' => $missed,
],
];
}
public static function getPhonebookForUser(PDO $db, array $allowedExts): array
{
$allowedExtIds = array_map(fn($e) => (int)$e['id'], $allowedExts);
$allowedMap = [];
foreach ($allowedExts as $e) {
$allowedMap[(int)$e['id']] = (string)$e['extension'];
}
$contacts = PhonebookManager::getContacts($db);
foreach ($contacts as &$c) {
$eid = isset($c['extension_id']) ? (int)$c['extension_id'] : 0;
$c['is_mine'] = ($eid > 0 && in_array($eid, $allowedExtIds, true));
$c['mine_extension'] = $c['is_mine'] ? $allowedMap[$eid] : null;
}
unset($c);
return $contacts;
}
public static function updateMyContactEmail(PDO $db, array $allowedExts, string $ext, string $email): void
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
$cleanEmail = trim($email);
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Adresse e-mail invalide.");
}
PhonebookManager::syncExtensions($db);
$extId = self::getExtensionId($allowedExts, $ext);
// Maintenir phonebook_contacts et extensions.voicemail_email alignés pour éviter un écrasement lors des syncExtensions()
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE id = :eid')
->execute([':em' => $cleanEmail, ':eid' => $extId]);
$db->prepare('UPDATE phonebook_contacts SET email = :em, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
->execute([':em' => $cleanEmail, ':eid' => $extId]);
$gen = new ConfigGenerator($db);
$vmContent = $gen->generateVoicemailConf();
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
self::reloadVoicemailQuiet($db);
}
public static function uploadMyAvatar(PDO $db, array $allowedExts, string $ext, array $file): string
{
$ext = self::assertAllowedExtension($allowedExts, $ext);
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException("Erreur lors de l'envoi de la photo.");
}
if (($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new InvalidArgumentException("La photo ne doit pas dépasser 5 Mo.");
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file((string)$file['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
throw new InvalidArgumentException("Format non supporté. Utilisez JPG, PNG ou WebP.");
}
PhonebookManager::initDirectory();
PhonebookManager::syncExtensions($db);
// Nettoyer l'ancien avatar UCP de ce poste
foreach (glob(PhonebookManager::AVATARS_DIR . "/avatar_ucp_{$ext}_*") ?: [] as $oldFile) {
@unlink($oldFile);
}
$fileExt = $allowed[$mime];
$filename = "avatar_ucp_{$ext}_" . time() . ".{$fileExt}";
$target = PhonebookManager::AVATARS_DIR . '/' . $filename;
if (!move_uploaded_file((string)$file['tmp_name'], $target)) {
throw new RuntimeException("Impossible d'enregistrer la photo sur le serveur.");
}
@chmod($target, 0664);
$avatarUrl = "/avatars/{$filename}";
$extId = self::getExtensionId($allowedExts, $ext);
$db->prepare('UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
->execute([':url' => $avatarUrl, ':eid' => $extId]);
return $avatarUrl;
}
public static function resolveAvatarPath(string $filename): ?string
{
$base = basename($filename);
if (!preg_match('/^[a-zA-Z0-9._\-]+\.(jpg|jpeg|png|webp)$/i', $base)) {
return null;
}
$full = PhonebookManager::AVATARS_DIR . '/' . $base;
return is_file($full) && is_readable($full) ? $full : null;
}
// =========================================================================
// HELPERS VALIDATION
// =========================================================================
private static function assertAllowedExtension(array $allowedExts, string $ext): string
{
$clean = trim($ext);
foreach ($allowedExts as $e) {
if ((string)$e['extension'] === $clean) {
return $clean;
}
}
throw new InvalidArgumentException("Accès refusé à ce poste SIP ({$clean}).");
}
private static function getExtensionId(array $allowedExts, string $ext): int
{
foreach ($allowedExts as $e) {
if ((string)$e['extension'] === $ext) {
return (int)$e['id'];
}
}
throw new InvalidArgumentException("Poste introuvable.");
}
private static function assertValidVmFolder(string $folder): string
{
if (!in_array($folder, ['INBOX', 'Old'], true)) {
throw new InvalidArgumentException("Dossier messagerie invalide.");
}
return $folder;
}
private static function assertValidMsgId(string $msgId): string
{
if (!preg_match('/^msg\d{4}$/', $msgId)) {
throw new InvalidArgumentException("Identifiant de message vocal invalide.");
}
return $msgId;
}
}