. /** * Plugin config helpers. * * @package mod_meetletacbfree * @copyright 2026 MeetLet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_meetletacbfree\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('meetletacbfree', 'host')); } /** * Meeting host domain (no protocol). */ public static function get_meet_host(): string { return trim((string)get_config('meetletacbfree', 'meethost')); } public static function get_port(): int { $port = (int)get_config('meetletacbfree', 'port'); return $port > 0 ? $port : 443; } public static function use_https(): bool { return (bool)get_config('meetletacbfree', 'https'); } public static function get_admin_login(): string { return (string)get_config('meetletacbfree', 'admin_login'); } public static function get_admin_password(): string { return (string)get_config('meetletacbfree', 'admin_password'); } public static function get_http_auth_header(): string { $h = trim((string)get_config('meetletacbfree', '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('meetletacbfree', '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('meetletacbfree', '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('meetletacbfree', '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('meetletacbfree', '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('meetletacbfree', '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_meetletacbfree\\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('meetletacbfree', 'agent_url')); } /** * Site-wide default meeting UI language (Adobe Connect), empty = server default. */ public static function default_meeting_lang(): string { $v = get_config('meetletacbfree', '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('meetletacbfree', 'default_waitforhost'); } /** * Language choices for meeting UI (site + activity forms). * * @return array code => label */ public static function meeting_lang_options(): array { return [ '' => get_string('meetinglang_default', 'meetletacbfree'), '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('meetletacbfree', $key); if ($v === false && $feature === 'mp4') { $legacy = get_config('meetletacbfree', '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('meetletacbfree', $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 */ public static function override_state_options(): array { return [ self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbfree'), self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbfree'), self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbfree'), ]; } /** * Read JSON override map stored on the activity row. * * @return array */ 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 $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 $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() !== ''; } }