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