Adobe Connect Bridge Marketplace edition — initial release

This commit is contained in:
2026-09-01 22:43:54 +03:30
commit 555cd725f7
93 changed files with 20833 additions and 0 deletions
+655
View File
@@ -0,0 +1,655 @@
<?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/>.
/**
* Plugin config helpers.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
/**
* Read MeetLet / Adobe Connect global settings.
*/
class config {
/** Activity follows site-wide setting. */
public const OVERRIDE_INHERIT = 0;
/** Activity explicitly disables the setting. */
public const OVERRIDE_OFF = 1;
/** Activity explicitly enables the setting. */
public const OVERRIDE_ON = 2;
/**
* API endpoint URL (may already include https://.../api/xml).
*/
public static function get_api_url(): string {
return trim((string)get_config('meetletacbmarketplace', 'host'));
}
/**
* Meeting host domain (no protocol).
*/
public static function get_meet_host(): string {
return trim((string)get_config('meetletacbmarketplace', 'meethost'));
}
public static function get_port(): int {
$port = (int)get_config('meetletacbmarketplace', 'port');
return $port > 0 ? $port : 443;
}
public static function use_https(): bool {
return (bool)get_config('meetletacbmarketplace', 'https');
}
public static function get_admin_login(): string {
return (string)get_config('meetletacbmarketplace', 'admin_login');
}
public static function get_admin_password(): string {
return (string)get_config('meetletacbmarketplace', 'admin_password');
}
public static function get_http_auth_header(): string {
$h = trim((string)get_config('meetletacbmarketplace', 'admin_httpauth'));
return $h !== '' ? $h : 'Generate-Safe-Random-Token';
}
/**
* When true, also look up legacy Connect accounts by email/username
* and realign them to mlu{id}. SSO identity itself is always Moodle id.
*/
public static function use_email_login(): bool {
return (bool)get_config('meetletacbmarketplace', 'email_login');
}
/**
* Site-wide auto MP4 after Adobe Connect class actually ends (room empty).
* Per-activity checkbox is an extra opt-in when this is off.
*/
public static function auto_offline_default(): bool {
if (!self::offline_render_active()) {
return false;
}
return (bool)get_config('meetletacbmarketplace', 'auto_offline');
}
/**
* Minutes between recording-sync / auto-MP4 cron runs (1–60, default 5).
*/
public static function cron_interval_minutes(): int {
$v = (int)get_config('meetletacbmarketplace', 'cron_interval');
if ($v < 1) {
$v = 5;
}
if ($v > 60) {
$v = 60;
}
return $v;
}
/** Send collected jobs with every MeetLet cron run. */
public const DISPATCH_IMMEDIATE = 'immediate';
/** Send collected jobs only during selected site-timezone hours. */
public const DISPATCH_WINDOW = 'window';
/** Default overnight hours (22:00–05:59). */
public const DEFAULT_DISPATCH_HOURS = '22,23,0,1,2,3,4,5';
/**
* How auto-MP4 jobs are handed to the Agent.
*/
public static function agent_dispatch_mode(): string {
$v = trim((string)get_config('meetletacbmarketplace', 'agent_dispatch'));
return $v === self::DISPATCH_WINDOW ? self::DISPATCH_WINDOW : self::DISPATCH_IMMEDIATE;
}
/**
* Selected hours (0–23) in the site timezone. Empty means “none”.
*
* @return int[]
*/
public static function agent_dispatch_hours(): array {
$raw = (string)get_config('meetletacbmarketplace', 'agent_dispatch_hours');
if ($raw === '') {
$raw = self::DEFAULT_DISPATCH_HOURS;
}
return self::parse_dispatch_hours($raw);
}
/**
* @return int[]
*/
public static function parse_dispatch_hours(string $raw): array {
$hours = [];
foreach (preg_split('/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $bit) {
if (!preg_match('/^\d{1,2}$/', $bit)) {
continue;
}
$h = (int)$bit;
if ($h >= 0 && $h <= 23) {
$hours[$h] = $h;
}
}
$hours = array_values($hours);
sort($hours);
return $hours;
}
/**
* Current hour 0–23 in the Moodle server / site timezone.
*/
public static function site_hour_now(): int {
$tz = \core_date::get_server_timezone_object();
return (int)(new \DateTimeImmutable('now', $tz))->format('G');
}
/**
* True when collected jobs may be POSTed to the Agent.
*/
public static function agent_send_allowed_now(): bool {
if (self::agent_dispatch_mode() !== self::DISPATCH_WINDOW) {
return true;
}
$hours = self::agent_dispatch_hours();
if (!$hours) {
return false;
}
return in_array(self::site_hour_now(), $hours, true);
}
/**
* Unix time of the next allowed send hour start, or 0 if sending is allowed now / immediate.
*/
public static function agent_send_next_open(): int {
if (self::agent_send_allowed_now()) {
return 0;
}
$hours = self::agent_dispatch_hours();
if (!$hours) {
return 0;
}
$tz = \core_date::get_server_timezone_object();
$now = new \DateTimeImmutable('now', $tz);
$cur = (int)$now->format('G');
foreach ($hours as $h) {
if ($h > $cur) {
return $now->setTime($h, 0, 0)->getTimestamp();
}
}
return $now->modify('+1 day')->setTime($hours[0], 0, 0)->getTimestamp();
}
/**
* Write the scheduled-task minute field from plugin settings.
*/
public static function apply_cron_interval(): void {
global $DB;
$mins = self::cron_interval_minutes();
$row = $DB->get_record('task_scheduled', ['classname' => '\\mod_meetletacbmarketplace\\task\\sync_recordings']);
if (!$row) {
return;
}
$row->minute = $mins <= 1 ? '*' : ('*/' . $mins);
$row->nextruntime = time() + ($mins * MINSECS);
$DB->update_record('task_scheduled', $row);
}
/**
* Offline MP4 is Premium-only: paid license + mp4_offline entitlement.
* Free / unlicensed sites never activate Agent auto/manual MP4 paths.
*/
public static function offline_render_active(): bool {
return false;
}
/**
* Configured Render Agent / offline listener base URL (may be empty).
*/
public static function agent_url(): string {
return trim((string)get_config('meetletacbmarketplace', 'agent_url'));
}
/**
* Site-wide default meeting UI language (Adobe Connect), empty = server default.
*/
public static function default_meeting_lang(): string {
$v = get_config('meetletacbmarketplace', 'default_meetinglang');
if ($v === false || $v === null) {
return '';
}
return clean_param((string)$v, PARAM_ALPHANUMEXT);
}
/**
* Site-wide default: block participants until host joins.
*/
public static function default_wait_for_host(): bool {
return (bool)get_config('meetletacbmarketplace', 'default_waitforhost');
}
/**
* Language choices for meeting UI (site + activity forms).
*
* @return array<string,string> code => label
*/
public static function meeting_lang_options(): array {
return [
'' => get_string('meetinglang_default', 'meetletacbmarketplace'),
'en' => 'English',
'fa' => 'فارسی',
'ar' => 'العربية',
'de' => 'Deutsch',
'fr' => 'Français',
'es' => 'Español',
'tr' => 'Türkçe',
];
}
/**
* Whether the manual “request offline video” button is allowed (optionally per activity).
*
* @param \stdClass|null $activity
*/
public static function allow_manual_offline(?\stdClass $activity = null): bool {
if (!self::offline_render_active()) {
return false;
}
return self::show_feature('makeoffline', $activity);
}
/**
* Whether a UI feature is enabled globally.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
*/
public static function feature_enabled(string $feature): bool {
// Offline player (pbMode) removed from product — always hidden.
if ($feature === 'offlineplayer') {
return false;
}
if (($feature === 'mp4' || $feature === 'makeoffline') && !self::offline_render_active()) {
return false;
}
$key = 'enable_' . $feature;
$v = get_config('meetletacbmarketplace', $key);
if ($v === false && $feature === 'mp4') {
$legacy = get_config('meetletacbmarketplace', 'allow_manual_offline');
return $legacy === false ? true : (bool)$legacy;
}
// ZIP is opt-in; other features default on when unset.
if ($v === false) {
return $feature !== 'zip';
}
return (bool)$v;
}
/**
* Whether teachers may override a feature per activity.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
*/
public static function feature_allow_override(string $feature): bool {
$key = 'allow_override_' . $feature;
$v = get_config('meetletacbmarketplace', $key);
return $v === false ? true : (bool)$v;
}
/**
* Effective visibility for a feature on an activity instance (site + activity switches only).
* Always combine with featureaccess::allowed() / the role matrix for user actions.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
* @param \stdClass|null $activity
*/
public static function show_feature(string $feature, ?\stdClass $activity = null): bool {
if (!self::feature_enabled($feature)) {
return false;
}
if (!self::feature_allow_override($feature) || $activity === null) {
return true;
}
$fieldmap = [
'zip' => 'showzip',
'offlineplayer' => 'showofflineplayer',
'recordingstab' => 'showrecordingstab',
'contenttab' => 'showcontenttab',
'mp4' => 'showmp4',
'makeoffline' => 'showmakeoffline',
];
$field = $fieldmap[$feature] ?? null;
if ($field === null) {
return true;
}
$siteenabled = true;
if ($field === 'showofflineplayer') {
$siteenabled = false;
}
return self::resolve_activity_toggle($activity, $field, $siteenabled);
}
/**
* Effective wait-for-host value for one activity.
*/
public static function wait_for_host(?\stdClass $activity = null): bool {
$sitevalue = self::default_wait_for_host();
return self::resolve_activity_toggle($activity, 'waitforhost', $sitevalue);
}
/**
* Effective automatic offline-video value for one activity.
*/
public static function auto_offline_enabled(?\stdClass $activity = null): bool {
if (!self::offline_render_active()) {
return false;
}
return self::resolve_activity_toggle($activity, 'autooffline', self::auto_offline_default());
}
/**
* Three-state options for inherit / off / on controls.
*
* @return array<int,string>
*/
public static function override_state_options(): array {
return [
self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbmarketplace'),
self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbmarketplace'),
self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbmarketplace'),
];
}
/**
* Read JSON override map stored on the activity row.
*
* @return array<string,int>
*/
public static function activity_setting_overrides(?\stdClass $activity): array {
if (!$activity || empty($activity->settingoverrides)) {
return [];
}
$decoded = json_decode((string)$activity->settingoverrides, true);
if (!is_array($decoded)) {
return [];
}
$out = [];
foreach ($decoded as $key => $value) {
$state = self::normalize_override_state($value);
if ($state !== self::OVERRIDE_INHERIT) {
$out[(string)$key] = $state;
}
}
return $out;
}
/**
* Effective state for one activity toggle.
*/
public static function activity_override_state(?\stdClass $activity, string $field): int {
$overrides = self::activity_setting_overrides($activity);
if (array_key_exists($field, $overrides)) {
return $overrides[$field];
}
return self::OVERRIDE_INHERIT;
}
/**
* Normalize one override state from DB / form values.
*/
public static function normalize_override_state($value): int {
$value = (int)$value;
if (in_array($value, [self::OVERRIDE_INHERIT, self::OVERRIDE_OFF, self::OVERRIDE_ON], true)) {
return $value;
}
return self::OVERRIDE_INHERIT;
}
/**
* Build normalized JSON for activity setting overrides.
*
* @param array<string,mixed> $states
*/
public static function setting_overrides_json(array $states): string {
$out = [];
foreach ($states as $field => $value) {
$state = self::normalize_override_state($value);
if ($state !== self::OVERRIDE_INHERIT) {
$out[(string)$field] = $state;
}
}
return $out ? json_encode($out, JSON_UNESCAPED_UNICODE) : '';
}
/**
* Resolve a boolean site/activity setting.
*/
protected static function resolve_activity_toggle(?\stdClass $activity, string $field, bool $sitevalue): bool {
if ($activity === null) {
return $sitevalue;
}
$state = self::activity_override_state($activity, $field);
if ($state === self::OVERRIDE_OFF) {
return false;
}
if ($state === self::OVERRIDE_ON) {
return true;
}
if (property_exists($activity, 'settingoverrides') || property_exists($activity, 'participantsmode')) {
return $sitevalue;
}
if (!isset($activity->{$field})) {
return $sitevalue;
}
return !empty($activity->{$field});
}
/**
* ZIP download link: site/activity “show ZIP” AND role matrix.
* Prefer featureaccess::allowed(ZIP) in new code.
*/
public static function user_can_download_zip(\context $context, ?\stdClass $activity = null, ?int $userid = null): bool {
return false;
}
/**
* Whether an email is acceptable for Adobe Connect principal-update.
*/
public static function email_usable_for_adobe(string $email): bool {
$email = trim($email);
if ($email === '' || strpos($email, '@') === false) {
return false;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
// Adobe rejects fake/reserved domains (e.g. test@test.test → invalid/format).
if (preg_match('/\.(test|invalid|localhost|local|example)$/i', $email)) {
return false;
}
if (preg_match('/@(users\.moodle\.local|example\.(com|org|net))$/i', $email)) {
return false;
}
return true;
}
/**
* Synthetic Connect email on the meeting host domain.
*/
public static function synthetic_connect_email(\stdClass $user): string {
$host = self::get_meet_host();
$host = preg_replace('#^https?://#i', '', $host);
$host = explode('/', $host)[0] ?? '';
$host = explode(':', $host)[0] ?? '';
$host = trim((string)$host);
if ($host === '' || !preg_match('/\./', $host)) {
global $CFG;
$www = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
$host = is_string($www) && $www !== '' ? $www : 'meetlet.local';
}
return 'moodleuser' . (int)($user->id ?? 0) . '@' . $host;
}
/**
* Canonical Adobe Connect login for a Moodle user.
*
* Stable across email/username changes: mlu{moodleuserid}.
* Header SSO must send this same string (or the principal's current login
* until a legacy account is realigned).
*/
public static function connect_login(\stdClass $user): string {
$id = (int)($user->id ?? 0);
if ($id > 0) {
return 'mlu' . $id;
}
// Disposable / connection-test users may have no Moodle id.
$username = trim((string)($user->username ?? ''));
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $username);
if ($safe !== '') {
return 'mlu_' . $safe;
}
return 'mlu_tmp' . substr(sha1(($user->email ?? '') . '|' . microtime(true)), 0, 12);
}
/**
* Legacy Connect logins that may already exist for this Moodle user
* (before stable mlu{id} identity). Used only for lookup/migration.
*
* @return string[]
*/
public static function legacy_connect_logins(\stdClass $user): array {
$out = [];
$email = trim((string)($user->email ?? ''));
$username = trim((string)($user->username ?? ''));
foreach ([
self::synthetic_connect_email($user),
'moodleuser' . (int)($user->id ?? 0),
self::email_usable_for_adobe($email) ? $email : '',
($username !== '' && strpos($username, '@') === false) ? $username : '',
self::email_usable_for_adobe($username) ? $username : '',
] as $candidate) {
$candidate = trim((string)$candidate);
if ($candidate === '') {
continue;
}
if (strpos($candidate, '@') !== false && !self::email_usable_for_adobe($candidate)) {
continue;
}
if (!in_array($candidate, $out, true)) {
$out[] = $candidate;
}
}
return $out;
}
/**
* Best-effort email for Adobe principal-update (profile field only; not SSO key).
*/
public static function connect_email(\stdClass $user): string {
$email = trim((string)($user->email ?? ''));
if (self::email_usable_for_adobe($email)) {
return $email;
}
return self::synthetic_connect_email($user);
}
/**
* Build public meeting/content URL.
*/
public static function public_url(string $urlpath, string $session = ''): string {
$protocol = self::use_https() ? 'https://' : 'http://';
$host = self::get_meet_host();
$port = self::get_port();
$portpart = '';
if ($port && $port != 80 && !(self::use_https() && $port == 443)) {
$portpart = ':' . $port;
}
if ($urlpath !== '' && $urlpath[0] !== '/') {
$urlpath = '/' . $urlpath;
}
$url = $protocol . $host . $portpart . $urlpath;
if ($session !== '') {
$url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($session);
}
return $url;
}
/**
* Append query params without duplicating keys.
*
* @param array<string,string|int|bool> $params
*/
public static function append_query(string $url, array $params): string {
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$pair = rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
// Skip if already present.
if (preg_match('/([?&])' . preg_quote((string)$key, '/') . '=/', $url)) {
continue;
}
$url .= (strpos($url, '?') === false ? '?' : '&') . $pair;
}
return $url;
}
/**
* Force HTML client in-browser — skip Flash / desktop-app launcher interstitial
* that otherwise hangs on “preparing the recording/meeting”.
*/
public static function with_html_client(string $url): string {
return self::append_query($url, [
'html-view' => 'true',
'launcher' => 'false',
]);
}
/**
* Recording playback URL (HTML client).
*/
public static function recording_play_url(string $urlpath, string $session = ''): string {
return self::with_html_client(self::public_url($urlpath, $session));
}
/**
* Uploaded meeting content view URL (HTML client).
*/
public static function content_view_url(string $urlpath, string $session = ''): string {
return self::with_html_client(self::public_url($urlpath, $session));
}
/**
* Adobe Connect recording editor URL (pbMode=edit).
*/
public static function recording_editor_url(string $urlpath, string $session = ''): string {
$url = self::public_url($urlpath, $session);
return self::with_html_client(self::append_query($url, [
'pbMode' => 'edit',
'page' => 'm',
]));
}
/**
* Whether settings look complete enough to call the API.
*/
public static function is_configured(): bool {
return self::get_api_url() !== ''
&& self::get_meet_host() !== ''
&& self::get_admin_login() !== ''
&& self::get_admin_password() !== '';
}
}
+263
View File
@@ -0,0 +1,263 @@
<?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/>.
/**
* Stepwise Adobe Connect connection tests for admin Ajax UI.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
use mod_meetletacbmarketplace\api\client;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\usererror;
use Throwable;
/**
* Runs connection checks one step at a time (session-backed).
*/
class conntest_runner {
public const STEPS = ['login', 'folders', 'user', 'sso', 'cleanup'];
/**
* Reset session scratch data for a new run.
*/
public static function reset(): void {
global $SESSION;
$SESSION->meetletacbmarketplace_conntest = [
'service' => null,
'meetings' => null,
'scoid' => null,
'principalid' => null,
'login' => null,
'failed' => false,
];
}
/**
* @return array{label:string,ok:bool,detail:string,done:bool,step:string}
*/
public static function run_step(string $step): array {
global $SESSION;
\mod_meetletacbmarketplace\registration\manager::require_registered();
if (empty($SESSION->meetletacbmarketplace_conntest) || $step === 'login') {
self::reset();
}
$state = &$SESSION->meetletacbmarketplace_conntest;
if (!config::is_configured()) {
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => get_string('notconfigured', 'meetletacbmarketplace'),
'done' => true,
'abort' => true,
];
}
try {
switch ($step) {
case 'login':
return self::step_login($state);
case 'folders':
return self::step_folders($state);
case 'user':
return self::step_user($state);
case 'sso':
return self::step_sso($state);
case 'cleanup':
return self::step_cleanup($state);
default:
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => 'Unknown step',
'done' => true,
'abort' => true,
];
}
} catch (Throwable $e) {
$state['failed'] = true;
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => $e->getMessage(),
'done' => false,
'abort' => true,
];
}
}
/**
* Rebuild a fresh service (API client is not serializable in session).
*/
protected static function service(): service {
$service = new service();
$service->login_admin();
return $service;
}
protected static function step_login(array &$state): array {
$service = self::service();
$state['ok_login'] = true;
// Logout immediately; later steps re-login.
$service->logout();
return [
'step' => 'login',
'label' => get_string('testlogin', 'meetletacbmarketplace'),
'ok' => true,
'detail' => '',
'done' => false,
'abort' => false,
];
}
protected static function step_folders(array &$state): array {
$service = self::service();
$content = $service->get_folder_scoid('content');
$archives = $service->get_folder_scoid('forced-archives');
$meetings = $service->get_folder_scoid('meetings');
$state['meetings'] = $meetings;
$ok = ($content && $archives && $meetings);
if (!$ok) {
$state['failed'] = true;
}
$service->logout();
return [
'step' => 'folders',
'label' => get_string('testfolders', 'meetletacbmarketplace'),
'ok' => (bool)$ok,
'detail' => "content={$content} · archives={$archives} · meetings={$meetings}",
'done' => false,
'abort' => !$ok,
];
}
protected static function step_user(array &$state): array {
$service = self::service();
$meetings = (int)($state['meetings'] ?? 0);
if (!$meetings) {
$meetings = (int)$service->get_folder_scoid('meetings');
$state['meetings'] = $meetings;
}
$meeting = (object)[
'name' => 'meetlet_test_' . time(),
'starttime' => time(),
'endtime' => time() + HOURSECS,
'meeturl' => '',
'templatescoid' => 0,
];
$scoid = $service->create_meeting($meeting, $meetings);
$user = (object)[
'id' => random_int(900000000, 999999999),
'firstname' => 'MeetLet',
'lastname' => 'Test',
'username' => 'meetlettest' . random_int(1000, 9999),
'email' => 'meetlet.test.' . random_int(1000, 9999) . '@example.com',
];
$principal = $service->ensure_user($user);
$assigned = $service->assign_permission($principal->principalid, $scoid, service::ROLE_PRESENTER);
$state['scoid'] = $scoid;
$state['principalid'] = $principal->principalid;
$state['login'] = $principal->login;
$service->logout();
if (!$assigned) {
$state['failed'] = true;
}
return [
'step' => 'user',
'label' => get_string('testuser', 'meetletacbmarketplace'),
'ok' => (bool)$assigned,
'detail' => '',
'done' => false,
'abort' => false,
];
}
protected static function step_sso(array &$state): array {
$directok = false;
$directdetail = '';
try {
$sso = new client();
$cookie = $sso->login_external(config::get_admin_login());
$directok = ($cookie !== '');
$directdetail = get_string('testsso_direct_ok', 'meetletacbmarketplace');
} catch (Throwable $e) {
$msg = $e->getMessage();
$directdetail = get_string('testsso_direct_fail', 'meetletacbmarketplace', usererror::label('sso_01') . ': ' . $msg);
}
if ($directok && !empty($state['login'])) {
try {
$sso2 = new client();
$sso2->login_external($state['login']);
$directdetail .= ' · ' . get_string('testsso_userok', 'meetletacbmarketplace');
} catch (Throwable $e) {
$directdetail .= ' · ' . get_string('testsso_userwarn', 'meetletacbmarketplace');
}
}
if (!$directok) {
$state['failed'] = true;
}
return [
'step' => 'sso',
'label' => get_string('testsso', 'meetletacbmarketplace'),
'ok' => $directok,
'detail' => $directdetail,
'done' => false,
'abort' => false,
];
}
protected static function step_cleanup(array &$state): array {
$service = self::service();
$client = $service->get_client();
$ok = true;
$detail = '';
try {
if (!empty($state['scoid'])) {
$service->delete_sco((int)$state['scoid']);
}
if (!empty($state['principalid'])) {
$client->request(['action' => 'principals-delete', 'principal-id' => (int)$state['principalid']]);
}
} catch (Throwable $e) {
$ok = false;
$detail = $e->getMessage();
}
$service->logout();
self::reset();
return [
'step' => 'cleanup',
'label' => get_string('testmeeting', 'meetletacbmarketplace'),
'ok' => $ok,
'detail' => $detail,
'done' => true,
'abort' => false,
];
}
}
+196
View File
@@ -0,0 +1,196 @@
<?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/>.
/**
* Meeting content download helpers (Adobe Connect proxy).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Authenticated Moodle proxy for uploaded meeting content files.
*/
class content {
/**
* Opaque, HMAC-signed, current-user download URL.
*/
public static function download_url(int $userid, int $cmid, int $scoid): \moodle_url {
return new \moodle_url('/mod/meetletacbmarketplace/download_content.php', [
'k' => self::make_download_token($userid, $cmid, $scoid),
]);
}
/**
* Unpack and verify a content download token.
*
* @return array{userid:int,cmid:int,cid:int}
*/
public static function unpack_download_token(string $token): array {
$token = trim($token);
if (!preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$b64 = strtr($token, '-_', '+/');
$pad = strlen($b64) % 4;
if ($pad) {
$b64 .= str_repeat('=', 4 - $pad);
}
$raw = base64_decode($b64, true);
if ($raw === false || strlen($raw) < 44) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$mac = substr($raw, 0, 32);
$payload = substr($raw, 32);
if (strlen($payload) < 32) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$ids = unpack('Nuid/Ncmid/Ncid/Nexp', substr($payload, 0, 16));
$nonce = substr($payload, 16, 16);
$userid = (int)$ids['uid'];
$cmid = (int)$ids['cmid'];
$cid = (int)$ids['cid'];
$expires = (int)$ids['exp'];
$expected = hash_hmac(
'sha256',
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
if (!hash_equals($expected, $mac)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
return ['userid' => $userid, 'cmid' => $cmid, 'cid' => $cid];
}
/**
* Find a content item listed under a meeting folder.
*/
public static function find_in_meeting(service $service, int $meetingscoid, int $scoid): ?stdClass {
foreach ($service->list_meeting_content($meetingscoid) as $item) {
if ((int)($item->scoid ?? 0) === $scoid) {
return $item;
}
}
return null;
}
/**
* Adobe stores uploaded binaries under {url-path}/output/{filename}.
*/
public static function output_file_url(string $urlpath, string $filename): string {
$filename = basename(str_replace('\\', '/', $filename));
if ($filename === '' || strpos($filename, '/') !== false) {
throw new \moodle_exception('filenotfound', 'error');
}
$base = '/' . trim($urlpath, '/');
if ($base === '/') {
throw new \moodle_exception('filenotfound', 'error');
}
return config::public_url($base . '/output/' . rawurlencode($filename));
}
/**
* Stream a meeting content file to the browser via user SSO.
*/
public static function serve_download(instance $inst, int $scoid): void {
global $USER;
$service = new service();
$service->login_admin();
$item = self::find_in_meeting($service, $inst->require_meeting(), $scoid);
$service->logout();
if (!$item || empty($item->name) || empty($item->urlpath)) {
throw new \moodle_exception('filenotfound', 'error');
}
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, (int)$USER->id);
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
$auth = sso::provision_and_login($USER, $scoid, $permission);
$fileurl = self::output_file_url((string)$item->urlpath, (string)$item->name);
$filename = clean_filename((string)$item->name);
if ($filename === '') {
$filename = 'content_' . $scoid;
}
$tmpdir = make_request_directory();
$tmp = $tmpdir . '/content_' . $scoid . '_' . $filename;
self::fetch_to_path($fileurl, $auth['cookie'], $tmp);
\core\session\manager::write_close();
send_temp_file($tmp, $filename);
}
/**
* Download remote content bytes using a BREEZESESSION cookie.
*/
protected static function fetch_to_path(string $url, string $session, string $dest): void {
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
if ($fp === false) {
curl_close($ch);
throw new \moodle_exception('filenotfound', 'error');
}
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 300,
CURLOPT_COOKIE => $session !== '' ? ('BREEZESESSION=' . $session) : '',
]);
$ok = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$size = (int)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
curl_close($ch);
fclose($fp);
if (!$ok || $code < 200 || $code >= 400 || $size <= 0 || !is_file($dest) || filesize($dest) <= 0) {
@unlink($dest);
throw new \moodle_exception('filenotfound', 'error');
}
}
protected static function make_download_token(int $userid, int $cmid, int $scoid): string {
$expires = time() + DAYSECS * 7;
$nonce = random_bytes(16);
$payload = pack('N4', $userid, $cmid, $scoid, $expires) . $nonce;
$mac = hash_hmac(
'sha256',
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $scoid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
return rtrim(strtr(base64_encode($mac . $payload), '+/', '-_'), '=');
}
protected static function download_secret(): string {
return 'mod_meetletacbmarketplace/content/' . get_site_identifier();
}
}
+544
View File
@@ -0,0 +1,544 @@
<?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/>.
/**
* Role-based feature access matrix (site + activity).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use context;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Decide which Moodle roles may use which MeetLet features.
*
* =====================================================================
* ACCESS RULE (mandatory for current and future features)
* ---------------------------------------------------------------------
* Every user-facing MeetLet capability MUST go through
* featureaccess::allowed() / require_feature().
*
* allowed() = role×feature matrix (site or activity override)
* AND optional site/activity visibility switch when mapped.
*
* Do NOT grant access via Moodle capabilities alone (except the base
* mod/meetletacbmarketplace:view gate to open the activity page).
* Do NOT special-case admins/hosts outside the matrix.
*
* Adding a new feature:
* 1) Add a constant + entry in features()
* 2) Add accessfeature_* lang strings (fa + en)
* 3) Set defaults in features_for_profile() / profile_for_role()
* 4) If it also has a site “show …” switch, map it in visibility_feature()
* 5) Gate UI and web services with allowed() / require_feature()
* =====================================================================
*/
class featureaccess {
public const JOIN = 'join';
public const RECORDINGS = 'recordings';
public const CONTENT = 'content';
public const EDITRECORDING = 'editrecording';
public const ADOBEEDITOR = 'adobeeditor';
public const ENTERBEFOREHOST = 'enterbeforehost';
/** @var array<int,stdClass>|null */
protected static $rolerecords = null;
/**
* Ordered feature keys for UI.
*
* @return string[]
*/
public static function features(): array {
return [
self::JOIN,
self::ENTERBEFOREHOST,
self::RECORDINGS,
self::CONTENT,
self::EDITRECORDING,
self::ADOBEEDITOR,
];
}
/**
* All defined Moodle roles (shortnames), sorted like the role UI.
*
* @return string[]
*/
public static function role_shortnames(): array {
$out = [];
foreach (self::role_records() as $role) {
if ($role->shortname !== '') {
$out[] = $role->shortname;
}
}
return $out;
}
/**
* @return array<int,stdClass> id => role row (id, shortname, name, archetype, sortorder)
*/
public static function role_records(): array {
global $DB;
if (self::$rolerecords === null) {
self::$rolerecords = $DB->get_records(
'role',
null,
'sortorder ASC, id ASC',
'id, shortname, name, archetype, sortorder'
);
if (!is_array(self::$rolerecords)) {
self::$rolerecords = [];
}
}
return self::$rolerecords;
}
/**
* Reset cached role list (tests / after role changes in same request).
*/
public static function reset_role_cache(): void {
self::$rolerecords = null;
}
/**
* Profile of features enabled for a given access level.
*
* @return string[] feature keys
*/
public static function features_for_profile(string $profile): array {
switch ($profile) {
case 'manager':
case 'editingteacher':
return self::features();
case 'teacher':
return [
self::JOIN, self::ENTERBEFOREHOST,
self::RECORDINGS, self::CONTENT,
];
case 'student':
return [self::JOIN, self::RECORDINGS, self::CONTENT];
default:
return [];
}
}
/**
* Map a Moodle role to a sensible MeetLet access profile.
*/
public static function profile_for_role(stdClass $role): string {
$arch = strtolower(trim((string)($role->archetype ?? '')));
$short = strtolower(trim((string)($role->shortname ?? '')));
$name = strtolower(trim((string)($role->name ?? '')));
$hay = $short . ' ' . $name;
if (in_array($arch, ['manager', 'coursecreator'], true)
|| in_array($short, ['manager', 'coursecreator'], true)) {
return 'manager';
}
if ($arch === 'editingteacher' || $short === 'editingteacher') {
return 'editingteacher';
}
if ($arch === 'teacher' || $short === 'teacher') {
return 'teacher';
}
if ($arch === 'student' || $short === 'student') {
return 'student';
}
if (in_array($arch, ['guest', 'user', 'frontpage'], true)
|| in_array($short, ['guest', 'user', 'frontpage'], true)) {
return 'none';
}
// Custom / plugin roles — infer from shortname & name.
if (preg_match('/\b(host|manager|admin|owner)\b/u', $hay)
|| str_contains($hay, 'adobeconnecthost')) {
return 'editingteacher';
}
if (preg_match('/\b(presenter|editingteacher|instructor|tutor|moderator)\b/u', $hay)
|| str_contains($hay, 'adobeconnectpresenter')) {
return 'teacher';
}
if (preg_match('/\b(participant|student|learner|attendee)\b/u', $hay)
|| str_contains($hay, 'adobeconnectparticipant')) {
return 'student';
}
if (preg_match('/\b(teacher|teacherrole|ostad)\b/u', $hay)) {
return 'teacher';
}
return 'none';
}
/**
* Built-in defaults by role archetype + name heuristics for custom roles.
*
* @return array<string,array<string,int>> role => feature => 0|1
*/
public static function builtin_defaults(): array {
$alloff = array_fill_keys(self::features(), 0);
$matrix = [];
foreach (self::role_records() as $role) {
$row = $alloff;
foreach (self::features_for_profile(self::profile_for_role($role)) as $f) {
$row[$f] = 1;
}
$matrix[$role->shortname] = $row;
}
return $matrix;
}
/**
* Keep explicit non-empty stored rows; fill blank/missing roles from builtins.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize_fill_blank_roles($raw): array {
$defaults = self::builtin_defaults();
if (!is_array($raw)) {
return $defaults;
}
$out = $defaults;
foreach (self::role_shortnames() as $role) {
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
$enabled = 0;
foreach (self::features() as $feature) {
if (!empty($raw[$role][$feature])) {
$enabled++;
}
}
if ($enabled === 0) {
// Entirely empty row → keep sensible builtin defaults for that role.
continue;
}
foreach (self::features() as $feature) {
$out[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
return $out;
}
/**
* Whether the activity form may show a per-activity access matrix.
*/
public static function activity_override_enabled(): bool {
$raw = get_config('meetletacbmarketplace', 'activity_featureaccess');
if ($raw === false || $raw === null || $raw === '') {
return true;
}
return (bool)$raw;
}
/**
* Normalize a stored matrix; missing keys fall back to builtin defaults.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize($raw): array {
$base = self::builtin_defaults();
if (!is_array($raw)) {
return $base;
}
foreach (self::role_shortnames() as $role) {
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
foreach (self::features() as $feature) {
if (array_key_exists($feature, $raw[$role])) {
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
}
return $base;
}
/**
* Build matrix from checkbox POST (missing checkbox = denied).
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize_from_form($raw): array {
$base = [];
foreach (self::role_shortnames() as $role) {
$base[$role] = array_fill_keys(self::features(), 0);
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
foreach (self::features() as $feature) {
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
return $base;
}
/**
* Site-wide matrix from plugin config.
*
* @return array<string,array<string,int>>
*/
public static function site_matrix(): array {
$raw = get_config('meetletacbmarketplace', 'featureaccess');
if ($raw === false || $raw === null || $raw === '') {
return self::builtin_defaults();
}
$decoded = json_decode((string)$raw, true);
return self::normalize($decoded);
}
/**
* Effective matrix for an activity (override or site).
*
* @param stdClass|null $activity
* @return array<string,array<string,int>>
*/
public static function matrix_for_activity(?stdClass $activity): array {
if ($activity && !empty($activity->featureaccess)) {
$decoded = json_decode((string)$activity->featureaccess, true);
if (is_array($decoded) && !empty($decoded)) {
return self::normalize($decoded);
}
}
return self::site_matrix();
}
/**
* Whether current/user may use a feature in this module context.
* Decision is based solely on the role × feature matrix (no visibility switch).
*/
public static function user_can(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
global $USER;
$userid = $userid ?? (int)$USER->id;
// Must at least be able to view the activity page.
$viewcap = self::view_capability_for_context($context);
if ($viewcap && !has_capability($viewcap, $context, $userid)) {
return false;
}
$matrix = self::matrix_for_activity($activity);
$roles = get_user_roles($context, $userid, true);
foreach ($roles as $role) {
$short = $role->shortname ?? '';
if ($short === '' || !isset($matrix[$short])) {
continue;
}
if (!empty($matrix[$short][$feature])) {
return true;
}
}
// No matching matrix role grants the feature.
return false;
}
/**
* Optional site/activity visibility key for a matrix feature.
* Null = matrix-only (no enable_* / show_* switch).
*/
public static function visibility_feature(string $feature): ?string {
$map = [
self::RECORDINGS => 'recordingstab',
self::CONTENT => 'contenttab',
];
return $map[$feature] ?? null;
}
/**
* Single entry point: matrix grant AND visibility switch (when mapped).
*
* Use this for tabs, buttons, links, and web-service actions.
*/
public static function allowed(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
if (!\mod_meetletacbmarketplace\registration\manager::is_registered()) {
return false;
}
$vis = self::visibility_feature($feature);
if ($vis !== null && !config::show_feature($vis, $activity)) {
return false;
}
return self::user_can($feature, $context, $activity, $userid);
}
/**
* Require allowed() or throw.
*/
public static function require_feature(string $feature, context $context, ?stdClass $activity = null): void {
\mod_meetletacbmarketplace\registration\manager::require_registered();
if (self::allowed($feature, $context, $activity)) {
return;
}
$vis = self::visibility_feature($feature);
if ($vis !== null && !config::show_feature($vis, $activity)) {
throw new \moodle_exception('featuredisabled', 'meetletacbmarketplace');
}
throw new \moodle_exception('nopermissions', 'error', '', self::feature_label($feature));
}
public static function feature_label(string $feature): string {
$key = 'accessfeature_' . $feature;
if (get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
return get_string($key, 'meetletacbmarketplace');
}
return $feature;
}
/**
* @return array<string,string> feature => label
*/
public static function feature_labels(): array {
$out = [];
foreach (self::features() as $f) {
$out[$f] = self::feature_label($f);
}
return $out;
}
/**
* @return array<string,string> shortname => local name
*/
public static function role_labels(): array {
$out = [];
foreach (self::role_records() as $role) {
$out[$role->shortname] = role_get_name($role);
}
return $out;
}
protected static function view_capability_for_context(context $context): string {
return 'mod/meetletacbmarketplace:view';
}
/**
* Render HTML matrix table (admin or mod form).
*
* @param array $matrix
* @param string $inputname e.g. featureaccess[student][join] or admin name
* @param bool $disabled
*/
public static function render_matrix_html(array $matrix, string $inputname, bool $disabled = false): string {
$matrix = self::normalize($matrix);
$roles = self::role_labels();
$features = self::feature_labels();
$strtogglerow = get_string('accessmatrix_toggle_row', 'meetletacbmarketplace');
$strtogglecol = get_string('accessmatrix_toggle_col', 'meetletacbmarketplace');
$strall = get_string('accessmatrix_all', 'meetletacbmarketplace');
$html = \html_writer::start_div('meetlet-access-matrix', ['data-meetlet-access-matrix' => '1']);
$html .= \html_writer::start_tag('table', ['class' => 'generaltable meetlet-access-table']);
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
foreach ($features as $fkey => $flabel) {
$toggle = \html_writer::tag('button', s($strall), [
'type' => 'button',
'class' => 'meetlet-access-toggle meetlet-access-toggle--col',
'data-access-toggle' => 'col',
'data-feature' => $fkey,
'title' => $strtogglecol,
'aria-label' => $strtogglecol . ': ' . $flabel,
]);
$head .= \html_writer::tag('th',
\html_writer::div(s($flabel), 'meetlet-access-col__label') . $toggle,
['class' => 'meetlet-access-col', 'title' => $flabel]
);
}
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
$body = '';
foreach ($roles as $rshort => $rlabel) {
$rowtoggle = \html_writer::tag('button', s($strall), [
'type' => 'button',
'class' => 'meetlet-access-toggle meetlet-access-toggle--row',
'data-access-toggle' => 'row',
'data-role' => $rshort,
'title' => $strtogglerow,
'aria-label' => $strtogglerow . ': ' . $rlabel,
]);
$cells = \html_writer::tag('th',
\html_writer::span(s($rlabel), 'meetlet-access-role') . $rowtoggle,
['scope' => 'row', 'class' => 'meetlet-access-rowhead']
);
foreach (array_keys($features) as $fkey) {
$checked = !empty($matrix[$rshort][$fkey]);
$attrs = [
'type' => 'checkbox',
'name' => $inputname . '[' . $rshort . '][' . $fkey . ']',
'value' => '1',
'class' => 'meetlet-access-check',
'data-role' => $rshort,
'data-feature' => $fkey,
];
if ($checked) {
$attrs['checked'] = 'checked';
}
if ($disabled) {
$attrs['disabled'] = 'disabled';
}
$cells .= \html_writer::tag('td', \html_writer::empty_tag('input', $attrs), [
'class' => 'meetlet-access-cell',
]);
}
$body .= \html_writer::tag('tr', $cells, ['data-role-row' => $rshort]);
}
$html .= \html_writer::tag('tbody', $body);
$html .= \html_writer::end_tag('table');
$html .= \html_writer::end_div();
return $html;
}
/**
* Copy $from grants onto $to when $to is missing from a stored row.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function copy_missing_feature_from($raw, string $from, string $to): array {
$stored = is_array($raw) ? $raw : [];
$out = self::normalize($stored);
foreach (self::role_shortnames() as $role) {
if (empty($stored[$role]) || !is_array($stored[$role])) {
continue;
}
if (array_key_exists($to, $stored[$role])) {
continue;
}
if (array_key_exists($from, $stored[$role])) {
$out[$role][$to] = !empty($stored[$role][$from]) ? 1 : 0;
}
}
return $out;
}
/**
* Parse POSTed matrix checkbox array into normalized JSON.
*
* @param mixed $posted
*/
public static function matrix_from_post($posted): string {
return json_encode(self::normalize_from_form(is_array($posted) ? $posted : []), JSON_UNESCAPED_UNICODE);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?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/>.
/**
* Local inline SVG icons (air-gapped — no CDN).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Shared MeetLet icon SVG snippets.
*/
class icons {
/**
* Return inline SVG markup for a named icon.
*/
public static function svg(string $name): string {
$svgs = [
'play' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>',
'zip' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm1 7V3.5L19.5 9H15zM9 4h2v2H9V4zm0 3h2v2H9V7zm0 3h2v2H9v-2zm1 3h1v4H8v-1h2v-3z"/></svg>',
'offline' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7V3zm1 4v5l4 2-.8 1.6L11 12.2V7h2z"/></svg>',
'mp4' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 4v9.2l3.2-3.2 1.4 1.4L12 16.4 7.4 11.8l1.4-1.4L11 13.2V4h1z"/><rect x="5" y="18" width="14" height="2" rx="1" fill="currentColor"/></svg>',
'import' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'request' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6.25C4 4.45 5.45 3 7.25 3h6.5C15.55 3 17 4.45 17 6.25V8.1l4.15-2.3A1.25 1.25 0 0 1 23 6.9v10.2c0 .95-1.03 1.55-1.85 1.1L17 15.9v1.85C17 19.55 15.55 21 13.75 21h-6.5C5.45 21 4 19.55 4 17.75V6.25z"/></svg>',
'edit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.34a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z"/></svg>',
'adobeedit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4h7v2H6v12h12v-5h2v7H4V4zm9 0h7v7h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H13V4z"/></svg>',
'users' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>',
'csv' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 13h2.5l1.25 3.5L13 13h2v6h-1.5v-3.4L12.2 19h-.9L10 15.6V19H8.5v-6H8zm7.5 0H18c.8 0 1.5.7 1.5 1.5v3c0 .8-.7 1.5-1.5 1.5h-2.5v-6zm1.5 4.5h1v-3h-1v3z"/></svg>',
'refresh' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'update' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'download' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'details' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2 5 5h-4a1 1 0 0 1-1-1V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z"/></svg>',
'attendance' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-8 14-4-4 1.41-1.41L11 14.17l6.59-6.59L19 9l-8 8z"/></svg>',
'recordings' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>',
'content' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>',
'sessions' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V8h14v11zM7 10h5v5H7v-5z"/></svg>',
];
return $svgs[$name] ?? $svgs['play'];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?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/>.
/**
* Activity instance helpers.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use cm_info;
use context_module;
use moodle_exception;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Load and resolve MeetLet instances.
*/
class instance {
/** @var stdClass */
public $record;
/** @var stdClass */
public $course;
/** @var cm_info|stdClass */
public $cm;
/** @var context_module */
public $context;
/** @var int */
public $groupid = 0;
/** @var stdClass|null */
public $meetingmap;
public static function get_from_cmid(int $cmid, int $groupid = 0): self {
list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'meetletacbmarketplace');
return self::from_cm($cm, $course, $groupid);
}
public static function get_from_instanceid(int $instanceid, int $groupid = 0): self {
list($course, $cm) = get_course_and_cm_from_instance($instanceid, 'meetletacbmarketplace');
return self::from_cm($cm, $course, $groupid);
}
protected static function from_cm($cm, $course, int $groupid): self {
global $DB;
$inst = new self();
$inst->cm = $cm;
$inst->course = $course;
$inst->context = context_module::instance($cm->id);
$inst->record = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance], '*', MUST_EXIST);
$inst->groupid = $groupid;
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
'instanceid' => $inst->record->id,
'groupid' => $groupid,
]);
if (!$inst->meetingmap && $groupid !== 0) {
// Fallback to no-group mapping.
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
'instanceid' => $inst->record->id,
'groupid' => 0,
]);
}
return $inst;
}
public function meetingscoid(): int {
return (int)($this->meetingmap->meetingscoid ?? 0);
}
public function require_meeting(): int {
$scoid = $this->meetingscoid();
if (!$scoid) {
throw new moodle_exception('nomeeting', 'meetletacbmarketplace');
}
return $scoid;
}
public function auto_offline_enabled(): bool {
return config::auto_offline_enabled($this->record);
}
}
+263
View File
@@ -0,0 +1,263 @@
<?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/>.
/**
* Meeting lifecycle status (schedule + live presence + wait-for-host).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
defined('MOODLE_INTERNAL') || die();
/**
* Resolve whether the activity meeting can accept join requests.
*/
class meetingstatus {
public const OPEN = 'open';
public const LIVE = 'live';
public const SCHEDULED = 'scheduled';
public const ENDED = 'ended';
public const WAITINGHOST = 'waitinghost';
/** Local-only placeholder until Adobe is polled from the browser. */
public const CHECKING = 'checking';
/**
* Fast local status for first paint — no Adobe API calls.
*
* Join stays blocked until the client polls {@see resolve()} unless the
* schedule alone already forbids join (and the user cannot bypass).
*
* @return array{
* status:string,
* statuslabel:string,
* joinable:bool,
* message:string,
* errorcode:string,
* starttime:int,
* endtime:int,
* waitforhost:bool
* }
*/
public static function resolve_local(instance $inst, ?int $userid = null): array {
global $USER;
$userid = $userid ?? (int)$USER->id;
$rec = $inst->record;
$now = time();
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
$waitforhost = config::wait_for_host($rec);
$canbypass = featureaccess::allowed(
featureaccess::ENTERBEFOREHOST,
$inst->context,
$rec,
$userid
);
$status = self::CHECKING;
$errorcode = '';
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
// Never allow join before Adobe confirms room state.
$joinable = false;
if ($endts > 0 && $endts < $now) {
$status = self::ENDED;
$message = get_string('meetingended', 'meetletacbmarketplace');
$errorcode = 'meetingended';
$joinable = $canbypass;
} else if ($startts > $now) {
$status = self::SCHEDULED;
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
$errorcode = 'meetingnotstarted';
$joinable = $canbypass;
} else if ($waitforhost && !$canbypass) {
// Provisional: assume waiting until Adobe poll confirms a live/open room.
$status = self::WAITINGHOST;
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
$errorcode = 'waitingforhost';
$joinable = false;
} else if (!$canbypass) {
// Until Adobe confirms the room is live, keep Join disabled for participants.
$status = self::CHECKING;
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
$joinable = false;
}
return [
'status' => $status,
'statuslabel' => self::label($status),
'joinable' => $joinable,
'message' => $message,
'errorcode' => $errorcode,
'starttime' => $startts,
'endtime' => $endts,
'waitforhost' => $waitforhost,
];
}
/**
* Full status including Adobe live presence and host-hold.
*
* Priority:
* 1) Live room presence on Adobe Connect
* 2) Moodle schedule (start / end)
* 3) Wait-for-host (Moodle flag and/or Adobe ACL)
*
* @return array{
* status:string,
* statuslabel:string,
* joinable:bool,
* message:string,
* errorcode:string,
* starttime:int,
* endtime:int,
* waitforhost:bool
* }
*/
public static function resolve(instance $inst, ?int $userid = null, bool $checkhost = true): array {
global $USER;
$userid = $userid ?? (int)$USER->id;
$rec = $inst->record;
$now = time();
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
$service = null;
$scoid = 0;
$islive = false;
$adobehold = false;
if (config::is_configured()) {
$scoid = $inst->meetingscoid();
if ($scoid) {
try {
$service = new service();
$service->login_admin();
$info = $service->meeting_info($scoid);
if ($info && !empty($info->starttime) && $startts <= 0) {
$adobestart = strtotime((string)$info->starttime);
if ($adobestart) {
$startts = $adobestart;
}
}
$islive = $service->meeting_is_live($scoid);
if ($checkhost) {
$adobehold = $service->meeting_requires_host_first($scoid);
}
} catch (\Throwable $e) {
$service = null;
$islive = false;
$adobehold = false;
}
}
}
$waitforhost = config::wait_for_host($rec) || $adobehold;
$status = self::OPEN;
$errorcode = '';
$message = '';
$joinable = true;
$canbypass = featureaccess::allowed(
featureaccess::ENTERBEFOREHOST,
$inst->context,
$rec,
$userid
);
if ($islive) {
$status = self::LIVE;
$joinable = true;
} else if ($endts > 0 && $endts < $now) {
$status = self::ENDED;
if (!$canbypass) {
$joinable = false;
$errorcode = 'meetingended';
$message = get_string('meetingended', 'meetletacbmarketplace');
}
} else if ($startts > $now) {
$status = self::SCHEDULED;
if (!$canbypass) {
$joinable = false;
$errorcode = 'meetingnotstarted';
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
}
} else if (!$islive && $checkhost && !$canbypass) {
// Nobody is in the Adobe room yet. Participants must not get a join URL —
// Connect would only show "Meeting Host Expected". Moderators with
// enterbeforehost may still join to start the session.
$status = self::WAITINGHOST;
$joinable = false;
$errorcode = 'waitingforhost';
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
} else if (!$islive && $canbypass && ($waitforhost || $adobehold)) {
// Host/moderator may enter; still show waiting-for-host for clarity.
$status = self::WAITINGHOST;
$joinable = true;
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
}
if ($service instanceof service) {
try {
$service->logout();
} catch (\Throwable $e) {
// Ignore.
}
}
if ($inst->meetingmap) {
try {
if (roomsession::note_presence($inst, $islive)) {
recordings::sync($inst);
}
} catch (\Throwable $e) {
debugging('MeetLet room session observe failed: ' . $e->getMessage(), DEBUG_DEVELOPER);
}
}
return [
'status' => $status,
'statuslabel' => self::label($status),
'joinable' => $joinable,
'message' => $message !== '' ? $message : get_string('statushelp', 'meetletacbmarketplace'),
'errorcode' => $errorcode,
'starttime' => $startts,
'endtime' => $endts,
'waitforhost' => $waitforhost,
];
}
public static function label(string $status): string {
switch ($status) {
case self::LIVE:
return get_string('statuslive', 'meetletacbmarketplace');
case self::ENDED:
return get_string('statusended', 'meetletacbmarketplace');
case self::SCHEDULED:
return get_string('statusscheduled', 'meetletacbmarketplace');
case self::WAITINGHOST:
return get_string('statuswaitinghost', 'meetletacbmarketplace');
case self::CHECKING:
return get_string('statuschecking', 'meetletacbmarketplace');
default:
return get_string('statusopen', 'meetletacbmarketplace');
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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/>.
/**
* Admin HTML for the in-plugin privacy policy tab (Marketplace edition).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Renders the Privacy admin settings tab.
*/
class privacy_policy {
/**
* Build privacy policy HTML (safe for admin settings heading).
*/
public static function render(): string {
$plugin = 'meetletacbmarketplace';
$sections = [
self::section('scope', 'privacy_tab_scope_title', 'privacy_tab_scope_body'),
self::section('registration', 'privacy_tab_registration_title', 'privacy_tab_registration_body'),
self::section('purpose', 'privacy_tab_purpose_title', 'privacy_tab_purpose_body'),
self::section('service', 'privacy_tab_service_title', 'privacy_tab_service_body'),
self::section('adobe', 'privacy_tab_adobe_title', 'privacy_tab_adobe_body'),
self::section('local', 'privacy_tab_local_title', 'privacy_tab_local_body'),
self::section('retention', 'privacy_tab_retention_title', 'privacy_tab_retention_body'),
self::section('sharing', 'privacy_tab_sharing_title', 'privacy_tab_sharing_body'),
self::section('control', 'privacy_tab_control_title', 'privacy_tab_control_body'),
self::section('contact', 'privacy_tab_contact_title', 'privacy_tab_contact_body'),
];
return \html_writer::div(
\html_writer::tag('h3', get_string('privacy_tab_heading', $plugin), ['class' => 'meetlet-guide__heading']) .
\html_writer::tag('p', get_string('privacy_tab_intro', $plugin), ['class' => 'meetlet-guide__intro']) .
\html_writer::tag('p', get_string('privacy_tab_effective', $plugin), [
'class' => 'meetlet-guide__intro text-muted',
]) .
implode('', $sections),
'meetlet-guide meetlet-settings-card meetlet-privacy'
);
}
/**
* @param string $id Section id for anchoring.
* @param string $titlekey Lang string key for section title.
* @param string $bodykey Lang string key or pre-formatted body HTML.
*/
protected static function section(string $id, string $titlekey, string $bodykey): string {
$plugin = 'meetletacbmarketplace';
$title = get_string($titlekey, $plugin);
$body = strpos($bodykey, '<') !== false
? $bodykey
: get_string($bodykey, $plugin);
return \html_writer::div(
\html_writer::tag('h4', $title) .
\html_writer::div($body, 'meetlet-guide__body'),
'meetlet-guide__section',
['id' => 'meetlet-privacy-' . $id]
);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?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/>.
/**
* Recording list sync (community edition — no offline MP4 / agent).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
class recordings {
public static function for_tab(instance $instance): array {
self::sync($instance);
return self::load_local($instance);
}
public static function sync(instance $instance, ?service $service = null): array {
global $DB;
$ownsclient = false;
if ($service === null) {
$service = new service();
$service->login_admin();
$ownsclient = true;
}
$scoid = $instance->meetingscoid();
if (!$scoid) {
if ($ownsclient) {
$service->logout();
}
return [];
}
$remote = $service->list_recordings($scoid);
$now = time();
$out = [];
foreach ($remote as $rec) {
$record = $DB->get_record('meetlet_marketplace_recordings', [
'instanceid' => $instance->record->id,
'recordingscoid' => $rec->scoid,
]);
$zipurl = service::zip_url($rec->urlpath);
if (!$record) {
$record = new stdClass();
$record->instanceid = $instance->record->id;
$record->meetingscoid = $scoid;
$record->recordingscoid = $rec->scoid;
$record->name = $rec->name;
$record->urlpath = $rec->urlpath;
$record->zipurl = $zipurl;
$record->duration = $rec->duration;
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
$record->timemodified = $now;
$record->id = $DB->insert_record('meetlet_marketplace_recordings', $record);
} else {
$record->name = $rec->name;
$record->urlpath = $rec->urlpath;
$record->zipurl = $zipurl;
$record->duration = $rec->duration;
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
$record->timemodified = $now;
$DB->update_record('meetlet_marketplace_recordings', $record);
}
$record->playurl = config::public_url($rec->urlpath);
$record->editurl = config::recording_editor_url($rec->urlpath);
$out[] = $record;
}
if ($ownsclient) {
$service->logout();
}
return $out;
}
public static function load_local(instance $instance): array {
global $DB;
$rows = $DB->get_records(
'meetlet_marketplace_recordings',
['instanceid' => $instance->record->id],
'timestart DESC, id DESC'
);
foreach ($rows as $record) {
$record->playurl = config::public_url((string)$record->urlpath);
$record->editurl = config::recording_editor_url((string)$record->urlpath);
$record->mp4ready = false;
$record->mp4url = '';
}
return $rows;
}
public static function jobstatus_label(string $status, bool $mp4ready = false): string {
return $status !== '' ? $status : '—';
}
public static function job_is_busy(string $status): bool {
return false;
}
}
+486
View File
@@ -0,0 +1,486 @@
<?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/>.
/**
* BBB-style participant role mapping for Adobe Connect.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use context;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Participant list helpers (BBB pattern: all/role/user → Adobe role).
*/
class roles {
public const ROLE_HOST = 'host';
public const ROLE_PRESENTER = 'presenter';
public const ROLE_PARTICIPANT = 'participant';
public const MODE_INHERIT = 0;
public const MODE_OVERRIDE = 1;
/**
* Map form role id to Adobe permission-id.
*/
public static function to_adobe_permission(string $role): string {
switch ($role) {
case self::ROLE_HOST:
return service::ROLE_HOST;
case self::ROLE_PRESENTER:
return service::ROLE_PRESENTER;
default:
return service::ROLE_PARTICIPANT;
}
}
public static function role_rank(string $role): int {
switch ($role) {
case self::ROLE_HOST:
return 3;
case self::ROLE_PRESENTER:
return 2;
case self::ROLE_PARTICIPANT:
return 1;
default:
return 0;
}
}
/**
* Built-in site defaults (no owner user rule).
*/
public static function builtin_site_defaults(): array {
global $DB;
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]];
foreach (['manager', 'coursecreator', 'editingteacher'] as $shortname) {
if ($role = $DB->get_record('role', ['shortname' => $shortname], 'id')) {
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => self::ROLE_HOST,
];
}
}
if ($role = $DB->get_record('role', ['shortname' => 'teacher'], 'id')) {
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => self::ROLE_PRESENTER,
];
}
return $list;
}
/**
* Site-wide default participant rules from plugin config (or built-in).
*/
public static function get_site_default_rules(): array {
$raw = get_config('meetletacbmarketplace', 'default_participants');
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded) && !empty($decoded)) {
return self::normalize_rules($decoded);
}
}
return self::builtin_site_defaults();
}
/**
* Normalize and sanitize a participant rule list.
*/
public static function normalize_rules(array $rules): array {
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
$out = [];
$hasall = false;
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
$type = (string)($rule['selectiontype'] ?? '');
$id = (string)($rule['selectionid'] ?? '');
$role = (string)($rule['role'] ?? self::ROLE_PARTICIPANT);
if (!in_array($role, $allowed, true)) {
$role = self::ROLE_PARTICIPANT;
}
if ($type === 'all') {
$hasall = true;
$out[] = ['selectiontype' => 'all', 'selectionid' => 'all', 'role' => $role];
continue;
}
if ($type === 'role' && ctype_digit($id) && (int)$id > 0) {
$out[] = ['selectiontype' => 'role', 'selectionid' => $id, 'role' => $role];
continue;
}
if ($type === 'user' && ctype_digit($id) && (int)$id > 0) {
$out[] = ['selectiontype' => 'user', 'selectionid' => $id, 'role' => $role];
}
}
if (!$hasall) {
array_unshift($out, [
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]);
}
return $out;
}
/**
* Default participant rules for a new / empty activity.
*/
public static function get_participant_list_default(context $context, ?int $ownerid = null): array {
$list = self::get_site_default_rules();
// Activity owner as host when enrolled (activity-only convenience rule).
if (!empty($ownerid) && is_enrolled($context, $ownerid)) {
$list[] = [
'selectiontype' => 'user',
'selectionid' => (string)$ownerid,
'role' => self::ROLE_HOST,
];
}
return self::normalize_rules($list);
}
/**
* Whether one activity should inherit the site role mapping.
*/
public static function activity_inherits_defaults(?stdClass $instance): bool {
if ($instance === null) {
return true;
}
if (!property_exists($instance, 'participantsmode')) {
return false;
}
return (int)$instance->participantsmode !== self::MODE_OVERRIDE;
}
/**
* Convert rule list → admin matrix keyed by _all / role shortname.
*
* @return array<string,string>
*/
public static function matrix_from_rules(array $rules): array {
$matrix = ['_all' => self::ROLE_PARTICIPANT];
foreach (featureaccess::role_records() as $role) {
if ($role->shortname !== '') {
$matrix[$role->shortname] = self::ROLE_PARTICIPANT;
}
}
$byid = featureaccess::role_records();
foreach (self::normalize_rules($rules) as $rule) {
$adobe = $rule['role'] ?? self::ROLE_PARTICIPANT;
if (($rule['selectiontype'] ?? '') === 'all') {
$matrix['_all'] = $adobe;
} else if (($rule['selectiontype'] ?? '') === 'role') {
$rid = (int)($rule['selectionid'] ?? 0);
if (isset($byid[$rid]) && $byid[$rid]->shortname !== '') {
$matrix[$byid[$rid]->shortname] = $adobe;
}
}
}
return $matrix;
}
/**
* Convert posted admin matrix → JSON rule list for config storage.
*
* @param array $data role shortname / _all => adobe role
*/
public static function rules_json_from_matrix_post(array $data): string {
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
$allrole = (string)($data['_all'] ?? self::ROLE_PARTICIPANT);
if (!in_array($allrole, $allowed, true)) {
$allrole = self::ROLE_PARTICIPANT;
}
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => $allrole,
]];
foreach (featureaccess::role_records() as $role) {
if ($role->shortname === '') {
continue;
}
$adobe = (string)($data[$role->shortname] ?? self::ROLE_PARTICIPANT);
if (!in_array($adobe, $allowed, true)) {
$adobe = self::ROLE_PARTICIPANT;
}
// Skip redundant participant rows that match the “everyone” default.
if ($adobe === $allrole && $adobe === self::ROLE_PARTICIPANT) {
continue;
}
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => $adobe,
];
}
return json_encode(self::normalize_rules($list), JSON_UNESCAPED_UNICODE);
}
/**
* Adobe Connect role options for selects/radios.
*
* @return array<string,string> value => label
*/
public static function adobe_role_labels(): array {
return [
self::ROLE_HOST => get_string('role_host', 'meetletacbmarketplace'),
self::ROLE_PRESENTER => get_string('role_presenter', 'meetletacbmarketplace'),
self::ROLE_PARTICIPANT => get_string('role_participant', 'meetletacbmarketplace'),
];
}
/**
* Render site admin Moodle-role → Adobe-role mapping table.
*/
public static function render_site_mapping_html(array $rules, string $inputname): string {
$matrix = self::matrix_from_rules($rules);
$adobelabels = self::adobe_role_labels();
$rolelabels = featureaccess::role_labels();
$html = \html_writer::start_div('meetlet-rolemapping', ['data-meetlet-rolemapping' => '1']);
$html .= \html_writer::start_tag('table', [
'class' => 'generaltable meetlet-access-table meetlet-rolemapping-table',
]);
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
$head .= \html_writer::tag('th', get_string('adoberole', 'meetletacbmarketplace'));
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
$body = '';
// Everyone row first.
$body .= self::mapping_row_html(
get_string('participant_type_all', 'meetletacbmarketplace'),
$inputname . '[_all]',
$matrix['_all'] ?? self::ROLE_PARTICIPANT,
$adobelabels,
'_all'
);
foreach ($rolelabels as $shortname => $label) {
$body .= self::mapping_row_html(
$label,
$inputname . '[' . $shortname . ']',
$matrix[$shortname] ?? self::ROLE_PARTICIPANT,
$adobelabels,
$shortname
);
}
$html .= \html_writer::tag('tbody', $body);
$html .= \html_writer::end_tag('table');
$html .= \html_writer::end_div();
return $html;
}
/**
* One mapping table row with Adobe role radios.
*
* @param array<string,string> $adobelabels
*/
protected static function mapping_row_html(
string $moodlelabel,
string $inputname,
string $selected,
array $adobelabels,
string $rowkey
): string {
$cells = \html_writer::tag('th', s($moodlelabel), [
'scope' => 'row',
'class' => 'meetlet-access-rowhead',
]);
$chips = '';
foreach ($adobelabels as $value => $label) {
$id = 'meetlet-map-' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $rowkey . '-' . $value);
$attrs = [
'type' => 'radio',
'name' => $inputname,
'id' => $id,
'value' => $value,
'class' => 'meetlet-rolechip__input',
];
if ($selected === $value) {
$attrs['checked'] = 'checked';
}
$chips .= \html_writer::tag('label',
\html_writer::empty_tag('input', $attrs) .
\html_writer::span(s($label), 'meetlet-rolechip__text'),
[
'class' => 'meetlet-rolechip',
'for' => $id,
'data-role-value' => $value,
]
);
}
$group = \html_writer::div($chips, 'meetlet-rolechip-group', [
'role' => 'radiogroup',
'aria-label' => $moodlelabel,
]);
$cells .= \html_writer::tag('td', $group, ['class' => 'meetlet-rolemapping-cell']);
return \html_writer::tag('tr', $cells, ['data-role-row' => $rowkey]);
}
public static function get_participant_list(?stdClass $instance, context $context): array {
global $USER;
if ($instance === null || self::activity_inherits_defaults($instance)
|| empty($instance->participants) || $instance->participants === '[]') {
return self::get_participant_list_default($context, $USER->id);
}
$rules = json_decode($instance->participants, true);
if (!is_array($rules) || empty($rules)) {
// Legacy format support.
if (isset($rules['hosts']) || (is_array($rules) && isset($rules[0]) === false && isset($rules['participants']))) {
return self::legacy_to_rules($rules);
}
return self::get_participant_list_default($context, $USER->id);
}
// Detect legacy associative map.
if (isset($rules['hosts']) || isset($rules['presenters'])) {
return self::legacy_to_rules($rules);
}
return $rules;
}
protected static function legacy_to_rules(array $map): array {
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]];
foreach ($map['hosts'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_HOST];
}
foreach ($map['presenters'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PRESENTER];
}
foreach ($map['participants'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PARTICIPANT];
}
return $list;
}
public static function get_participant_data(context $context, ?stdClass $instance = null): array {
$data = [
'all' => [
'name' => get_string('participant_type_all', 'meetletacbmarketplace'),
'children' => [],
],
'role' => [
'name' => get_string('participant_type_role', 'meetletacbmarketplace'),
'children' => self::get_roles_select($context),
],
'user' => [
'name' => get_string('participant_type_user', 'meetletacbmarketplace'),
'children' => [],
],
];
if ($instance) {
$data['user']['children'] = self::get_users_array($context);
}
return $data;
}
protected static function get_roles_select(context $context): array {
$roles = get_viewable_roles($context);
$out = [];
foreach ($roles as $id => $name) {
$out[$id] = ['id' => $id, 'name' => $name];
}
return $out;
}
protected static function get_users_array(context $context): array {
$users = get_enrolled_users($context, '', 0, 'u.id, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic, u.middlename, u.alternatename');
$out = [];
foreach ($users as $u) {
$out[$u->id] = ['id' => $u->id, 'name' => fullname($u)];
}
return $out;
}
public static function get_participant_selection_data(): array {
return [
'type_options' => [
'all' => get_string('participant_type_all', 'meetletacbmarketplace'),
'role' => get_string('participant_type_role', 'meetletacbmarketplace'),
'user' => get_string('participant_type_user', 'meetletacbmarketplace'),
],
'type_selected' => 'role',
'options' => ['all' => '---------------'],
'selected' => 'all',
];
}
/**
* Resolve Adobe permission for user from participant rules.
*
* @return string|null Adobe permission-id or null if denied
*/
public static function resolve_adobe_role(stdClass $instance, context $context, int $userid): ?string {
if (has_capability('mod/meetletacbmarketplace:host', $context, $userid)) {
return service::ROLE_HOST;
}
if (has_capability('mod/meetletacbmarketplace:presenter', $context, $userid)) {
return service::ROLE_PRESENTER;
}
$rules = self::get_participant_list($instance, $context);
$userroles = get_user_roles($context, $userid);
$roleids = [];
foreach ($userroles as $r) {
$roleids[] = (int)$r->roleid;
}
$best = null;
$bestrank = 0;
foreach ($rules as $rule) {
$match = false;
if (($rule['selectiontype'] ?? '') === 'all') {
$match = true;
} else if (($rule['selectiontype'] ?? '') === 'role') {
$match = in_array((int)$rule['selectionid'], $roleids, true);
} else if (($rule['selectiontype'] ?? '') === 'user') {
$match = ((int)$rule['selectionid'] === (int)$userid);
}
if (!$match) {
continue;
}
$role = $rule['role'] ?? self::ROLE_PARTICIPANT;
$rank = self::role_rank($role);
if ($rank > $bestrank) {
$bestrank = $rank;
$best = $role;
}
}
if ($best === null) {
return !empty($instance->meetingpublic) ? service::ROLE_PARTICIPANT : null;
}
return self::to_adobe_permission($best);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?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/>.
/**
* Track Adobe Connect room occupancy for auto MP4 after the class actually ends.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Persist live/closed room state on meetlet_marketplace_meetings and decide when to sweep recordings.
*/
class roomsession {
/** Seconds to keep looking for new recordings after the room empties. */
public const SWEEP_GRACE = 20 * MINSECS;
/**
* Record occupancy. Returns true for an opportunistic queue (just closed / grace window).
*
* Open = at least one person in the Adobe room. Closed = it was open, then empty.
* Moodle schedule endtime is intentionally ignored here.
* Cron retries leftover recordings forever while the room is empty; this return
* only gates page-view / join-status opportunistic queues during the grace window.
*/
public static function note_presence(instance $instance, bool $islive): bool {
global $DB;
$map = $instance->meetingmap;
if (!$map || empty($map->id)) {
return false;
}
$row = $DB->get_record('meetlet_marketplace_meetings', ['id' => (int)$map->id]);
if (!$row) {
return false;
}
$now = time();
$waslive = !empty($row->islive);
if ($islive) {
$row->islive = 1;
$row->liveat = $now;
$DB->update_record('meetlet_marketplace_meetings', $row);
$instance->meetingmap = $row;
return false;
}
if ($waslive) {
$row->islive = 0;
$row->closedat = $now;
$row->sweepuntil = $now + self::sweep_window();
}
$insweep = !empty($row->sweepuntil) && $now <= (int)$row->sweepuntil;
$due = $insweep && $instance->auto_offline_enabled()
&& ($now - (int)($row->sweptat ?? 0) >= 45);
if ($due) {
$row->sweptat = $now;
}
if ($waslive || $due) {
$DB->update_record('meetlet_marketplace_meetings', $row);
}
$instance->meetingmap = $row;
return $due;
}
protected static function sweep_window(): int {
$mins = config::cron_interval_minutes();
return max(self::SWEEP_GRACE, $mins * MINSECS * 4);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?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/>.
/**
* Admin HTML for Adobe Connect server setup guide.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Renders the “Server setup” admin help tab.
*/
class server_guide {
/**
* Build guide HTML (safe for admin settings heading).
*/
public static function render(): string {
$plugin = 'meetletacbmarketplace';
$match = \html_writer::tag('h4', get_string('serverguide_match_title', $plugin)) .
\html_writer::tag('p', get_string('serverguide_match_intro', $plugin)) .
self::table([
[get_string('admin_httpauth', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_httpauth', $plugin)))],
[get_string('host', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_apiurl', $plugin)))],
[get_string('meethost', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_meethost', $plugin)))],
]);
$customini = self::section(
'customini',
get_string('serverguide_customini_title', $plugin),
get_string('serverguide_customini_body', $plugin) .
\html_writer::tag('pre', s(get_string('serverguide_customini_sample', $plugin)), [
'class' => 'meetlet-guide__code',
])
);
$webxml = self::section(
'webxml',
get_string('serverguide_webxml_title', $plugin),
get_string('serverguide_webxml_body', $plugin) .
\html_writer::tag('pre', s(get_string('serverguide_webxml_sample', $plugin)), [
'class' => 'meetlet-guide__code',
])
);
$serverxml = self::section(
'serverxml',
get_string('serverguide_serverxml_title', $plugin),
get_string('serverguide_serverxml_body', $plugin) .
\html_writer::tag('pre', s(get_string('serverguide_serverxml_sample', $plugin)), [
'class' => 'meetlet-guide__code',
])
);
$upgrade = \html_writer::div(
\html_writer::tag('strong', get_string('serverguide_upgrade_title', $plugin)) .
\html_writer::div(get_string('serverguide_upgrade_body', $plugin), 'meetlet-guide__body'),
'meetlet-guide__callout meetlet-guide__callout--warn',
['id' => 'meetlet-guide-upgrade']
);
$codes = self::errorcodes_section();
return \html_writer::div(
\html_writer::tag('h3', get_string('serverguide_heading', $plugin), ['class' => 'meetlet-guide__heading']) .
\html_writer::tag('p', get_string('serverguide_intro', $plugin), ['class' => 'meetlet-guide__intro']) .
\html_writer::div($match, 'meetlet-guide__section meetlet-guide__section--match') .
$customini .
$webxml .
$serverxml .
$upgrade .
$codes,
'meetlet-guide meetlet-settings-card'
);
}
/**
* Help-code table so an admin can match a student-reported ML- code.
*/
protected static function errorcodes_section(): string {
$plugin = 'meetletacbmarketplace';
$head = \html_writer::tag('tr',
\html_writer::tag('th', get_string('serverguide_errorcodes_col_code', $plugin)) .
\html_writer::tag('th', get_string('serverguide_errorcodes_col_meaning', $plugin)) .
\html_writer::tag('th', get_string('serverguide_errorcodes_col_fix', $plugin))
);
$body = '';
foreach (usererror::catalog() as $item) {
$body .= \html_writer::tag('tr',
\html_writer::tag('td', \html_writer::tag('code', s($item['label'])), ['class' => 'meetlet-guide__code-cell']) .
\html_writer::tag('td', get_string($item['titlekey'], $plugin)) .
\html_writer::tag('td', get_string($item['fixkey'], $plugin))
);
}
$table = \html_writer::tag(
'table',
\html_writer::tag('thead', $head) . \html_writer::tag('tbody', $body),
['class' => 'meetlet-guide__table generaltable']
);
return self::section(
'errorcodes',
get_string('serverguide_errorcodes_title', $plugin),
\html_writer::tag('p', get_string('serverguide_errorcodes_intro', $plugin)) . $table
);
}
/**
* @param string $id Section id for anchoring.
* @param string $title Section title.
* @param string $bodyhtml Trusted HTML from lang strings (admin-only).
*/
protected static function section(string $id, string $title, string $bodyhtml): string {
return \html_writer::div(
\html_writer::tag('h4', $title) .
\html_writer::div($bodyhtml, 'meetlet-guide__body'),
'meetlet-guide__section',
['id' => 'meetlet-guide-' . $id]
);
}
/**
* @param array<int, array{0:string,1:string}> $rows Label / value (value may contain HTML).
*/
protected static function table(array $rows): string {
$trs = '';
foreach ($rows as $row) {
$trs .= \html_writer::tag(
'tr',
\html_writer::tag('th', $row[0], ['scope' => 'row']) .
\html_writer::tag('td', $row[1])
);
}
return \html_writer::tag(
'table',
\html_writer::tag('tbody', $trs),
['class' => 'meetlet-guide__table generaltable']
);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?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/>.
/**
* Adobe Connect SSO helpers for the current Moodle user.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\client;
use mod_meetletacbmarketplace\api\service;
use moodle_exception;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Build Connect logins and BREEZESESSION for Moodle users.
*/
class sso {
/**
* Ordered Connect login candidates for a Moodle user.
*
* @return string[]
*/
public static function login_candidates(stdClass $user, ?stdClass $adobeuser = null): array {
$reserved = [
'administrators', 'authors', 'everyone', 'live-admins', 'admins',
'course-admins', 'event-administrators', 'event-managers', 'learners',
'seminar-administrators',
// Generic Moodle accounts must never be sent as Connect SSO identities.
'admin', 'guest', 'root', 'administrator', 'moodle',
];
$candidates = [];
// Prefer ext-login (refresh_principal_login) then stable mlu{id}, then legacy logins.
$seed = [];
if (!empty($adobeuser->login)) {
$seed[] = (string)$adobeuser->login;
}
if (!empty($adobeuser->internallogin)) {
$seed[] = (string)$adobeuser->internallogin;
}
$seed[] = config::connect_login($user);
foreach (array_merge($seed, config::legacy_connect_logins($user)) as $candidate) {
$candidate = trim((string)$candidate);
if ($candidate === '' || in_array(strtolower($candidate), $reserved, true)) {
continue;
}
if (!in_array($candidate, $candidates, true)) {
$candidates[] = $candidate;
}
}
return $candidates;
}
/**
* Obtain a BREEZESESSION cookie via HTTP header SSO for the given logins.
*
* @param string[] $logincandidates
*/
public static function breeze_session(array $logincandidates): string {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$cookie = '';
$lasterror = null;
$primary = $logincandidates[0] ?? '';
foreach ($logincandidates as $logintry) {
try {
$sso = new client();
$cookie = $sso->login_external($logintry);
if ($cookie !== '') {
return $cookie;
}
} catch (\Throwable $e) {
$lasterror = $e;
}
}
$parsed = $lasterror ? usererror::from_throwable($lasterror) : ['helpid' => 'sso_01', 'facts' => [], 'xml' => ''];
$facts = $parsed['facts'];
if ($primary !== '' && empty($facts['login'])) {
$facts['login'] = $primary;
}
$ip = self::guess_egress_ip();
if ($ip !== '') {
$facts['egress_ip'] = $ip;
}
$helpid = $parsed['helpid'];
if ($lasterror instanceof moodle_exception && $lasterror->errorcode === 'agentrequestfailed') {
$helpid = 'sso_02';
$facts['agent'] = is_scalar($lasterror->a) ? (string)$lasterror->a : $lasterror->getMessage();
}
if ($lasterror instanceof usererror && $lasterror->helpid === 'sso_01') {
$helpid = 'sso_01';
}
throw new usererror($helpid, 'errorhttpauth', $facts, $parsed['xml']);
}
/**
* Best-effort public IP Adobe will see for Moodle API calls.
*/
public static function guess_egress_ip(): string {
try {
$curl = new \curl(['ignoresecurity' => true]);
$curl->setopt(['CURLOPT_TIMEOUT' => 5]);
$raw = $curl->get('https://api.ipify.org');
$ip = trim((string)$raw);
if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) {
return $ip;
}
} catch (\Throwable $e) {
// Ignore.
}
return '';
}
/**
* Ensure Connect user exists, grant permission on a SCO (and parent meeting for
* recordings), then SSO login.
*
* @return array{cookie:string,adobeuser:stdClass}
*/
public static function provision_and_login(stdClass $user, int $scoid, string $permission): array {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$service = new service();
$service->login_admin();
$adobeuser = $service->ensure_user($user);
$adobeuser = $service->refresh_principal_login($adobeuser);
if (trim((string)($adobeuser->login ?? '')) === '') {
throw new usererror('usr_01', 'errorcreateuser', ['login' => config::connect_login($user)]);
}
$targets = [$scoid];
try {
$info = $service->get_client()->request([
'action' => 'sco-info',
'sco-id' => $scoid,
]);
$folder = (int)($info->sco['folder-id'] ?? 0);
// Parent meeting folder ACL is required for recordings and uploaded content.
if ($folder > 0) {
$targets[] = $folder;
}
} catch (\Throwable $e) {
// Best-effort enrichment only.
}
foreach (array_unique($targets) as $aclid) {
if ($aclid > 0) {
$service->assign_permission((int)$adobeuser->principalid, (int)$aclid, $permission);
}
}
$service->logout();
$cookie = self::breeze_session(self::login_candidates($user, $adobeuser));
return ['cookie' => $cookie, 'adobeuser' => $adobeuser];
}
}
+397
View File
@@ -0,0 +1,397 @@
<?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/>.
/**
* Audience-aware MeetLet errors (student vs teacher/admin) with help codes.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Structured plugin error with a public help code (ML-…) and staff-only detail.
*/
class usererror extends moodle_exception {
/** @var string Internal catalog id, e.g. sso_01 */
public string $helpid = 'err_01';
/** @var array<string, string> Staff fact rows (login, adobe_code, …) */
public array $facts = [];
/** @var string Optional Adobe XML / raw snippet */
public string $xml = '';
/**
* @param string $helpid Catalog id (sso_01, api_01, …)
* @param string $errorcode Moodle lang string id
* @param array<string, string> $facts
* @param string $xml
*/
public function __construct(string $helpid, string $errorcode, array $facts = [], string $xml = '') {
$this->helpid = $helpid !== '' ? $helpid : 'err_01';
$this->facts = $facts;
$this->xml = trim($xml);
$debug = $this->facts;
if ($this->xml !== '') {
$debug['xml'] = $this->xml;
}
parent::__construct($errorcode, 'meetletacbmarketplace', '', null, json_encode($debug, JSON_UNESCAPED_UNICODE));
}
/**
* Teachers, managers, and site admins see technical detail; students do not.
*/
public static function is_staff(\context $context, ?int $userid = null): bool {
return has_any_capability([
'moodle/site:config',
'moodle/course:update',
'moodle/course:manageactivities',
'mod/meetletacbmarketplace:addinstance',
'mod/meetletacbmarketplace:host',
'mod/meetletacbmarketplace:presenter',
], $context, $userid);
}
/**
* Help-code catalog shown in the server-setup guide.
*
* @return array<string, array{label:string,titlekey:string,fixkey:string}>
*/
public static function catalog(): array {
return [
'sso_01' => [
'label' => 'ML-SSO-01',
'titlekey' => 'helpcode_sso_01_title',
'fixkey' => 'helpcode_sso_01_fix',
],
'api_01' => [
'label' => 'ML-API-01',
'titlekey' => 'helpcode_api_01_title',
'fixkey' => 'helpcode_api_01_fix',
],
'api_02' => [
'label' => 'ML-API-02',
'titlekey' => 'helpcode_api_02_title',
'fixkey' => 'helpcode_api_02_fix',
],
'usr_01' => [
'label' => 'ML-USR-01',
'titlekey' => 'helpcode_usr_01_title',
'fixkey' => 'helpcode_usr_01_fix',
],
'mtg_01' => [
'label' => 'ML-MTG-01',
'titlekey' => 'helpcode_mtg_01_title',
'fixkey' => 'helpcode_mtg_01_fix',
],
'cfg_01' => [
'label' => 'ML-CFG-01',
'titlekey' => 'helpcode_cfg_01_title',
'fixkey' => 'helpcode_cfg_01_fix',
],
'err_01' => [
'label' => 'ML-ERR-01',
'titlekey' => 'helpcode_err_01_title',
'fixkey' => 'helpcode_err_01_fix',
],
];
}
/**
* Public label for a catalog id (ML-SSO-01).
*/
public static function label(string $helpid): string {
$cat = self::catalog();
return $cat[$helpid]['label'] ?? 'ML-ERR-01';
}
/**
* Ajax payload for join / recording launch failures.
*
* @return array{code:string,message:string,detailhtml:string,staff:bool}
*/
public static function payload(\Throwable $e, \context $context): array {
if (self::is_gate_exception($e)) {
return [
'code' => '',
'message' => $e->getMessage(),
'detailhtml' => '',
'staff' => false,
];
}
$parsed = self::from_throwable($e);
$label = self::label($parsed['helpid']);
$staff = self::is_staff($context);
if (!$staff) {
return [
'code' => $label,
'message' => get_string('error_user_unavailable', 'meetletacbmarketplace', $label),
'detailhtml' => '',
'staff' => false,
];
}
return [
'code' => $label,
'message' => self::staff_title($parsed['helpid'], $e),
'detailhtml' => self::render_staff_html($parsed),
'staff' => true,
];
}
/**
* WS success envelope.
*
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
*/
public static function ws_ok(string $url): array {
return [
'url' => $url,
'ok' => true,
'helpcode' => '',
'message' => '',
'detailhtml' => '',
];
}
/**
* WS failure envelope (HTTP 200 so the UI can format HTML).
*
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
*/
public static function ws_fail(\Throwable $e, \context $context): array {
$p = self::payload($e, $context);
return [
'url' => '',
'ok' => false,
'helpcode' => $p['code'],
'message' => $p['message'],
'detailhtml' => $p['detailhtml'],
];
}
/**
* @return array{url: \core_external\external_value, ok: \core_external\external_value, helpcode: \core_external\external_value, message: \core_external\external_value, detailhtml: \core_external\external_value}
*/
public static function ws_url_returns(): array {
return [
'url' => new \core_external\external_value(PARAM_RAW, 'URL with session when ok'),
'ok' => new \core_external\external_value(PARAM_BOOL, 'False when the session could not be opened'),
'helpcode' => new \core_external\external_value(PARAM_TEXT, 'Public help code, e.g. ML-SSO-01'),
'message' => new \core_external\external_value(PARAM_RAW, 'Audience-appropriate short message'),
'detailhtml' => new \core_external\external_value(PARAM_RAW, 'Staff-only HTML; empty for students'),
];
}
/**
* Pretty-print an Adobe XML snippet for a code box.
*/
public static function format_xml(string $raw): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
$start = strpos($raw, '<?xml');
if ($start === false) {
$start = strpos($raw, '<results');
}
$chunk = $start === false ? $raw : substr($raw, $start);
if (preg_match('/<results\b[\s\S]*?<\/results>/i', $chunk, $m)) {
$chunk = $m[0];
} else {
$chunk = substr($chunk, 0, 1200);
}
$dom = new \DOMDocument();
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (@$dom->loadXML($chunk)) {
$out = $dom->saveXML($dom->documentElement);
return $out !== false ? trim($out) : trim($chunk);
}
return trim($chunk);
}
/**
* @return array{helpid:string,facts:array<string,string>,xml:string}
*/
public static function from_throwable(\Throwable $e): array {
if ($e instanceof self) {
return [
'helpid' => $e->helpid,
'facts' => $e->facts,
'xml' => $e->xml,
];
}
$code = $e instanceof moodle_exception ? (string)$e->errorcode : '';
$helpid = self::helpid_from_moodle($code);
$facts = [];
$xml = '';
if ($e instanceof moodle_exception && is_scalar($e->a) && (string)$e->a !== '') {
$legacy = self::parse_legacy_detail((string)$e->a);
$facts = $legacy['facts'];
$xml = $legacy['xml'];
}
return ['helpid' => $helpid, 'facts' => $facts, 'xml' => $xml];
}
protected static function is_gate_exception(\Throwable $e): bool {
if (!$e instanceof moodle_exception) {
return false;
}
return in_array($e->errorcode, [
'meetingended',
'meetingnotstarted',
'waitingforhost',
'notparticipant',
'usergrouprequired',
'joinblocked_waitinghost',
], true);
}
protected static function helpid_from_moodle(string $errorcode): string {
$map = [
'errorhttpauth' => 'sso_01',
'erroremptyxml' => 'api_01',
'errorlogin' => 'api_02',
'errorcreateuser' => 'usr_01',
'errorcreatemeeting' => 'mtg_01',
'nomeeting' => 'mtg_01',
'errorexistingsco' => 'mtg_01',
'notconfigured' => 'cfg_01',
];
return $map[$errorcode] ?? 'err_01';
}
/**
* @return array{facts:array<string,string>,xml:string}
*/
protected static function parse_legacy_detail(string $detail): array {
$facts = [];
$xml = '';
if (preg_match('/login=([^\s·]+)/u', $detail, $m)) {
$facts['login'] = $m[1];
}
if (preg_match('/\bcode=([^\s·]+)/u', $detail, $m)) {
$facts['adobe_code'] = $m[1];
}
if (preg_match('/subcode=([^\s·]+)/u', $detail, $m)) {
$facts['adobe_subcode'] = $m[1];
}
$bodypos = stripos($detail, 'body=');
if ($bodypos !== false) {
$xml = self::format_xml(substr($detail, $bodypos + 5));
}
return ['facts' => $facts, 'xml' => $xml];
}
protected static function staff_title(string $helpid, \Throwable $e): string {
$cat = self::catalog();
$key = $cat[$helpid]['titlekey'] ?? '';
if ($key !== '' && get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
return get_string($key, 'meetletacbmarketplace');
}
if ($e instanceof moodle_exception) {
return $e->getMessage();
}
return get_string('error', 'meetletacbmarketplace');
}
/**
* @param array{helpid:string,facts:array<string,string>,xml:string} $parsed
*/
protected static function render_staff_html(array $parsed): string {
$helpid = $parsed['helpid'];
$label = self::label($helpid);
$cat = self::catalog();
$titlekey = $cat[$helpid]['titlekey'] ?? '';
$fixkey = $cat[$helpid]['fixkey'] ?? '';
$title = ($titlekey !== '' && get_string_manager()->string_exists($titlekey, 'meetletacbmarketplace'))
? get_string($titlekey, 'meetletacbmarketplace')
: get_string('error', 'meetletacbmarketplace');
$codebox = \html_writer::div(
\html_writer::span(get_string('error_helpcode_label', 'meetletacbmarketplace'), 'meetlet-err__codelabel') .
\html_writer::tag('code', s($label), ['data-copy-code' => $label]),
'meetlet-err__codebox'
);
$rows = '';
$factorder = ['login', 'adobe_code', 'adobe_subcode', 'egress_ip'];
$facts = $parsed['facts'];
foreach ($factorder as $key) {
if (empty($facts[$key])) {
continue;
}
$dtkey = 'errorfact_' . $key;
$dt = get_string_manager()->string_exists($dtkey, 'meetletacbmarketplace')
? get_string($dtkey, 'meetletacbmarketplace')
: $key;
$rows .= \html_writer::tag(
'div',
\html_writer::tag('dt', s($dt)) .
\html_writer::tag('dd', \html_writer::tag('code', s((string)$facts[$key]))),
['class' => 'meetlet-err__fact']
);
}
foreach ($facts as $key => $val) {
if (in_array($key, $factorder, true) || (string)$val === '') {
continue;
}
$rows .= \html_writer::tag(
'div',
\html_writer::tag('dt', s((string)$key)) .
\html_writer::tag('dd', \html_writer::tag('code', s((string)$val))),
['class' => 'meetlet-err__fact']
);
}
$factshtml = $rows !== '' ? \html_writer::tag('dl', $rows, ['class' => 'meetlet-err__facts']) : '';
$fix = '';
if ($fixkey !== '' && get_string_manager()->string_exists($fixkey, 'meetletacbmarketplace')) {
$fix = \html_writer::div(get_string($fixkey, 'meetletacbmarketplace'), 'meetlet-err__fix');
}
$xmlhtml = '';
if ($parsed['xml'] !== '') {
$xmlhtml = \html_writer::tag('p', get_string('error_adobe_response', 'meetletacbmarketplace'), ['class' => 'meetlet-err__prelabel']) .
\html_writer::tag('pre', s($parsed['xml']), ['class' => 'meetlet-err__xml']);
}
$guide = \html_writer::tag('p', get_string('error_see_serverguide', 'meetletacbmarketplace'), ['class' => 'meetlet-err__guide']);
return \html_writer::div(
\html_writer::tag('p', s($title), ['class' => 'meetlet-err__lead']) .
$codebox .
$factshtml .
$fix .
$xmlhtml .
$guide,
'meetlet-err meetlet-err--staff'
);
}
}