feat(ucp): v2.6.0 provisionnement XML Acrobits GroundWire par poste
This commit is contained in:
1 parent
135a6267db
commit
28efb2d6bc
10 files changed
+341
-12
No files matched your search
+8
-4
@@ -311,6 +311,7 @@ try {
|
|||||||
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
|
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
|
||||||
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
|
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
|
||||||
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
|
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
|
||||||
|
$groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0;
|
||||||
|
|
||||||
if ($force_pin_change === 1) {
|
if ($force_pin_change === 1) {
|
||||||
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
|
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
|
||||||
@@ -339,7 +340,7 @@ try {
|
|||||||
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
|
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
|
||||||
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
|
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
|
||||||
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
|
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
|
||||||
voicemail_delete = :vmd, ring_time = :rt
|
voicemail_delete = :vmd, ring_time = :rt, groundwire_enabled = :gwe
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
');
|
');
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
@@ -360,14 +361,15 @@ try {
|
|||||||
':vma' => $voicemail_attach,
|
':vma' => $voicemail_attach,
|
||||||
':vmd' => $voicemail_delete,
|
':vmd' => $voicemail_delete,
|
||||||
':rt' => $ring_time,
|
':rt' => $ring_time,
|
||||||
|
':gwe' => $groundwire_enabled,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
|
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
|
||||||
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
|
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
|
||||||
voicemail_email, voicemail_attach, voicemail_delete, ring_time)
|
voicemail_email, voicemail_attach, voicemail_delete, ring_time, groundwire_enabled)
|
||||||
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
|
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
|
||||||
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt)
|
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt, :gwe)
|
||||||
ON CONFLICT(extension) DO UPDATE SET
|
ON CONFLICT(extension) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
secret = excluded.secret,
|
secret = excluded.secret,
|
||||||
@@ -383,7 +385,8 @@ try {
|
|||||||
voicemail_email = excluded.voicemail_email,
|
voicemail_email = excluded.voicemail_email,
|
||||||
voicemail_attach = excluded.voicemail_attach,
|
voicemail_attach = excluded.voicemail_attach,
|
||||||
voicemail_delete = excluded.voicemail_delete,
|
voicemail_delete = excluded.voicemail_delete,
|
||||||
ring_time = excluded.ring_time
|
ring_time = excluded.ring_time,
|
||||||
|
groundwire_enabled = excluded.groundwire_enabled
|
||||||
');
|
');
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
':ext' => $extension,
|
':ext' => $extension,
|
||||||
@@ -402,6 +405,7 @@ try {
|
|||||||
':vma' => $voicemail_attach,
|
':vma' => $voicemail_attach,
|
||||||
':vmd' => $voicemail_delete,
|
':vmd' => $voicemail_delete,
|
||||||
':rt' => $ring_time,
|
':rt' => $ring_time,
|
||||||
|
':gwe' => $groundwire_enabled,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -937,14 +937,18 @@ function renderExtensionsTable() {
|
|||||||
const vmBadge = Number(ext.voicemail_enabled) === 1
|
const vmBadge = Number(ext.voicemail_enabled) === 1
|
||||||
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>`
|
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const gwBadge = Number(ext.groundwire_enabled) === 1
|
||||||
|
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Provisionnement XML GroundWire activé dans l'UCP">📱 GroundWire</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-anthracite-750/60 transition">
|
<tr class="hover:bg-anthracite-750/60 transition">
|
||||||
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
|
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
|
||||||
<td class="py-3.5 px-4 font-medium text-white">
|
<td class="py-3.5 px-4 font-medium text-white">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span>${escapeHtml(ext.name)}</span>
|
<span>${escapeHtml(ext.name)}</span>
|
||||||
${vmBadge}
|
${vmBadge}
|
||||||
|
${gwBadge}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td>
|
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td>
|
||||||
@@ -1027,6 +1031,9 @@ function openExtensionModal(ext = null) {
|
|||||||
});
|
});
|
||||||
syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0));
|
syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0));
|
||||||
|
|
||||||
|
const gwCheckbox = document.getElementById('ext-groundwire-enabled');
|
||||||
|
if (gwCheckbox) gwCheckbox.checked = ext ? (Number(ext.groundwire_enabled) === 1) : false;
|
||||||
|
|
||||||
// Réinitialisation de l'onglet actif vers "Général"
|
// Réinitialisation de l'onglet actif vers "Général"
|
||||||
switchExtModalTab('general');
|
switchExtModalTab('general');
|
||||||
|
|
||||||
@@ -3829,6 +3836,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
|
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
|
||||||
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
|
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
|
||||||
payload.ring_time = Number(payload.ring_time || 20);
|
payload.ring_time = Number(payload.ring_time || 20);
|
||||||
|
payload.groundwire_enabled = document.getElementById('ext-groundwire-enabled')?.checked ? 1 : 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiCall('save_extension', payload);
|
const res = await apiCall('save_extension', payload);
|
||||||
|
|||||||
@@ -3263,6 +3263,20 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
|||||||
<p class="text-[11px] text-slate-400">Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.</p>
|
<p class="text-[11px] text-slate-400">Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer transition">
|
||||||
|
<input type="checkbox" name="groundwire_enabled" id="ext-groundwire-enabled" value="1"
|
||||||
|
class="mt-0.5 rounded bg-anthracite-900 border-anthracite-600 text-cyan-500 focus:ring-cyan-500">
|
||||||
|
<div class="leading-snug">
|
||||||
|
<div class="text-xs font-bold text-white flex items-center gap-2">
|
||||||
|
<span>📱 GroundWire XML</span>
|
||||||
|
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Softphone Mobile UCP</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Affiche les boutons d'auto-configuration Acrobits GroundWire (lien direct mobile + téléchargement XML) sur ce poste dans l'UCP.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-2">
|
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs font-bold text-cyan-300">Appartenance aux Groupes d'Extensions (Sites & Droits)</span>
|
<span class="text-xs font-bold text-cyan-300">Appartenance aux Groupes d'Extensions (Sites & Droits)</span>
|
||||||
|
|||||||
+12
-1
@@ -48,7 +48,8 @@ class Database
|
|||||||
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP
|
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP
|
||||||
// ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
|
// ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
|
||||||
try {
|
try {
|
||||||
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry FROM settings LIMIT 1");
|
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret FROM settings LIMIT 1");
|
||||||
|
self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1");
|
||||||
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
|
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
self::initializeSchema();
|
self::initializeSchema();
|
||||||
@@ -117,6 +118,7 @@ class Database
|
|||||||
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
|
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
|
||||||
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
|
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
|
||||||
max_contacts INTEGER NOT NULL DEFAULT 2,
|
max_contacts INTEGER NOT NULL DEFAULT 2,
|
||||||
|
groundwire_enabled INTEGER NOT NULL DEFAULT 0 CHECK (groundwire_enabled IN (0, 1)),
|
||||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -439,12 +441,20 @@ class Database
|
|||||||
'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084",
|
'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084",
|
||||||
'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
||||||
'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
||||||
|
'groundwire_secret' => "VARCHAR(128) NOT NULL DEFAULT ''",
|
||||||
];
|
];
|
||||||
foreach ($extraSettings as $colName => $colDef) {
|
foreach ($extraSettings as $colName => $colDef) {
|
||||||
if (!in_array($colName, $existing, true)) {
|
if (!in_array($colName, $existing, true)) {
|
||||||
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
|
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clé HMAC persistante en base (partagée entre nœuds HA) pour les liens XML GroundWire
|
||||||
|
$gwSecret = (string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '');
|
||||||
|
if ($gwSecret === '') {
|
||||||
|
$newSecret = bin2hex(random_bytes(32));
|
||||||
|
$db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $newSecret]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function migrateExtensionsTable(PDO $db): void
|
private static function migrateExtensionsTable(PDO $db): void
|
||||||
@@ -487,6 +497,7 @@ class Database
|
|||||||
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
|
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
|
||||||
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
|
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
|
||||||
'ring_time' => "INTEGER NOT NULL DEFAULT 20",
|
'ring_time' => "INTEGER NOT NULL DEFAULT 20",
|
||||||
|
'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0",
|
||||||
];
|
];
|
||||||
foreach ($extraExtensionCols as $colName => $colDef) {
|
foreach ($extraExtensionCols as $colName => $colDef) {
|
||||||
if (!in_array($colName, $existing, true)) {
|
if (!in_array($colName, $existing, true)) {
|
||||||
|
|||||||
+1
-1
@@ -159,7 +159,7 @@ class UcpAuth
|
|||||||
$linkedSet = array_values(array_unique(array_map('strval', $linked)));
|
$linkedSet = array_values(array_unique(array_map('strval', $linked)));
|
||||||
|
|
||||||
$extRows = $db->query('
|
$extRows = $db->query('
|
||||||
SELECT id, extension, name, transport, encryption, voicemail_enabled, voicemail_email, voicemail_pin, ring_time
|
SELECT id, extension, name, transport, encryption, groundwire_enabled, voicemail_enabled, voicemail_email, voicemail_pin, ring_time
|
||||||
FROM extensions
|
FROM extensions
|
||||||
WHERE enabled = 1
|
WHERE enabled = 1
|
||||||
ORDER BY extension ASC
|
ORDER BY extension ASC
|
||||||
|
|||||||
+165
-1
@@ -162,7 +162,7 @@ class UcpManager
|
|||||||
/**
|
/**
|
||||||
* Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées.
|
* 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
|
public static function getExtensionsState(PDO $db, array $allowedExts, ?int $ucpUserId = null): array
|
||||||
{
|
{
|
||||||
$extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
$extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
||||||
if (empty($extNums)) {
|
if (empty($extNums)) {
|
||||||
@@ -243,6 +243,16 @@ class UcpManager
|
|||||||
$vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0];
|
$vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0];
|
||||||
$pb = $pbByExtId[$extId] ?? [];
|
$pb = $pbByExtId[$extId] ?? [];
|
||||||
|
|
||||||
|
$gwEnabled = !empty($ext['groundwire_enabled']) ? 1 : 0;
|
||||||
|
$gwXmlUrl = '';
|
||||||
|
$gwDeepLink = '';
|
||||||
|
if ($gwEnabled === 1 && $ucpUserId !== null && $ucpUserId > 0) {
|
||||||
|
$gwXmlUrl = self::buildGroundwireXmlUrl($db, $extId, $ucpUserId);
|
||||||
|
if ($gwXmlUrl !== '') {
|
||||||
|
$gwDeepLink = 'provlinkbs:' . preg_replace('#^https?://#i', '', $gwXmlUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'id' => $extId,
|
'id' => $extId,
|
||||||
'extension' => $num,
|
'extension' => $num,
|
||||||
@@ -262,6 +272,9 @@ class UcpManager
|
|||||||
'vm_old_count' => $vmCounts['old'],
|
'vm_old_count' => $vmCounts['old'],
|
||||||
'profile_email' => (string)($pb['email'] ?? ''),
|
'profile_email' => (string)($pb['email'] ?? ''),
|
||||||
'photo_url' => (string)($pb['photo_url'] ?? ''),
|
'photo_url' => (string)($pb['photo_url'] ?? ''),
|
||||||
|
'groundwire_enabled' => $gwEnabled,
|
||||||
|
'groundwire_xml_url' => $gwXmlUrl,
|
||||||
|
'groundwire_deeplink_url' => $gwDeepLink,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +803,157 @@ class UcpManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 7. PROVISIONNEMENT XML ACROBITS GROUNDWIRE
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère ou initialise la clé secrète HMAC utilisée pour signer les URL XML GroundWire sans session.
|
||||||
|
*/
|
||||||
|
public static function getGroundwireSecret(PDO $db): string
|
||||||
|
{
|
||||||
|
$secret = trim((string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: ''));
|
||||||
|
if ($secret === '') {
|
||||||
|
$secret = bin2hex(random_bytes(32));
|
||||||
|
$db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $secret]);
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildGroundwireToken(PDO $db, int $extId, int $ucpUserId): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', "{$extId}-{$ucpUserId}", self::getGroundwireSecret($db));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyGroundwireToken(PDO $db, int $extId, int $ucpUserId, string $token): bool
|
||||||
|
{
|
||||||
|
if ($extId <= 0 || $ucpUserId <= 0 || !preg_match('/^[a-f0-9]{64}$/i', $token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hash_equals(self::buildGroundwireToken($db, $extId, $ucpUserId), strtolower($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildGroundwireXmlUrl(PDO $db, int $extId, int $ucpUserId): string
|
||||||
|
{
|
||||||
|
$rawHost = (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? '');
|
||||||
|
$host = trim(explode(',', $rawHost)[0] ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fwdProto = strtolower(trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? ''));
|
||||||
|
$isHttps = ($fwdProto === 'https')
|
||||||
|
|| (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443);
|
||||||
|
$scheme = $isHttps ? 'https' : 'http';
|
||||||
|
|
||||||
|
$scriptDir = rtrim(str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/api.php'))), '/');
|
||||||
|
$path = $scriptDir . '/download_groundwire.php';
|
||||||
|
$token = self::buildGroundwireToken($db, $extId, $ucpUserId);
|
||||||
|
|
||||||
|
return "{$scheme}://{$host}{$path}?ext_id={$extId}&uid={$ucpUserId}&token={$token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère à la volée le XML <account> pour Acrobits GroundWire à partir des paramètres de l'extension.
|
||||||
|
*
|
||||||
|
* @return array{filename: string, xml: string}
|
||||||
|
*/
|
||||||
|
public static function buildGroundwireXml(PDO $db, int $extId): array
|
||||||
|
{
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, extension, name, secret, transport, encryption, enabled, groundwire_enabled
|
||||||
|
FROM extensions
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1
|
||||||
|
');
|
||||||
|
$stmt->execute([':id' => $extId]);
|
||||||
|
$extRow = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$extRow || (int)$extRow['enabled'] !== 1 || (int)$extRow['groundwire_enabled'] !== 1) {
|
||||||
|
throw new InvalidArgumentException("Provisionnement GroundWire non activé pour ce poste.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $db->query('
|
||||||
|
SELECT domain, external_ip, letsencrypt_domain, udp_port, tls_port, external_udp_port, external_tls_port
|
||||||
|
FROM settings
|
||||||
|
WHERE id = 1
|
||||||
|
')->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
$haVip = '';
|
||||||
|
try {
|
||||||
|
$cl = $db->query('SELECT enabled, virtual_ip FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
if (!empty($cl['enabled']) && !empty($cl['virtual_ip'])) {
|
||||||
|
$haVip = trim(explode('/', (string)$cl['virtual_ip'])[0] ?? '');
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
$sipHost = trim((string)($settings['external_ip'] ?? ''));
|
||||||
|
if ($sipHost === '') {
|
||||||
|
$sipHost = trim((string)($settings['letsencrypt_domain'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($sipHost === '' && $haVip !== '') {
|
||||||
|
$sipHost = $haVip;
|
||||||
|
}
|
||||||
|
if ($sipHost === '') {
|
||||||
|
$rawHttpHost = trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''))[0] ?? '');
|
||||||
|
if ($rawHttpHost !== '') {
|
||||||
|
$parsedHost = parse_url("http://{$rawHttpHost}", PHP_URL_HOST);
|
||||||
|
$sipHost = is_string($parsedHost) && $parsedHost !== '' ? $parsedHost : $rawHttpHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($sipHost === '') {
|
||||||
|
$sipHost = trim((string)($settings['domain'] ?? '')) ?: '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$isTls = (($extRow['transport'] ?? 'udp') === 'tls');
|
||||||
|
$transport = $isTls ? 'tls' : 'udp';
|
||||||
|
$sipPort = $isTls
|
||||||
|
? (((int)($settings['external_tls_port'] ?? 0) > 0) ? (int)$settings['external_tls_port'] : ((int)($settings['tls_port'] ?? 5061) ?: 5061))
|
||||||
|
: (((int)($settings['external_udp_port'] ?? 0) > 0) ? (int)$settings['external_udp_port'] : ((int)($settings['udp_port'] ?? 5060) ?: 5060));
|
||||||
|
|
||||||
|
$encMode = (int)($extRow['encryption'] ?? 0);
|
||||||
|
$sdes = ($isTls || $encMode > 0) ? 'enabled' : 'disabled';
|
||||||
|
|
||||||
|
$vmCode = '*97';
|
||||||
|
try {
|
||||||
|
$fc = $db->query("SELECT code FROM feature_codes WHERE feature_key = 'voicemail_own' AND enabled = 1 LIMIT 1")->fetchColumn();
|
||||||
|
if (is_string($fc) && trim($fc) !== '') {
|
||||||
|
$vmCode = trim($fc);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
$esc = static fn(string $v): string => htmlspecialchars($v, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
$extNum = (string)$extRow['extension'];
|
||||||
|
$lineName = trim((string)($extRow['name'] ?? '')) ?: $extNum;
|
||||||
|
$password = (string)($extRow['secret'] ?? '');
|
||||||
|
$hostPort = "{$sipHost}:{$sipPort}";
|
||||||
|
|
||||||
|
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||||
|
. "<account>\n"
|
||||||
|
. " <title>" . $esc($lineName) . "</title>\n"
|
||||||
|
. " <username>" . $esc($extNum) . "</username>\n"
|
||||||
|
. " <password>" . $esc($password) . "</password>\n"
|
||||||
|
. " <host>" . $esc($hostPort) . "</host>\n"
|
||||||
|
. " <transport>" . $esc($transport) . "</transport>\n"
|
||||||
|
. " <sdesIncoming>" . $esc($sdes) . "</sdesIncoming>\n"
|
||||||
|
. " <sdesOutgoing>" . $esc($sdes) . "</sdesOutgoing>\n"
|
||||||
|
. " <allowMessage>1</allowMessage>\n"
|
||||||
|
. " <voiceMailNumber>" . $esc($vmCode) . "</voiceMailNumber>\n"
|
||||||
|
. " <allowVideo>1</allowVideo>\n"
|
||||||
|
. " <natTraversal>off</natTraversal>\n"
|
||||||
|
. " <icm>auto</icm>\n"
|
||||||
|
. "</account>\n";
|
||||||
|
|
||||||
|
$safeExt = preg_replace('/[^a-zA-Z0-9_-]/', '', $extNum) ?: (string)$extId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filename' => "groundwire-{$safeExt}.xml",
|
||||||
|
'xml' => $xml,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// HELPERS VALIDATION
|
// HELPERS VALIDATION
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+2
-2
@@ -114,7 +114,7 @@ try {
|
|||||||
'company_logo' => (string)($branding['company_logo'] ?? ''),
|
'company_logo' => (string)($branding['company_logo'] ?? ''),
|
||||||
],
|
],
|
||||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
|
||||||
'forward_targets' => UcpManager::getForwardTargets($db),
|
'forward_targets' => UcpManager::getForwardTargets($db),
|
||||||
'feature_codes' => UcpManager::getFeatureCodesForUser($db),
|
'feature_codes' => UcpManager::getFeatureCodesForUser($db),
|
||||||
],
|
],
|
||||||
@@ -130,7 +130,7 @@ try {
|
|||||||
]);
|
]);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
'extensions' => UcpManager::getExtensionsState($db, $allowedExts, $ucpUserId),
|
||||||
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
|
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -351,6 +351,44 @@ function renderExtensionsGrid() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
${(Number(ext.groundwire_enabled) === 1 && ext.groundwire_deeplink_url && ext.groundwire_xml_url) ? `
|
||||||
|
<div class="pt-3 border-t border-slate-100">
|
||||||
|
<div class="rounded-xl bg-slate-50/80 border border-slate-200/80 p-3 space-y-2.5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs font-bold text-slate-700">
|
||||||
|
<span>📱</span>
|
||||||
|
<span>GroundWire Mobile</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[11px] font-medium text-slate-500">
|
||||||
|
<a href="https://apps.apple.com/fr/app/groundwire-voip-sip-softphone/id378503081"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
class="hover:text-indigo-600 transition underline decoration-slate-300 underline-offset-2">
|
||||||
|
iOS
|
||||||
|
</a>
|
||||||
|
<span class="text-slate-300">•</span>
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=cz.acrobits.softphone.aliengroundwire"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
class="hover:text-indigo-600 transition underline decoration-slate-300 underline-offset-2">
|
||||||
|
Android
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<a href="${escapeHtml(ext.groundwire_deeplink_url)}"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 text-xs font-semibold transition flex-1 sm:flex-initial">
|
||||||
|
<span>⚡</span>
|
||||||
|
<span>Configurer dans Groundwire</span>
|
||||||
|
</a>
|
||||||
|
<a href="${escapeHtml(ext.groundwire_xml_url)}"
|
||||||
|
download="groundwire-${escapeHtml(ext.extension)}.xml"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl bg-white hover:bg-slate-100 text-slate-700 border border-slate-200 text-xs font-medium transition">
|
||||||
|
<span>⬇</span>
|
||||||
|
<span>Télécharger le XML</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||||
|
require_once __DIR__ . '/../src/AmiClient.php';
|
||||||
|
require_once __DIR__ . '/../src/ConfigGenerator.php';
|
||||||
|
require_once __DIR__ . '/../src/CdrManager.php';
|
||||||
|
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||||
|
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||||
|
require_once __DIR__ . '/../src/UcpManager.php';
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
use App\UcpAuth;
|
||||||
|
use App\UcpManager;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getConnection();
|
||||||
|
|
||||||
|
if (!UcpAuth::isUcpEnabled($db)) {
|
||||||
|
http_response_code(503);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Le portail utilisateur UCP est actuellement désactivé.";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extId = (int)($_GET['ext_id'] ?? $_GET['line_id'] ?? 0);
|
||||||
|
$uid = (int)($_GET['uid'] ?? 0);
|
||||||
|
$token = trim((string)($_GET['token'] ?? ''));
|
||||||
|
|
||||||
|
$authorized = false;
|
||||||
|
|
||||||
|
// 1. Authentification sans session par signature HMAC GET (utilisée par l'application GroundWire / Acrobits)
|
||||||
|
if ($token !== '' && $extId > 0 && $uid > 0) {
|
||||||
|
if (UcpManager::verifyGroundwireToken($db, $extId, $uid, $token)) {
|
||||||
|
$allowed = UcpAuth::getAllowedExtensions($db, $uid);
|
||||||
|
foreach ($allowed as $row) {
|
||||||
|
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
|
||||||
|
$authorized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sinon : utilisateur UCP connecté en session et propriétaire de l'extension
|
||||||
|
if (!$authorized && $extId > 0) {
|
||||||
|
UcpAuth::startSession();
|
||||||
|
$sessionUser = UcpAuth::getCurrentUser($db);
|
||||||
|
if ($sessionUser !== null) {
|
||||||
|
$allowed = UcpAuth::getAllowedExtensions($db, (int)$sessionUser['id']);
|
||||||
|
foreach ($allowed as $row) {
|
||||||
|
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
|
||||||
|
$authorized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$authorized) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Accès refusé ou lien de provisionnement invalide.";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bundle = UcpManager::buildGroundwireXml($db, $extId);
|
||||||
|
$xmlContent = $bundle['xml'];
|
||||||
|
|
||||||
|
while (ob_get_level()) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $bundle['filename'] . '"');
|
||||||
|
header('Content-Length: ' . strlen($xmlContent));
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
|
||||||
|
echo $xmlContent;
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo $e->getMessage();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Erreur interne lors de la génération du profil XML.";
|
||||||
|
}
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "2.5.7",
|
"version": "2.6.0",
|
||||||
"codename": "Pulsar UCP",
|
"codename": "Pulsar UCP",
|
||||||
"release_date": "2026-11-11",
|
"release_date": "2026-11-12",
|
||||||
"min_php_version": "8.2",
|
"min_php_version": "8.2",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
"Nouveauté v2.6.0 : provisionnement dynamique XML Acrobits GroundWire par poste (option Admin « GroundWire XML », deep link mobile provlinkbs:, téléchargement XML signé HMAC sans session, liens App Store / Google Play dans l'UCP)",
|
||||||
"UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur",
|
"UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur",
|
||||||
"Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)",
|
"Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)",
|
||||||
"Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP",
|
"Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP",
|
||||||
|
|||||||
Reference in new issue
Block a user