91334a46b5
Co-authored-by: Cursor <cursoragent@cursor.com>
1643 lines
61 KiB
PHP
1643 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 => false,
|
|
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');
|
|
}
|
|
}
|