339 lines
13 KiB
PHP
339 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App;
|
|
|
|
use PDO;
|
|
use Exception;
|
|
use RuntimeException;
|
|
use InvalidArgumentException;
|
|
|
|
class PhonebookManager
|
|
{
|
|
public const AVATARS_DIR = '/var/www/nexus-pbx/public/avatars';
|
|
|
|
/**
|
|
* Initialise le dossier des avatars.
|
|
*/
|
|
public static function initDirectory(): void
|
|
{
|
|
if (!is_dir(self::AVATARS_DIR)) {
|
|
@mkdir(self::AVATARS_DIR, 0775, true);
|
|
}
|
|
@chown(self::AVATARS_DIR, 'www-data');
|
|
@chgrp(self::AVATARS_DIR, 'www-data');
|
|
}
|
|
|
|
/**
|
|
* Synchronise automatiquement les postes d'extensions vers l'annuaire d'entreprise.
|
|
*/
|
|
public static function syncExtensions(PDO $db): void
|
|
{
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS phonebook_contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
extension_id INTEGER DEFAULT NULL,
|
|
display_name VARCHAR(128) NOT NULL,
|
|
internal_number VARCHAR(32) NOT NULL DEFAULT '',
|
|
mobile_number VARCHAR(32) NOT NULL DEFAULT '',
|
|
other_number VARCHAR(32) NOT NULL DEFAULT '',
|
|
email VARCHAR(128) NOT NULL DEFAULT '',
|
|
photo_url TEXT NOT NULL DEFAULT '',
|
|
group_name VARCHAR(64) NOT NULL DEFAULT 'Général',
|
|
is_internal INTEGER NOT NULL DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_phonebook_ext ON phonebook_contacts(extension_id);
|
|
CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name);
|
|
");
|
|
|
|
// 1. Récupère la cartographie des groupes d'extensions
|
|
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll();
|
|
$extToGroup = [];
|
|
foreach ($egRows as $eg) {
|
|
$members = json_decode((string)$eg['members'], true) ?: [];
|
|
foreach ($members as $extNum) {
|
|
if (!isset($extToGroup[(string)$extNum])) {
|
|
$extToGroup[(string)$extNum] = trim((string)$eg['name']);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Récupère tous les postes
|
|
$exts = $db->query("SELECT id, extension, name, voicemail_email FROM extensions")->fetchAll();
|
|
$activeExtIds = [];
|
|
|
|
$findStmt = $db->prepare("SELECT id, display_name, internal_number, email, group_name FROM phonebook_contacts WHERE extension_id = :ext_id OR (extension_id IS NULL AND is_internal = 1 AND internal_number = :num)");
|
|
$insertStmt = $db->prepare("
|
|
INSERT INTO phonebook_contacts (extension_id, display_name, internal_number, email, group_name, is_internal)
|
|
VALUES (:ext_id, :name, :num, :email, :grp, 1)
|
|
");
|
|
$updateStmt = $db->prepare("
|
|
UPDATE phonebook_contacts
|
|
SET extension_id = :ext_id, display_name = :name, internal_number = :num, email = :email, group_name = :grp, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = :id
|
|
");
|
|
|
|
foreach ($exts as $ext) {
|
|
$extId = (int)$ext['id'];
|
|
$extNum = trim((string)$ext['extension']);
|
|
$extName = trim((string)$ext['name']);
|
|
$email = trim((string)($ext['voicemail_email'] ?? ''));
|
|
$group = $extToGroup[$extNum] ?? 'Général';
|
|
$activeExtIds[] = $extId;
|
|
|
|
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
|
|
$existing = $findStmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($existing) {
|
|
$updateStmt->execute([
|
|
':id' => (int)$existing['id'],
|
|
':ext_id' => $extId,
|
|
':name' => $extName,
|
|
':num' => $extNum,
|
|
':email' => $email,
|
|
':grp' => $group,
|
|
]);
|
|
} else {
|
|
$insertStmt->execute([
|
|
':ext_id' => $extId,
|
|
':name' => $extName,
|
|
':num' => $extNum,
|
|
':email' => $email,
|
|
':grp' => $group,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 3. Supprime les contacts internes orphelins (si extension supprimée)
|
|
if (!empty($activeExtIds)) {
|
|
$inClause = implode(',', $activeExtIds);
|
|
$db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL AND extension_id NOT IN ({$inClause})");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère la liste des contacts filtrée ou non par groupe.
|
|
*/
|
|
public static function getContacts(PDO $db, ?string $group = null): array
|
|
{
|
|
self::syncExtensions($db);
|
|
|
|
$params = [];
|
|
$sql = "SELECT * FROM phonebook_contacts";
|
|
|
|
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') {
|
|
$cleanGroup = trim($group);
|
|
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')";
|
|
$params[':grp'] = $cleanGroup;
|
|
$params[':grp_like'] = "%{$cleanGroup}%";
|
|
}
|
|
|
|
$sql .= " ORDER BY display_name COLLATE NOCASE ASC";
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute($params);
|
|
$contacts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
// Vérification de la présence réelle du fichier sur le disque
|
|
foreach ($contacts as &$c) {
|
|
if (!empty($c['photo_url'])) {
|
|
$filename = basename((string)$c['photo_url']);
|
|
if (!file_exists(self::AVATARS_DIR . '/' . $filename)) {
|
|
$c['photo_url'] = '';
|
|
}
|
|
}
|
|
}
|
|
unset($c);
|
|
|
|
return $contacts;
|
|
}
|
|
|
|
/**
|
|
* Récupère la liste de tous les groupes d'annuaire disponibles.
|
|
*/
|
|
public static function getGroups(PDO $db): array
|
|
{
|
|
$groups = [];
|
|
$egRows = $db->query("SELECT name FROM extension_groups ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN);
|
|
foreach ($egRows as $g) {
|
|
$g = trim((string)$g);
|
|
if ($g !== '') $groups[$g] = true;
|
|
}
|
|
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
|
|
foreach ($pbRows as $g) {
|
|
$g = trim((string)$g);
|
|
if ($g !== '') $groups[$g] = true;
|
|
}
|
|
|
|
// Ajoute Issy-Les-Moulineaux si pas encore présent
|
|
if (!isset($groups['Issy-Les-Moulineaux'])) {
|
|
$groups['Issy-Les-Moulineaux'] = true;
|
|
}
|
|
|
|
$result = array_keys($groups);
|
|
sort($result, SORT_NATURAL | SORT_FLAG_CASE);
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Enregistre un contact (création ou mise à jour).
|
|
*/
|
|
public static function saveContact(PDO $db, array $data): int
|
|
{
|
|
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
|
|
$name = trim((string)($data['display_name'] ?? ''));
|
|
$internal = trim((string)($data['internal_number'] ?? ''));
|
|
$mobile = trim((string)($data['mobile_number'] ?? ''));
|
|
$other = trim((string)($data['other_number'] ?? ''));
|
|
$email = trim((string)($data['email'] ?? ''));
|
|
$photo = trim((string)($data['photo_url'] ?? ''));
|
|
$group = trim((string)($data['group_name'] ?? 'Général')) ?: 'Général';
|
|
$isInternal = !empty($data['is_internal']) ? 1 : 0;
|
|
$extensionId = !empty($data['extension_id']) ? (int)$data['extension_id'] : null;
|
|
|
|
if ($name === '') {
|
|
throw new InvalidArgumentException("Le nom du contact est obligatoire.");
|
|
}
|
|
if ($internal === '' && $mobile === '' && $other === '') {
|
|
throw new InvalidArgumentException("Veuillez renseigner au moins un numéro de téléphone (Poste, Mobile ou Autre).");
|
|
}
|
|
|
|
if ($id) {
|
|
$stmt = $db->prepare("
|
|
UPDATE phonebook_contacts
|
|
SET display_name = :name, internal_number = :internal, mobile_number = :mobile,
|
|
other_number = :other, email = :email, photo_url = :photo, group_name = :grp,
|
|
is_internal = :is_int, extension_id = :ext_id, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = :id
|
|
");
|
|
$stmt->execute([
|
|
':id' => $id,
|
|
':name' => $name,
|
|
':internal' => $internal,
|
|
':mobile' => $mobile,
|
|
':other' => $other,
|
|
':email' => $email,
|
|
':photo' => $photo,
|
|
':grp' => $group,
|
|
':is_int' => $isInternal,
|
|
':ext_id' => $extensionId,
|
|
]);
|
|
return $id;
|
|
} else {
|
|
$stmt = $db->prepare("
|
|
INSERT INTO phonebook_contacts (display_name, internal_number, mobile_number, other_number, email, photo_url, group_name, is_internal, extension_id)
|
|
VALUES (:name, :internal, :mobile, :other, :email, :photo, :grp, :is_int, :ext_id)
|
|
");
|
|
$stmt->execute([
|
|
':name' => $name,
|
|
':internal' => $internal,
|
|
':mobile' => $mobile,
|
|
':other' => $other,
|
|
':email' => $email,
|
|
':photo' => $photo,
|
|
':grp' => $group,
|
|
':is_int' => $isInternal,
|
|
':ext_id' => $extensionId,
|
|
]);
|
|
return (int)$db->lastInsertId();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Supprime un contact d'annuaire.
|
|
*/
|
|
public static function deleteContact(PDO $db, int $id): bool
|
|
{
|
|
$stmt = $db->prepare("DELETE FROM phonebook_contacts WHERE id = :id");
|
|
return $stmt->execute([':id' => $id]);
|
|
}
|
|
|
|
/**
|
|
* Détermine l'URL de base pour l'accès aux photos et avatars depuis les postes IP.
|
|
*/
|
|
public static function getBaseUrl(?PDO $db = null): string
|
|
{
|
|
if (!empty($_SERVER['HTTP_HOST'])) {
|
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
return "{$scheme}://" . $_SERVER['HTTP_HOST'];
|
|
}
|
|
|
|
if ($db) {
|
|
try {
|
|
$st = $db->query("SELECT domain, external_ip FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
|
if (!empty($st['external_ip'])) {
|
|
return "http://" . $st['external_ip'] . ":8080";
|
|
}
|
|
if (!empty($st['domain']) && $st['domain'] !== 'pbx.local') {
|
|
return "http://" . $st['domain'] . ":8080";
|
|
}
|
|
} catch (\Throwable $e) {}
|
|
}
|
|
|
|
$serverIp = !empty($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '127.0.0.1';
|
|
return "http://{$serverIp}:8080";
|
|
}
|
|
|
|
/**
|
|
* Génère le flux XML officiel Yealink IP Phone Remote Address Book.
|
|
*/
|
|
public static function renderYealinkXml(PDO $db, ?string $group = null): string
|
|
{
|
|
$contacts = self::getContacts($db, $group);
|
|
$baseUrl = self::getBaseUrl($db);
|
|
$groupTitle = ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all')
|
|
? trim($group)
|
|
: 'NexusPBX';
|
|
|
|
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><YealinkIPPhoneDirectory/>');
|
|
$xml->addChild('Title', "Annuaire " . htmlspecialchars($groupTitle, ENT_XML1, 'UTF-8'));
|
|
|
|
foreach ($contacts as $c) {
|
|
$name = trim((string)$c['display_name']);
|
|
$phone1 = trim((string)$c['internal_number']);
|
|
$phone2 = trim((string)$c['mobile_number']);
|
|
$phone3 = trim((string)$c['other_number']);
|
|
$email = trim((string)($c['email'] ?? ''));
|
|
$photo = trim((string)($c['photo_url'] ?? ''));
|
|
|
|
if ($phone1 === '' && $phone2 !== '') {
|
|
$phone1 = $phone2;
|
|
$phone2 = '';
|
|
}
|
|
|
|
if ($phone1 === '' && $phone2 === '' && $phone3 === '') {
|
|
continue;
|
|
}
|
|
|
|
$entry = $xml->addChild('DirectoryEntry');
|
|
$entry->addChild('Name', htmlspecialchars($name, ENT_XML1, 'UTF-8'));
|
|
$entry->addChild('Telephone', htmlspecialchars($phone1, ENT_XML1, 'UTF-8'));
|
|
if ($phone2 !== '') {
|
|
$entry->addChild('Mobile', htmlspecialchars($phone2, ENT_XML1, 'UTF-8'));
|
|
}
|
|
if ($phone3 !== '') {
|
|
$entry->addChild('OtherTelephone', htmlspecialchars($phone3, ENT_XML1, 'UTF-8'));
|
|
}
|
|
if ($email !== '') {
|
|
$entry->addChild('Email', htmlspecialchars($email, ENT_XML1, 'UTF-8'));
|
|
}
|
|
if ($photo !== '') {
|
|
$photoFile = basename($photo);
|
|
if (file_exists(self::AVATARS_DIR . '/' . $photoFile)) {
|
|
$fullPhotoUrl = (strpos($photo, '://') === false)
|
|
? rtrim($baseUrl, '/') . '/' . ltrim($photo, '/')
|
|
: $photo;
|
|
$entry->addChild('Photo', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
|
|
$entry->addChild('Picture', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
|
|
}
|
|
}
|
|
}
|
|
|
|
$dom = dom_import_simplexml($xml)->ownerDocument;
|
|
$dom->formatOutput = true;
|
|
return $dom->saveXML();
|
|
}
|
|
}
|
|
|