. /** * High-level Adobe Connect service operations. * * @package mod_meetletacbfree * @copyright 2026 MeetLet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_meetletacbfree\api; use cache; use mod_meetletacbfree\local\config; use moodle_exception; use stdClass; defined('MOODLE_INTERNAL') || die(); /** * Service layer on top of the XML API client. */ class service { public const ROLE_HOST = 'host'; public const ROLE_PRESENTER = 'mini-host'; public const ROLE_PARTICIPANT = 'view'; /** @var client */ protected $client; public function __construct(?client $client = null) { $this->client = $client ?? new client(); } public function get_client(): client { return $this->client; } public function login_admin(): self { $this->client->login_admin(); return $this; } public function logout(): void { $this->client->logout(); } /** * Resolve a sco-shortcuts folder id (cached). */ public function get_folder_scoid(string $type): int { $cache = cache::make('mod_meetletacbfree', 'folders'); $key = 'folder_' . str_replace('-', '_', $type); $cached = $cache->get($key); if ($cached !== false && $cached !== null) { return (int)$cached; } $xml = $this->client->request(['action' => 'sco-shortcuts']); $scoid = 0; if (!empty($xml->shortcuts->sco)) { foreach ($xml->shortcuts->sco as $sco) { if ((string)$sco['type'] === $type) { $scoid = (int)$sco['sco-id']; break; } } } if ($scoid) { $cache->set($key, $scoid); } return $scoid; } /** * Meeting templates list (cached). * * @return array scoid => name */ public function get_templates(): array { $cache = cache::make('mod_meetletacbfree', 'templates'); $cached = $cache->get('all'); if (is_array($cached)) { return $cached; } $folder = $this->get_folder_scoid('shared-meeting-templates'); if (!$folder) { $folder = $this->get_folder_scoid('my-meeting-templates'); } $templates = []; if ($folder) { $xml = $this->client->request([ 'action' => 'sco-contents', 'sco-id' => $folder, 'filter-type' => 'meeting', ]); if (!empty($xml->scos->sco)) { foreach ($xml->scos->sco as $sco) { $templates[(int)$sco['sco-id']] = (string)$sco->name; } } } $cache->set('all', $templates); return $templates; } /** * Create a meeting SCO. */ public function create_meeting(stdClass $data, int $folderid): int { $params = [ 'action' => 'sco-update', 'type' => 'meeting', 'name' => $data->name, 'folder-id' => $folderid, 'date-begin' => self::format_date((int)$data->starttime), ]; if (!empty($data->endtime)) { $params['date-end'] = self::format_date((int)$data->endtime); } if (!empty($data->meeturl)) { $params['url-path'] = ltrim($data->meeturl, '/'); } if (!empty($data->templatescoid)) { $params['source-sco-id'] = (int)$data->templatescoid; } $xml = $this->client->request($params); if ((string)$xml->status['code'] !== 'ok') { throw new moodle_exception('errorcreatemeeting', 'meetletacbfree'); } $scoid = (int)$xml->sco['sco-id']; $this->apply_meeting_options($scoid, $data); return $scoid; } /** * Update meeting metadata. */ public function update_meeting(int $scoid, stdClass $data): bool { $params = [ 'action' => 'sco-update', 'sco-id' => $scoid, 'name' => $data->name, 'date-begin' => self::format_date((int)$data->starttime), ]; if (!empty($data->endtime)) { $params['date-end'] = self::format_date((int)$data->endtime); } $xml = $this->client->request($params); $ok = (string)$xml->status['code'] === 'ok'; if ($ok) { cache::make('mod_meetletacbfree', 'meetinginfo')->delete((string)$scoid); $this->apply_meeting_options($scoid, $data); } return $ok; } /** * Apply Adobe room options (language + wait-for-host ACL). * Wait-for-host is also enforced by Moodle on join (host presence check). */ public function apply_meeting_options(int $scoid, stdClass $data): void { if (isset($data->meetinglang) && (string)$data->meetinglang !== '') { $this->client->request([ 'action' => 'acl-field-update', 'acl-id' => $scoid, 'field-id' => 'meeting-language', 'value' => (string)$data->meetinglang, ]); } if (isset($data->waitforhost)) { $hold = !empty($data->waitforhost) ? 'true' : 'false'; foreach ([ 'meetingHostMustJoinFirst', 'meeting-hold-until-host', 'meeting-block-participants-before-host', 'meetingBlockAVUntilHost', ] as $fieldid) { $this->client->request([ 'action' => 'acl-field-update', 'acl-id' => $scoid, 'field-id' => $fieldid, 'value' => $hold, ]); } } } /** * Active participants currently in the meeting room (Connect report). * * @return array list of participant row objects */ public function list_active_participants(int $meetingscoid): array { $attempts = [ ['action' => 'report-active-participants', 'sco-id' => $meetingscoid], ['action' => 'report-meeting-concurrent-users', 'sco-id' => $meetingscoid], ]; foreach ($attempts as $params) { try { $xml = $this->client->request($params); } catch (\Throwable $e) { continue; } if ((string)($xml->status['code'] ?? '') !== 'ok') { continue; } $nodes = []; if (!empty($xml->{'report-active-participants'}->row)) { foreach ($xml->{'report-active-participants'}->row as $row) { $nodes[] = $row; } } if (!empty($xml->{'report-meeting-concurrent-users'}->row)) { foreach ($xml->{'report-meeting-concurrent-users'}->row as $row) { $nodes[] = $row; } } if (empty($nodes) && !empty($xml->principal)) { foreach ($xml->principal as $row) { $nodes[] = $row; } } if (!empty($nodes)) { return $nodes; } // Some Connect builds return only a count attribute (no row nodes). // Note: empty(SimpleXMLElement) is true when there are no children, even if attributes exist. if (isset($xml->{'report-active-participants'})) { $wrap = $xml->{'report-active-participants'}; $count = 0; foreach ($wrap->attributes() ?: [] as $aname => $avalue) { $n = strtolower((string)$aname); // Exact / known count attrs only — ignore unrelated fields. if (in_array($n, ['noofparticipant', 'no-of-participant', 'noofparticipants', 'participants', 'count'], true)) { $count = max($count, (int)$avalue); } } if ($count > 0) { $fake = new stdClass(); $fake->countonly = $count; return [$fake]; } } } return []; } /** * Whether Adobe Connect holds the room until a host joins first. */ public function meeting_requires_host_first(int $meetingscoid): bool { $want = [ 'meetinghostmustjoinfirst' => true, 'meeting-hold-until-host' => true, 'meeting-block-participants-before-host' => true, 'meetingblockavuntilhost' => true, ]; $queries = [ [ 'action' => 'acl-field-info', 'acl-id' => $meetingscoid, 'filter-field-id' => 'meetingHostMustJoinFirst', ], [ 'action' => 'acl-field-info', 'acl-id' => $meetingscoid, ], ]; foreach ($queries as $params) { try { $xml = $this->client->request($params); } catch (\Throwable $e) { continue; } if ((string)($xml->status['code'] ?? '') !== 'ok') { continue; } foreach ($xml->xpath('//*[@field-id]') ?: [] as $node) { $fid = strtolower(trim((string)$node['field-id'])); $val = strtolower(trim((string)($node['value'] ?? $node->value ?? ''))); if ($val === '' && isset($node->values->value)) { $val = strtolower(trim((string)$node->values->value)); } if (isset($want[$fid]) && in_array($val, ['true', '1', 'yes'], true)) { return true; } } foreach ($xml->xpath('//acl-field') ?: [] as $node) { $fid = strtolower(trim((string)($node['field-id'] ?? $node->{'field-id'} ?? ''))); $val = strtolower(trim((string)($node['value'] ?? $node->value ?? ''))); if (isset($want[$fid]) && in_array($val, ['true', '1', 'yes'], true)) { return true; } } } return false; } /** * Whether anyone is currently inside the meeting room. */ public function meeting_is_live(int $meetingscoid): bool { $nodes = $this->list_active_participants($meetingscoid); if (empty($nodes)) { return false; } foreach ($nodes as $row) { if (is_object($row) && !empty($row->countonly)) { return ((int)$row->countonly) > 0; } } return true; } /** * Whether a host (or presenter acting as host) is currently in the meeting room. */ public function host_is_present(int $meetingscoid): bool { $nodes = $this->list_active_participants($meetingscoid); if (empty($nodes)) { return false; } $sawrole = false; foreach ($nodes as $row) { if (is_object($row) && !empty($row->countonly)) { // Count-only payloads prove occupancy, not that a host is present. continue; } $perm = self::sco_xml_string($row, 'permission-id'); $role = self::sco_xml_string($row, 'role'); if ($perm !== '' || $role !== '') { $sawrole = true; } if (in_array($perm, ['host', 'mini-host', self::ROLE_HOST, self::ROLE_PRESENTER], true) || in_array($role, ['host', 'presenter', 'mini-host'], true)) { return true; } $ishost = self::sco_xml_string($row, 'is-host'); if ($ishost === 'true' || $ishost === '1') { return true; } } // Rows without role fields: cannot prove a host is present. return false; } /** * Read attribute or child string from a SimpleXML SCO/row node. * * @param \SimpleXMLElement|object $node */ protected static function sco_xml_string($node, string $name): string { if (isset($node[$name]) && (string)$node[$name] !== '') { return strtolower(trim((string)$node[$name])); } if (isset($node->{$name}) && (string)$node->{$name} !== '') { return strtolower(trim((string)$node->{$name})); } return ''; } /** * List shared meetings on the Connect server (meetings folder). * * @param string $query Optional name filter * @param int $limit Max rows (0 = no limit) * @return array scoid => object{scoid,name,urlpath,datebegin,dateend,datemodified} */ public function list_server_meetings(string $query = '', int $limit = 10): array { $query = trim($query); $items = []; $addfromxml = function($nodes) use (&$items, $query): void { foreach ($nodes as $sco) { $type = strtolower((string)($sco['type'] ?? '')); $icon = strtolower((string)($sco['icon'] ?? '')); if ($type !== 'meeting' && $icon !== 'meeting' && $icon !== 'virtual-classroom') { continue; } $scoid = (int)$sco['sco-id']; if ($scoid <= 0 || isset($items[$scoid])) { continue; } $item = new stdClass(); $item->scoid = $scoid; $item->name = trim((string)($sco->name ?? '')); $item->urlpath = trim((string)($sco->{'url-path'} ?? '')); $item->datebegin = (string)($sco->{'date-begin'} ?? ''); $item->dateend = (string)($sco->{'date-end'} ?? ''); $item->datemodified = (string)($sco->{'date-modified'} ?? $sco->{'date-created'} ?? ''); if ($item->name === '') { $item->name = $item->urlpath !== '' ? $item->urlpath : ('SCO ' . $scoid); } if ($query !== '' && stripos($item->name, $query) === false && stripos($item->urlpath, $query) === false) { continue; } $items[$scoid] = $item; } }; $folders = []; foreach (['meetings', 'user-meetings', 'my-meetings'] as $foldertype) { $folderid = $this->get_folder_scoid($foldertype); if ($folderid) { $folders[$folderid] = true; } } $filters = []; if ($query !== '') { $filters[] = ['filter-type' => 'meeting', 'filter-like-name' => $query]; $filters[] = ['filter-icon' => 'meeting', 'filter-like-name' => $query]; $filters[] = ['filter-icon' => 'virtual-classroom', 'filter-like-name' => $query]; } else { $filters[] = ['filter-type' => 'meeting']; $filters[] = ['filter-icon' => 'meeting']; $filters[] = ['filter-icon' => 'virtual-classroom']; } foreach (array_keys($folders) as $folder) { foreach ($filters as $filter) { // Do not send sort-* params: this Connect host returns status=invalid with them. $params = array_merge([ 'action' => 'sco-contents', 'sco-id' => $folder, ], $filter); if ($limit > 0) { $params['filter-rows'] = max($limit * 5, $limit); } try { $xml = $this->client->request($params); } catch (\Throwable $e) { continue; } if ((string)($xml->status['code'] ?? '') !== 'ok') { continue; } if (!empty($xml->scos->sco)) { $addfromxml($xml->scos->sco); } if ($limit > 0 && count($items) >= $limit * 3) { break 2; } } } // Broad search / report only when folder listing found nothing (or query needs more hits). $needfallback = ($query !== '') ? (count($items) < $limit) : (count($items) === 0); if ($needfallback) { $searchactions = []; if ($query !== '') { $searchactions[] = [ 'action' => 'sco-search-by-field', 'field' => 'name', 'query' => $query, ]; $searchactions[] = [ 'action' => 'sco-search-by-field', 'filter-like-name' => $query, 'query' => $query, ]; $searchactions[] = [ 'action' => 'sco-search', 'query' => $query, 'filter-type' => 'meeting', ]; } else { // Prefer folder listing above; these are last-resort only. $searchactions[] = [ 'action' => 'report-bulk-objects', 'filter-type' => 'meeting', 'filter-rows' => max($limit * 5, $limit), ]; } foreach ($searchactions as $params) { try { $xml = $this->client->request($params); } catch (\Throwable $e) { continue; } if ((string)($xml->status['code'] ?? '') !== 'ok') { continue; } $nodes = $xml->scos->sco ?? $xml->{'sco-search-by-field-info'}->sco ?? $xml->{'sco-search-info'}->sco ?? $xml->{'report-bulk-objects'}->row ?? []; if (!empty($nodes)) { $addfromxml($nodes); } if ($limit > 0 && count($items) >= $limit) { break; } } } uasort($items, static function($a, $b) { $ta = !empty($a->datemodified) ? strtotime((string)$a->datemodified) : 0; $tb = !empty($b->datemodified) ? strtotime((string)$b->datemodified) : 0; if ($ta === $tb) { return strcasecmp((string)$a->name, (string)$b->name); } return $tb <=> $ta; }); if ($limit > 0 && count($items) > $limit) { $items = array_slice($items, 0, $limit, true); } return $items; } /** * Rename a recording (archive) SCO. */ public function rename_sco(int $scoid, string $name): bool { $name = trim($name); if ($name === '') { return false; } $xml = $this->client->request([ 'action' => 'sco-update', 'sco-id' => $scoid, 'name' => $name, ]); return (string)($xml->status['code'] ?? '') === 'ok'; } public function set_meeting_public(int $scoid, bool $public): void { $this->client->request([ 'action' => 'permissions-update', 'acl-id' => $scoid, 'principal-id' => 'public-access', 'permission-id' => $public ? 'view-hidden' : 'denied', ]); } /** * Prefer sco-info (fast) over sco-contents listing. */ public function meeting_info(int $scoid): ?stdClass { $cache = cache::make('mod_meetletacbfree', 'meetinginfo'); $cached = $cache->get((string)$scoid); if (is_object($cached)) { return $cached; } $xml = $this->client->request(['action' => 'sco-info', 'sco-id' => $scoid]); if ((string)$xml->status['code'] !== 'ok' || empty($xml->sco)) { return null; } $info = new stdClass(); $info->scoid = (int)$xml->sco['sco-id']; $info->name = (string)$xml->sco->name; $info->url = (string)$xml->sco->{'url-path'}; $info->starttime = (string)($xml->sco->{'date-begin'} ?? ''); $info->endtime = (string)($xml->sco->{'date-end'} ?? ''); $info->folderid = (int)($xml->sco['folder-id'] ?? 0); $cache->set((string)$scoid, $info); return $info; } /** * Find or create Connect user. Returns object with principalid + login. * * Identity key is Moodle user id → Connect login mlu{id}. Email/username * are only used to find legacy principals and then realign them. */ public function ensure_user(stdClass $user): stdClass { $stable = config::connect_login($user); $email = config::connect_email($user); $lookup = $this->user_login_candidates($user); foreach ($lookup as $logintry) { $found = $this->find_principal_by_login($logintry); if (!$found) { $found = $this->find_principal_by_ext_login($logintry); } if ($found && !$this->is_reserved_connect_login($found->login)) { return $this->align_principal_to_moodle($found, $user); } } if (config::use_email_login()) { $found = $this->find_principal_by_email($email); if ($found && !$this->is_reserved_connect_login($found->login)) { return $this->align_principal_to_moodle($found, $user); } foreach ($lookup as $logintry) { $found = $this->find_principal_like($logintry, $email); if ($found && !$this->is_reserved_connect_login($found->login)) { return $this->align_principal_to_moodle($found, $user); } } } $firstname = trim((string)($user->firstname ?? '')); $lastname = trim((string)($user->lastname ?? '')); if ($firstname === '') { $firstname = 'Moodle'; } if ($lastname === '') { $lastname = 'User' . (int)($user->id ?? 0); } $lastdetail = ''; $login = $stable; $password = 'Ac!' . substr(sha1($login . '|' . (int)$user->id . '|' . microtime(true)), 0, 14) . '9a'; $this->clear_ext_login_conflicts($login, 0); $attempts = [ [ 'action' => 'principal-update', 'first-name' => $firstname, 'last-name' => $lastname, 'login' => $login, 'password' => $password, 'ext-login' => $login, 'extlogin' => $login, 'type' => 'user', 'send-email' => 'false', 'has-children' => 0, 'email' => $email, ], // Second try without ext-login (some Connect builds reject it). [ 'action' => 'principal-update', 'first-name' => $firstname, 'last-name' => $lastname, 'login' => $login, 'password' => $password, 'extlogin' => $login, 'type' => 'user', 'send-email' => 'false', 'has-children' => 0, 'email' => $email, ], ]; foreach ($attempts as $params) { $xml = $this->client->request($params); $code = (string)($xml->status['code'] ?? ''); $sub = (string)($xml->status['subcode'] ?? ''); if ($code === 'ok' && !empty($xml->principal['principal-id'])) { $out = new stdClass(); $out->principalid = (int)$xml->principal['principal-id']; $returnedlogin = (string)($xml->principal->{'ext-login'} ?? $xml->principal->extlogin ?? $xml->principal->login ?? ''); $out->login = $returnedlogin !== '' ? $returnedlogin : $login; return $this->refresh_principal_login($out); } $lastdetail = 'login=' . $login . ' email=' . $email . ' status=' . $code . ($sub !== '' ? '/' . $sub : ''); if (!empty($xml->status->invalid)) { $lastdetail .= ' invalid=' . (string)$xml->status->invalid['field'] . '/' . (string)$xml->status->invalid['type']; } // Duplicate — resolve again (another process may have created it). $found = $this->find_principal_by_login($login); if (!$found && config::use_email_login()) { $found = $this->find_principal_by_email($email); } if (!$found && config::use_email_login()) { $found = $this->find_principal_like($login, $email); } if ($found && !$this->is_reserved_connect_login($found->login)) { return $this->align_principal_to_moodle($found, $user); } } throw new moodle_exception('errorcreateuser', 'meetletacbfree', '', null, $lastdetail); } /** * Re-point a found principal to stable Moodle-id login and refresh profile fields. * If Connect refuses login rename, keep the existing login so SSO still works. */ protected function align_principal_to_moodle(stdClass $adobeuser, stdClass $user): stdClass { // Safety net — callers should already skip reserved logins. if ($this->is_reserved_connect_login((string)($adobeuser->login ?? ''))) { throw new moodle_exception( 'errorcreateuser', 'meetletacbfree', '', null, 'refusing to align reserved principal login=' . $adobeuser->login ); } $stable = config::connect_login($user); $email = config::connect_email($user); $firstname = trim((string)($user->firstname ?? '')); $lastname = trim((string)($user->lastname ?? '')); if ($firstname === '') { $firstname = 'Moodle'; } if ($lastname === '') { $lastname = 'User' . (int)($user->id ?? 0); } $params = [ 'action' => 'principal-update', 'principal-id' => (int)$adobeuser->principalid, 'first-name' => $firstname, 'last-name' => $lastname, 'email' => $email, ]; $renamelogin = (strcasecmp((string)$adobeuser->login, $stable) !== 0); if ($renamelogin) { $this->clear_ext_login_conflicts($stable, (int)$adobeuser->principalid); $params['login'] = $stable; $params['ext-login'] = $stable; $params['extlogin'] = $stable; } $xml = $this->client->request($params); if ((string)($xml->status['code'] ?? '') === 'ok') { if ($renamelogin) { $adobeuser->login = $stable; } return $this->refresh_principal_login($adobeuser); } // Login rename rejected — update profile, then best-effort ext-login for header SSO. if ($renamelogin) { $profile = $params; unset($profile['login'], $profile['ext-login'], $profile['extlogin']); $this->client->request($profile); try { $this->clear_ext_login_conflicts($stable, (int)$adobeuser->principalid); $this->client->request([ 'action' => 'principal-update', 'principal-id' => (int)$adobeuser->principalid, 'ext-login' => $stable, 'extlogin' => $stable, ]); } catch (\Throwable $e) { // SSO will fall back to legacy login candidates. } } return $this->refresh_principal_login($adobeuser); } /** * Re-read principal login/ext-login from Connect after create or align. */ public function refresh_principal_login(stdClass $adobeuser): stdClass { $pid = (int)($adobeuser->principalid ?? 0); if ($pid <= 0) { return $adobeuser; } try { $xml = $this->client->request([ 'action' => 'principal-list', 'filter-principal-id' => $pid, ]); } catch (\Throwable $e) { return $adobeuser; } if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { return $adobeuser; } foreach ($xml->{'principal-list'}->principal as $p) { if ((int)($p['principal-id'] ?? 0) !== $pid) { continue; } if (!$this->is_sso_eligible_user_principal($p)) { break; } $internallogin = trim((string)($p->login ?? '')); $extlogin = trim((string)($p->{'ext-login'} ?? '')); if ($extlogin === '') { $extlogin = trim((string)($p->extlogin ?? '')); } // Header SSO matches ext-login when Adobe stores one separately from login. $ssologin = $extlogin !== '' ? $extlogin : $internallogin; if ($ssologin !== '' && !$this->is_reserved_connect_login($ssologin)) { $adobeuser->login = $ssologin; } if ($internallogin !== '' && strcasecmp($internallogin, $ssologin) !== 0 && !$this->is_reserved_connect_login($internallogin)) { $adobeuser->internallogin = $internallogin; } else { unset($adobeuser->internallogin); } break; } return $adobeuser; } /** * Ensure user exists and grant meeting permission (admin API). */ public function provision_user_on_meeting(stdClass $user, int $scoid, string $permission): stdClass { $adobeuser = $this->ensure_user($user); $adobeuser = $this->refresh_principal_login($adobeuser); $this->assign_permission((int)$adobeuser->principalid, $scoid, $permission); $info = $this->meeting_info($scoid); if ($info && !empty($info->folderid)) { $this->assign_permission((int)$adobeuser->principalid, (int)$info->folderid, $permission); } return $adobeuser; } /** * Ordered login candidates for Connect principal lookup (stable first, then legacy). * * @return string[] */ protected function user_login_candidates(stdClass $user): array { $out = [config::connect_login($user)]; if (!config::use_email_login()) { return $out; } foreach (config::legacy_connect_logins($user) as $candidate) { if (!in_array($candidate, $out, true)) { $out[] = $candidate; } } return $out; } public function find_principal_by_login(string $login): ?stdClass { if (trim($login) === '') { return null; } $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-login' => $login, ]); // Exact login match only — never accept the first fuzzy row. return $this->parse_principal($xml, $login, true); } /** * Find a user principal whose ext-login matches exactly (header SSO identity). */ public function find_principal_by_ext_login(string $extlogin): ?stdClass { $extlogin = trim($extlogin); if ($extlogin === '') { return null; } $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-like-login' => $extlogin, ]); if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { return null; } foreach ($xml->{'principal-list'}->principal as $p) { if (!$this->is_sso_eligible_user_principal($p)) { continue; } $ext = trim((string)($p->{'ext-login'} ?? $p->extlogin ?? '')); if ($ext !== '' && strcasecmp($ext, $extlogin) === 0) { return $this->principal_from_xml($p); } } return null; } /** * Clear ext-login from other principals so header SSO is unambiguous. */ protected function clear_ext_login_conflicts(string $extlogin, int $keepprincipalid): void { $extlogin = trim($extlogin); if ($extlogin === '' || $keepprincipalid <= 0) { return; } try { $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-like-login' => $extlogin, ]); } catch (\Throwable $e) { return; } if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->{'principal-list'}->principal)) { return; } foreach ($xml->{'principal-list'}->principal as $p) { $pid = (int)($p['principal-id'] ?? 0); if ($pid <= 0 || $pid === $keepprincipalid) { continue; } if (!$this->is_sso_eligible_user_principal($p)) { continue; } $ext = trim((string)($p->{'ext-login'} ?? $p->extlogin ?? '')); if ($ext === '' || strcasecmp($ext, $extlogin) !== 0) { continue; } try { $this->client->request([ 'action' => 'principal-update', 'principal-id' => $pid, 'ext-login' => '', 'extlogin' => '', ]); } catch (\Throwable $e) { // Best effort — customer may need to clean duplicates manually. } } } public function find_principal_by_email(string $email): ?stdClass { if (trim($email) === '') { return null; } $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-email' => $email, ]); return $this->parse_principal($xml, $email, false); } /** * Broader principal lookup when exact filters miss. * * Adobe filter-like-login is substring-based ("admin" matches group * "Administrators"). We only accept real users with an exact login/email. */ protected function find_principal_like(string $login, string $email): ?stdClass { foreach (array_unique(array_filter([$login, $email])) as $needle) { // Short bare usernames are too ambiguous for substring search. if (strpos($needle, '@') === false && strlen($needle) < 5) { continue; } $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-like-login' => $needle, ]); $found = $this->parse_principal($xml, $needle, true); if ($found) { return $found; } if (strpos($needle, '@') !== false) { $xml = $this->client->request([ 'action' => 'principal-list', 'filter-type' => 'user', 'filter-like-email' => $needle, ]); $found = $this->parse_principal($xml, $needle, false); if ($found) { return $found; } } } return null; } /** * Parse principal-list response into a user principal only. * * @param \SimpleXMLElement $xml * @param string $fallbacklogin * @param bool $requireexactlogin When true, login/ext-login must equal $fallbacklogin. */ protected function parse_principal($xml, string $fallbacklogin, bool $requireexactlogin = false): ?stdClass { if ((string)$xml->status['code'] !== 'ok' || empty($xml->{'principal-list'}->principal)) { return null; } foreach ($xml->{'principal-list'}->principal as $p) { if (!$this->is_sso_eligible_user_principal($p)) { continue; } $candidate = $this->principal_from_xml($p); if ($requireexactlogin && !$this->principal_matches_login($p, $fallbacklogin)) { continue; } if ($candidate->login === '' && $fallbacklogin !== '') { $candidate->login = $fallbacklogin; } return $candidate; } return null; } /** * Build principal object preferring ext-login for header SSO. * * @param \SimpleXMLElement $p */ protected function principal_from_xml($p): stdClass { $internallogin = trim((string)($p->login ?? '')); $extlogin = trim((string)($p->{'ext-login'} ?? '')); if ($extlogin === '') { $extlogin = trim((string)($p->extlogin ?? '')); } $ssologin = $extlogin !== '' ? $extlogin : $internallogin; $out = new stdClass(); $out->principalid = (int)$p['principal-id']; $out->login = $ssologin; if ($internallogin !== '' && strcasecmp($internallogin, $ssologin) !== 0 && !$this->is_reserved_connect_login($internallogin)) { $out->internallogin = $internallogin; } return $out; } /** * Whether a principal row matches a login or ext-login exactly. * * @param \SimpleXMLElement $p */ protected function principal_matches_login($p, string $needle): bool { $needle = trim($needle); if ($needle === '') { return false; } foreach ([(string)($p->login ?? ''), (string)($p->{'ext-login'} ?? ''), (string)($p->extlogin ?? '')] as $field) { $field = trim($field); if ($field !== '' && strcasecmp($field, $needle) === 0) { return true; } } return false; } /** * Groups / built-in Adobe principals cannot be used for header SSO. * * @param \SimpleXMLElement $p */ protected function is_sso_eligible_user_principal($p): bool { $type = strtolower(trim((string)($p['type'] ?? 'user'))); if ($type !== '' && $type !== 'user') { return false; } $haschildren = strtolower(trim((string)($p['has-children'] ?? 'false'))); if (in_array($haschildren, ['true', '1', 'yes'], true)) { return false; } $login = strtolower(trim((string)($p->login ?? $p->{'ext-login'} ?? ''))); if ($this->is_reserved_connect_login($login)) { return false; } return true; } /** * Built-in Adobe Connect group / role logins that must never be used for SSO. */ protected function is_reserved_connect_login(string $login): bool { $login = strtolower(trim($login)); if ($login === '') { return false; } $reserved = [ 'administrators', 'authors', 'everyone', 'live-admins', 'admins', 'course-admins', 'event-administrators', 'event-managers', 'learners', 'seminar-administrators', ]; return in_array($login, $reserved, true); } public function assign_permission(int $principalid, int $scoid, string $permission): bool { $xml = $this->client->request([ 'action' => 'permissions-update', 'acl-id' => $scoid, 'principal-id' => $principalid, 'permission-id' => $permission, ]); return (string)$xml->status['code'] === 'ok'; } /** * List recordings for a meeting sco. * * @return array of stdClass */ public function list_recordings(int $meetingscoid): array { $items = []; $xml = $this->client->request([ 'action' => 'sco-contents', 'sco-id' => $meetingscoid, 'filter-icon' => 'archive', ]); if (!empty($xml->scos->sco)) { foreach ($xml->scos->sco as $sco) { $items[] = $this->sco_to_recording($sco, $meetingscoid); } } // Also check forced-archives folder for linked recordings. $forced = $this->get_folder_scoid('forced-archives'); if ($forced) { $xml2 = $this->client->request([ 'action' => 'sco-contents', 'sco-id' => $forced, 'filter-icon' => 'archive', ]); if (!empty($xml2->scos->sco)) { foreach ($xml2->scos->sco as $sco) { $source = (int)($sco['source-sco-id'] ?? 0); if ($source === $meetingscoid) { $items[(int)$sco['sco-id']] = $this->sco_to_recording($sco, $meetingscoid); } } } } // Reindex unique by scoid. $unique = []; foreach ($items as $item) { $unique[$item->scoid] = $item; } return array_values($unique); } protected function sco_to_recording($sco, int $meetingscoid): stdClass { $rec = new stdClass(); $rec->scoid = (int)$sco['sco-id']; $rec->meetingscoid = $meetingscoid; $rec->name = (string)$sco->name; $rec->urlpath = (string)$sco->{'url-path'}; $rec->duration = (int)($sco['duration'] ?? 0); $rec->datebegin = (string)($sco->{'date-begin'} ?? $sco->{'date-created'} ?? ''); $rec->dateend = (string)($sco->{'date-end'} ?? ''); $rec->icon = (string)($sco['icon'] ?? 'archive'); return $rec; } /** * True when a sco-contents row is a recording/archive, not uploaded meeting content. * * @param \SimpleXMLElement|null $sco */ protected function sco_is_recording_sco($sco, string $icon = '', string $type = ''): bool { if ($sco !== null) { if ($icon === '') { $icon = strtolower(trim((string)($sco['icon'] ?? ''))); } if ($type === '') { $type = strtolower(trim((string)($sco['type'] ?? ''))); } } $scotype = ''; if ($sco !== null) { $scotype = strtolower(trim((string)($sco['sco-type'] ?? ''))); } $skipicons = ['archive', 'recording', 'mp4-archive', 'folder']; $skiptypes = [ 'recording', 'archive', 'meeting-recording', 'session-recording', 'meeting', 'folder', 'virtual-classroom', ]; if (in_array($icon, $skipicons, true) || in_array($type, $skiptypes, true)) { return true; } if ($scotype !== '') { $scotypeskip = [ 'recording', 'archive', 'meeting-recording', 'session-recording', 'meeting', 'folder', ]; if (in_array($scotype, $scotypeskip, true)) { return true; } } // Connect recordings expose session window + duration; uploaded files do not. if ($sco !== null) { $duration = (int)($sco['duration'] ?? 0); $datebegin = trim((string)($sco->{'date-begin'} ?? '')); $dateend = trim((string)($sco->{'date-end'} ?? '')); if ($duration > 0 && $datebegin !== '' && $dateend !== '') { $name = trim((string)($sco->name ?? '')); if (!preg_match('/\.[a-z0-9]{2,5}$/i', $name)) { return true; } } } return false; } /** * Content uploaded inside a meeting folder (non-archive). * Filters out folders and Adobe placeholder SCOs named like "/abc123/". */ public function list_meeting_content(int $meetingscoid): array { $items = []; $recordingscoids = []; foreach ($this->list_recordings($meetingscoid) as $rec) { $recordingscoids[(int)$rec->scoid] = true; } $xml = $this->client->request([ 'action' => 'sco-contents', 'sco-id' => $meetingscoid, ]); if (empty($xml->scos->sco)) { return $items; } foreach ($xml->scos->sco as $sco) { $scoid = (int)$sco['sco-id']; if (isset($recordingscoids[$scoid])) { continue; } $icon = strtolower(trim((string)($sco['icon'] ?? ''))); $type = strtolower(trim((string)($sco['type'] ?? ''))); $name = trim((string)($sco->name ?? '')); $urlpath = trim((string)($sco->{'url-path'} ?? '')); // Recordings / nested meetings / folders are not "uploaded content". if ($this->sco_is_recording_sco($sco, $icon, $type)) { continue; } // Adobe often lists a content SCO whose title is just the room url-path. if ($name === '' || $name === $urlpath || preg_match('#^/[A-Za-z0-9_-]+/?$#', $name)) { if (preg_match('#/([^/]+\.(jpe?g|png|gif|webp|bmp|pdf|pptx?|docx?|xlsx?|mp4|zip|mp3))$#i', $urlpath, $m)) { $name = rawurldecode($m[1]); } else { continue; } } $bytesize = $this->sco_byte_size($sco); $datemod = trim((string)($sco->{'date-modified'} ?? '')); $datecreated = trim((string)($sco->{'date-created'} ?? $sco->{'date-begin'} ?? '')); $datesource = $datemod !== '' ? $datemod : $datecreated; // sco-contents often omits size — enrich from sco-info. if ($bytesize <= 0 || $icon === '' || $icon === 'content') { $detail = $this->sco_content_detail((int)$sco['sco-id']); if ($detail) { if ($bytesize <= 0 && !empty($detail->bytesize)) { $bytesize = (int)$detail->bytesize; } if (($icon === '' || $icon === 'content') && !empty($detail->icon)) { $icon = $detail->icon; } if ($type === '' || $type === 'content') { $type = $detail->type !== '' ? $detail->type : $type; } if ($datesource === '' && !empty($detail->datemodified)) { $datesource = $detail->datemodified; } } } // Infer icon from filename when API only says "content". if (($icon === '' || $icon === 'content') && preg_match('/\.([a-z0-9]+)$/i', $name, $m)) { $ext = strtolower($m[1]); $extmap = [ 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'gif' => 'image', 'webp' => 'image', 'bmp' => 'image', 'pdf' => 'pdf', 'ppt' => 'presentation', 'pptx' => 'presentation', 'mp3' => 'mp3', 'mp4' => 'mp4', 'flv' => 'flv', 'zip' => 'zip', 'swf' => 'swf', 'html' => 'html', 'htm' => 'html', ]; if (isset($extmap[$ext])) { $icon = $extmap[$ext]; } } // sco-info can reveal archive/recording icon after an initial "content" placeholder. if ($this->sco_is_recording_sco($sco, $icon, $type)) { continue; } $item = new stdClass(); $item->scoid = (int)$sco['sco-id']; $item->name = $name; $item->urlpath = $urlpath; $item->icon = $icon; $item->type = $type !== '' ? $type : $icon; $item->typelabel = self::content_type_label($icon, $type, $name); if ($bytesize <= 0) { $bytesize = $this->fetch_output_bytesize($urlpath, $name); } $item->bytesize = $bytesize; $item->datecreated = $datesource; $item->datecreated_fmt = $this->format_connect_date($datesource); $item->url = config::public_url($item->urlpath); $items[] = $item; } return $items; } /** * Human label for uploaded content type (icon / extension based). */ public static function content_type_label(string $icon, string $type, string $name = ''): string { $candidates = []; $icon = strtolower(trim($icon)); $type = strtolower(trim($type)); if ($icon !== '') { $candidates[] = $icon; } if ($type !== '' && $type !== $icon) { $candidates[] = $type; } if (preg_match('/\.([a-z0-9]+)$/i', $name, $m)) { $candidates[] = strtolower($m[1]); } foreach ($candidates as $key) { $stringkey = 'contenticon_' . $key; if (get_string_manager()->string_exists($stringkey, 'meetletacbfree')) { return get_string($stringkey, 'meetletacbfree'); } } return $icon !== '' && $icon !== 'content' ? $icon : get_string('contenticon_content', 'meetletacbfree'); } /** * Read byte size from sco XML (attribute or child, several Connect variants). * * @param \SimpleXMLElement $sco */ protected function sco_byte_size($sco): int { $names = ['byte-count', 'byte-size', 'bytes', 'size', 'file-size']; foreach ($names as $name) { if (isset($sco[$name]) && is_numeric((string)$sco[$name])) { return (int)$sco[$name]; } if (isset($sco->{$name}) && is_numeric((string)$sco->{$name})) { return (int)$sco->{$name}; } } return 0; } /** * Extra metadata for one content SCO. */ protected function sco_content_detail(int $scoid): ?stdClass { $xml = $this->client->request(['action' => 'sco-info', 'sco-id' => $scoid]); if ((string)($xml->status['code'] ?? '') !== 'ok' || empty($xml->sco)) { return null; } $sco = $xml->sco; $out = new stdClass(); $out->icon = strtolower(trim((string)($sco['icon'] ?? ''))); $out->type = strtolower(trim((string)($sco['type'] ?? ''))); $out->bytesize = $this->sco_byte_size($sco); $out->datemodified = trim((string)($sco->{'date-modified'} ?? $sco->{'date-created'} ?? '')); return $out; } /** * Adobe stores binaries under {url-path}/output/{filename}; XML often omits byte-count. */ protected function fetch_output_bytesize(string $urlpath, string $filename): int { $filename = basename(str_replace('\\', '/', $filename)); if ($filename === '' || strpos($filename, '/') !== false) { return 0; } if (!preg_match('/\.[a-z0-9]{2,5}$/i', $filename)) { return 0; } $base = '/' . trim($urlpath, '/'); if ($base === '/') { return 0; } $url = config::public_url($base . '/output/' . rawurlencode($filename)); $cookie = $this->client->get_cookie(); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 12, CURLOPT_COOKIE => $cookie !== '' ? ('BREEZESESSION=' . $cookie) : '', ]); $headers = curl_exec($ch); $len = (int)curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($len <= 0 && is_string($headers) && preg_match('/Content-Length:\s*(\d+)/i', $headers, $m)) { $len = (int)$m[1]; } if ($code >= 200 && $code < 400 && $len > 0) { return $len; } return 0; } /** * Schedule server-side MP4 job (requires encoder). */ public function schedule_offline_job(int $recordingscoid): array { $xml = $this->client->request([ 'action' => 'schedule-recording-job', 'source-sco-id' => $recordingscoid, ]); $code = (string)$xml->status['code']; $sub = (string)($xml->status['subcode'] ?? ''); return [ 'ok' => $code === 'ok', 'code' => $code, 'subcode' => $sub, 'raw' => $this->client->get_last_response(), ]; } public function list_recording_jobs(int $scoid): array { $xml = $this->client->request([ 'action' => 'list-recording-jobs', 'sco-id' => $scoid, ]); $jobs = []; if (!empty($xml->{'recording-jobs'}->{'recording-job'})) { foreach ($xml->{'recording-jobs'}->{'recording-job'} as $job) { $jobs[] = [ 'scoid' => (int)($job['sco-id'] ?? 0), 'status' => (string)($job['job-status'] ?? $job['status'] ?? ''), ]; } } return $jobs; } /** * Find MP4 child under a recording sco. */ public function find_mp4_child(int $recordingscoid): ?stdClass { $xml = $this->client->request([ 'action' => 'sco-contents', 'sco-id' => $recordingscoid, ]); if (empty($xml->scos->sco)) { return null; } foreach ($xml->scos->sco as $sco) { $icon = (string)($sco['icon'] ?? ''); if ($icon === 'mp4' || $icon === 'mp4-archive') { $out = new stdClass(); $out->scoid = (int)$sco['sco-id']; $out->urlpath = (string)$sco->{'url-path'}; $out->name = (string)$sco->name; return $out; } } return null; } /** * Attendance rows for a meeting, grouped by session. * * @return array{sessions:array,attendees:array} */ public function get_attendance(int $meetingscoid): array { $sessions = []; $xml = $this->client->request([ 'action' => 'report-meeting-sessions', 'sco-id' => $meetingscoid, ]); if (!empty($xml->{'report-meeting-sessions'}->row)) { foreach ($xml->{'report-meeting-sessions'}->row as $row) { $assetid = (string)($row['asset-id'] ?? ''); $datecreated = (string)($row->{'date-created'} ?? ''); $dateend = (string)($row->{'date-end'} ?? ''); $sessions[] = [ 'scoid' => (int)($row['sco-id'] ?? $meetingscoid), 'assetid' => $assetid, 'numparticipants' => (int)($row['num-participants'] ?? 0), 'datecreated' => $datecreated, 'dateend' => $dateend, 'datecreated_fmt' => $this->format_connect_date($datecreated), 'dateend_fmt' => $this->format_connect_date($dateend), 'attendees' => $this->get_session_attendees($meetingscoid, $assetid, $datecreated, $dateend), ]; } } // Flat list kept for compatibility / empty-session fallback. $people = []; foreach ($sessions as $session) { foreach ($session['attendees'] as $person) { $key = ($person['login'] !== '' ? $person['login'] : $person['name']) . '|' . ($person['timein'] ?? ''); $people[$key] = $person; } } if (empty($sessions)) { $xml2 = $this->client->request([ 'action' => 'report-bulk-consolidated-transactions', 'filter-type' => 'meeting', 'filter-sco-id' => $meetingscoid, ]); if (!empty($xml2->{'report-bulk-consolidated-transactions'}->row)) { foreach ($xml2->{'report-bulk-consolidated-transactions'}->row as $row) { $login = (string)($row->login ?? ''); $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); $key = $login !== '' ? $login : $name; if (!isset($people[$key])) { $people[$key] = [ 'login' => $login, 'name' => $name, 'status' => 'present', 'timein' => $this->format_connect_date((string)($row->{'date-created'} ?? '')), 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? '')), ]; } } } } return [ 'sessions' => $sessions, 'attendees' => array_values($people), ]; } /** * Attendees for one meeting session (asset). * * @return array */ public function get_session_attendees(int $meetingscoid, string $assetid, string $datecreated = '', string $dateend = ''): array { $people = []; if ($assetid !== '') { $xml = $this->client->request([ 'action' => 'report-event-participants', 'sco-id' => $meetingscoid, 'asset-id' => $assetid, ]); $rows = $xml->{'report-event-participants'}->row ?? $xml->{'report-meeting-attendance'}->row ?? null; if (!empty($rows)) { foreach ($rows as $row) { $login = (string)($row->login ?? ''); $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); $key = $login !== '' ? $login : $name . '|' . (string)($row->{'date-created'} ?? ''); $people[$key] = [ 'login' => $login, 'name' => $name !== '' ? $name : $login, 'status' => 'present', 'timein' => $this->format_connect_date((string)($row->{'date-created'} ?? $row->{'first-time'} ?? '')), 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? $row->{'last-time'} ?? '')), ]; } } } if (!empty($people)) { return array_values($people); } // Fallback: filter consolidated transactions by session window. $xml2 = $this->client->request([ 'action' => 'report-bulk-consolidated-transactions', 'filter-type' => 'meeting', 'filter-sco-id' => $meetingscoid, ]); if (empty($xml2->{'report-bulk-consolidated-transactions'}->row)) { return []; } $startts = $datecreated !== '' ? strtotime($datecreated) : 0; $endts = $dateend !== '' ? strtotime($dateend) : 0; foreach ($xml2->{'report-bulk-consolidated-transactions'}->row as $row) { $created = (string)($row->{'date-created'} ?? ''); $createdts = $created !== '' ? strtotime($created) : 0; if ($startts && $createdts && $createdts < ($startts - 120)) { continue; } if ($endts && $createdts && $createdts > ($endts + 120)) { continue; } $login = (string)($row->login ?? ''); $name = trim((string)($row->{'user-name'} ?? $row->name ?? $login)); $key = $login !== '' ? $login : $name . '|' . $created; $people[$key] = [ 'login' => $login, 'name' => $name !== '' ? $name : $login, 'status' => 'present', 'timein' => $this->format_connect_date($created), 'timeout' => $this->format_connect_date((string)($row->{'date-closed'} ?? '')), ]; } return array_values($people); } /** * Pretty-print Connect ISO dates for UI. */ protected function format_connect_date(string $value): string { $value = trim($value); if ($value === '') { return ''; } $ts = strtotime($value); if (!$ts) { return $value; } return userdate($ts); } public function delete_sco(int $scoid): bool { $xml = $this->client->request(['action' => 'sco-delete', 'sco-id' => $scoid]); return (string)$xml->status['code'] === 'ok'; } public static function format_date(int $timestamp): string { // Adobe expects ISO8601 with timezone offset. return date('c', $timestamp); } /** * Build zip download URL for an archive. */ public static function zip_url(string $urlpath): string { $path = trim($urlpath, '/'); $segment = $path; if (strpos($path, '/') !== false) { $parts = explode('/', $path); $segment = end($parts); } return config::public_url('/' . $path . '/output/' . $segment . '.zip?download=zip'); } }