Files
meetletacbfree/classes/local/config.php
T

656 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_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<string,string> 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<int,string>
*/
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<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() !== '';
}
}