Initial commit: MeetLet Adobe Connect Bridge Marketplace edition (Moodle plugin).
Single-plugin source tree for mod_meetletacbmarketplace — admin, licensing, and marketplace integration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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() !== '';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?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';
|
||||
$httpauth = config::get_http_auth_header();
|
||||
|
||||
$current = \html_writer::tag('h4', get_string('serverguide_current_title', $plugin)) .
|
||||
\html_writer::tag('p', get_string('serverguide_current_intro', $plugin)) .
|
||||
self::table([
|
||||
[get_string('admin_httpauth', $plugin), \html_writer::tag('code', s($httpauth))],
|
||||
[get_string('host', $plugin), \html_writer::tag('code', s(config::get_api_url() !== '' ? config::get_api_url() : '—'))],
|
||||
[get_string('meethost', $plugin), \html_writer::tag('code', s(config::get_meet_host() !== '' ? config::get_meet_host() : '—'))],
|
||||
]);
|
||||
|
||||
$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, $httpauth)), [
|
||||
'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($current, 'meetlet-guide__section meetlet-guide__section--current') .
|
||||
$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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user