Files
meetletacbfree/classes/api/service.php
T
mrxzamani be252e49e2 Address Moodle Marketplace review for mod_meetletacbfree 1.1.3.
Migrate legacy ajax/JS to external services and AMD, enable TLS verification, scope admin CSS with RTLCSS-safe rules, and fix README/lang/boilerplate gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 12:41:47 +03:30

1644 lines
61 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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<int,object> 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<int,stdClass> 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<int,array{login:string,name:string,timein:string,timeout:string,status:string}>
*/
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');
}
}