Files
mrxzamani be252e49e2 Address Moodle Marketplace review for mod_meetletacbfree 1.1.3.
Migrate legacy ajax/JS to external services and AMD, enable TLS verification, scope admin CSS with RTLCSS-safe rules, and fix README/lang/boilerplate gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 12:41:47 +03:30

482 lines
22 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* MeetLet activity module form.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/course/moodleform_mod.php');
use mod_meetletacbfree\api\service;
use mod_meetletacbfree\local\config;
use mod_meetletacbfree\local\featureaccess;
use mod_meetletacbfree\local\roles;
use mod_meetletacbfree\registration\manager as registration_manager;
/**
* MeetLet mod form.
*/
class mod_meetletacbfree_mod_form extends moodleform_mod {
public function definition() {
global $OUTPUT, $PAGE, $USER;
$mform = $this->_form;
$context = context_course::instance($this->_course->id);
$current = empty($this->current->instance) ? null : $this->current;
$isnew = empty($this->current->instance);
if (!registration_manager::is_registered()) {
$mform->addElement('static', 'registrationlock', '',
registration_manager::admin_gate_notice_html());
$this->standard_hidden_coursemodule_elements();
$this->add_action_buttons(false);
return;
}
$PAGE->requires->js_call_amd('mod_meetletacbfree/modform', 'init');
$PAGE->requires->js_call_amd('mod_meetletacbfree/featureaccess_matrix', 'init');
$PAGE->requires->js_call_amd('mod_meetletacbfree/settings_toggles', 'init');
$mform->addElement('header', 'general', get_string('general', 'form'));
$mform->addElement('text', 'name', get_string('meetingname', 'meetletacbfree'), ['size' => '64']);
$mform->setType('name', PARAM_TEXT);
$mform->addRule('name', null, 'required', null, 'client');
$mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
$this->standard_intro_elements(get_string('meetingintro', 'meetletacbfree'));
$mform->addElement('header', 'meetletroom', get_string('roomsettings', 'meetletacbfree'));
// Create-new is default; existing meetings are loaded lazily via Ajax.
$linkmodes = [
0 => get_string('linkmode_create', 'meetletacbfree'),
1 => get_string('linkmode_existing', 'meetletacbfree'),
];
$mform->addElement('select', 'linkmode', get_string('linkmode', 'meetletacbfree'), $linkmodes);
$mform->setDefault('linkmode', 0);
$mform->addHelpButton('linkmode', 'linkmode', 'meetletacbfree');
if (!$isnew) {
$mform->hardFreeze('linkmode');
}
$meetingsmeta = [];
$selectedsco = (int)($current->existingscoid ?? 0);
$selectedlabel = get_string('chooseservermeeting', 'meetletacbfree');
$readonly = !$isnew;
// On edit of a linked activity, keep the selected meeting visible without listing all.
if (!$isnew && !empty($current->linkmode) && $selectedsco && config::is_configured()) {
try {
$service = new service();
$service->login_admin();
$info = $service->meeting_info($selectedsco);
$service->logout();
if ($info) {
$selectedlabel = (string)$info->name;
$urlpath = trim((string)($info->url ?? ''), '/');
if ($urlpath !== '') {
$selectedlabel .= ' (/' . $urlpath . '/)';
}
$meetingsmeta[$selectedsco] = [
'name' => (string)$info->name,
'urlpath' => $urlpath,
'starttime' => !empty($info->starttime) ? (int)strtotime((string)$info->starttime) : 0,
'endtime' => !empty($info->endtime) ? (int)strtotime((string)$info->endtime) : 0,
];
}
} catch (Throwable $e) {
$selectedlabel = 'SCO ' . $selectedsco;
}
}
$mform->addElement('hidden', 'existingscoid', $selectedsco);
$mform->setType('existingscoid', PARAM_INT);
$listhtml = '';
if ($selectedsco) {
$listhtml = html_writer::tag('li',
html_writer::tag('button', s($selectedlabel), [
'type' => 'button',
'class' => 'meetlet-combobox__option is-selected',
'data-scoid' => $selectedsco,
'data-name' => $meetingsmeta[$selectedsco]['name'] ?? $selectedlabel,
'data-urlpath' => $meetingsmeta[$selectedsco]['urlpath'] ?? '',
'data-starttime' => (string)($meetingsmeta[$selectedsco]['starttime'] ?? 0),
'data-endtime' => (string)($meetingsmeta[$selectedsco]['endtime'] ?? 0),
])
);
}
$toggleattrs = [
'type' => 'button',
'class' => 'meetlet-combobox__toggle',
'data-meeting-toggle' => '1',
'aria-expanded' => 'false',
];
if ($readonly) {
$toggleattrs['disabled'] = 'disabled';
}
$picker = html_writer::div(
html_writer::tag('button',
html_writer::span(s($selectedlabel), 'meetlet-combobox__value', ['data-meeting-label' => '1']) .
html_writer::span('▾', 'meetlet-combobox__caret', ['aria-hidden' => 'true']),
$toggleattrs
) .
html_writer::div(
html_writer::empty_tag('input', [
'type' => 'search',
'class' => 'meetlet-combobox__search',
'data-meeting-search' => '1',
'placeholder' => get_string('searchservermeetings', 'meetletacbfree'),
'autocomplete' => 'off',
]) .
html_writer::tag('ul', $listhtml, [
'class' => 'meetlet-combobox__list',
'data-meeting-list' => '1',
]) .
html_writer::div('', 'meetlet-combobox__hint', ['data-meeting-hint' => '1']),
'meetlet-combobox__panel',
['data-meeting-panel' => '1', 'hidden' => 'hidden']
),
'meetlet-combobox' . ($readonly ? ' is-readonly' : ''),
[
'data-meeting-picker' => '1',
'data-courseid' => (int)$this->_course->id,
'data-readonly' => $readonly ? '1' : '0',
'data-str-loading' => get_string('loadingmeetings', 'meetletacbfree'),
'data-str-empty' => get_string('nomeetingsfound', 'meetletacbfree'),
'data-str-choose' => get_string('chooseservermeeting', 'meetletacbfree'),
]
);
$mform->addElement('static', 'existingscoid_ui', get_string('existingscoid', 'meetletacbfree'), $picker);
$mform->hideIf('existingscoid_ui', 'linkmode', 'neq', 1);
$mform->addElement('html', html_writer::div('', 'd-none', [
'data-meetings-meta' => json_encode($meetingsmeta, JSON_UNESCAPED_UNICODE),
]));
$mform->addElement('text', 'meeturl', get_string('meeturl', 'meetletacbfree'), ['size' => 40]);
$mform->setType('meeturl', PARAM_PATH);
$mform->addHelpButton('meeturl', 'meeturl', 'meetletacbfree');
$mform->hideIf('meeturl', 'linkmode', 'eq', 1);
$mform->addElement('select', 'meetingpublic', get_string('meetingtype', 'meetletacbfree'), [
0 => get_string('private', 'meetletacbfree'),
1 => get_string('public', 'meetletacbfree'),
]);
$mform->setDefault('meetingpublic', 0);
$langs = config::meeting_lang_options();
$mform->addElement('select', 'meetinglang', get_string('meetinglang', 'meetletacbfree'), $langs);
$mform->setType('meetinglang', PARAM_ALPHANUMEXT);
$mform->addHelpButton('meetinglang', 'meetinglang', 'meetletacbfree');
$mform->setDefault('meetinglang', config::default_meeting_lang());
$waitstate = $this->resolve_toggle_state($current, 'waitforhost', $isnew);
$mform->addElement('static', 'waitforhost_state_ui', get_string('waitforhost', 'meetletacbfree'),
$this->render_override_chip_group(
'waitforhost_state',
$waitstate,
get_string('waitforhost_help', 'meetletacbfree'),
[
config::OVERRIDE_INHERIT => get_string(
config::default_wait_for_host() ? 'settingstate_inherit_on' : 'settingstate_inherit_off',
'meetletacbfree'
),
]
)
);
$mform->addElement('hidden', 'waitforhost', $waitstate === config::OVERRIDE_ON ? 1 : 0);
$mform->setType('waitforhost', PARAM_INT);
$templates = [0 => get_string('defaulttemplate', 'meetletacbfree')];
if (config::is_configured()) {
try {
$service = new service();
$service->login_admin();
$templates = $templates + $service->get_templates();
$service->logout();
} catch (Throwable $e) {
// Ignore.
}
}
$mform->addElement('select', 'templatescoid', get_string('meettemplates', 'meetletacbfree'), $templates);
$mform->setType('templatescoid', PARAM_INT);
$mform->hideIf('templatescoid', 'linkmode', 'eq', 1);
$mform->addElement('date_time_selector', 'starttime', get_string('starttime', 'meetletacbfree'));
$mform->addRule('starttime', null, 'required', null, 'client');
$mform->addElement('advcheckbox', 'endtimeenable', get_string('endtimeenable', 'meetletacbfree'));
$mform->addElement('date_time_selector', 'endtime', get_string('endtime', 'meetletacbfree'));
$mform->disabledIf('endtime', 'endtimeenable');
$mform->setDefault('endtime', time() + 2 * HOURSECS);
// BBB-style participant mapping.
$participantlist = roles::get_participant_list($current, $context);
$participantselection = roles::get_participant_selection_data();
$mform->addElement('header', 'permissions', get_string('rolemapping', 'meetletacbfree'));
$mform->setExpanded('permissions');
$mform->addElement('static', 'rolemappingdesc', '', get_string('rolemapping_desc', 'meetletacbfree'));
$participantsmode = $this->resolve_participants_mode($current, $isnew);
$mform->addElement('static', 'participantsmode_ui', get_string('rolemapping_mode', 'meetletacbfree'),
$this->render_override_chip_group(
'participantsmode_state',
$participantsmode,
get_string('rolemapping_mode_help', 'meetletacbfree'),
[],
[
roles::MODE_INHERIT => get_string('settingstate_inherit', 'meetletacbfree'),
roles::MODE_OVERRIDE => get_string('rolemapping_mode_override_short', 'meetletacbfree'),
]
)
);
$mform->addElement('hidden', 'participantsmode', $participantsmode === roles::MODE_OVERRIDE ? 1 : 0);
$mform->setType('participantsmode', PARAM_INT);
$mform->addElement('hidden', 'participants', json_encode($participantlist));
$mform->setType('participants', PARAM_RAW);
$selectiontype = new single_select(new moodle_url(qualified_me()),
'meetlet_participant_selection_type',
$participantselection['type_options'],
$participantselection['type_selected']);
$selectionparticipants = new single_select(new moodle_url(qualified_me()),
'meetlet_participant_selection',
$participantselection['options'],
$participantselection['selected']);
$action = new single_button(new moodle_url(qualified_me()),
get_string('participant_add', 'meetletacbfree'),
'post',
single_button::BUTTON_SECONDARY,
['name' => 'meetlet_participant_selection_add']
);
$html = $OUTPUT->render_from_template('mod_meetletacbfree/participant_form', [
'selectionType' => $selectiontype->export_for_template($OUTPUT),
'selectionParticipant' => $selectionparticipants->export_for_template($OUTPUT),
'action' => $action->export_for_template($OUTPUT),
]);
$mform->addElement('static', 'static_participant_list', get_string('participant_list', 'meetletacbfree'), $html);
$mform->addElement('html', html_writer::div('', 'd-none', [
'data-participant-data' => json_encode(roles::get_participant_data($context, $current ?: (object)['id' => 0])),
]));
if (featureaccess::activity_override_enabled()) {
$mform->addElement('header', 'meetletaccess', get_string('featureaccess_activity', 'meetletacbfree'));
$mform->addElement('advcheckbox', 'featureaccess_override', get_string('featureaccess_override', 'meetletacbfree'),
get_string('featureaccess_override_help', 'meetletacbfree'));
$hascustom = $current && !empty($current->featureaccess);
$mform->setDefault('featureaccess_override', $hascustom ? 1 : 0);
$matrix = featureaccess::matrix_for_activity($current);
$mform->addElement('html', featureaccess::render_matrix_html($matrix, 'featureaccess_matrix'));
$mform->addElement('hidden', 'featureaccess', '');
$mform->setType('featureaccess', PARAM_RAW);
}
if (config::offline_render_active()) {
$mform->addElement('header', 'meetletoffline', get_string('offlinesettings', 'meetletacbfree'));
$autostate = $this->resolve_toggle_state($current, 'autooffline', $isnew);
$mform->addElement('static', 'autooffline_state_ui', get_string('autooffline', 'meetletacbfree'),
$this->render_override_chip_group(
'autooffline_state',
$autostate,
get_string('autooffline_help', 'meetletacbfree'),
[
config::OVERRIDE_INHERIT => get_string(
config::auto_offline_default() ? 'settingstate_inherit_on' : 'settingstate_inherit_off',
'meetletacbfree'
),
]
)
);
$mform->addElement('hidden', 'autooffline', $autostate === config::OVERRIDE_ON ? 1 : 0);
$mform->setType('autooffline', PARAM_INT);
}
$mform->addElement('header', 'meetletfeatures', get_string('activityfeatures', 'meetletacbfree'));
$featurefields = [
'zip' => 'showzip',
'recordingstab' => 'showrecordingstab',
'contenttab' => 'showcontenttab',
'mp4' => 'showmp4',
'makeoffline' => 'showmakeoffline',
];
foreach ($featurefields as $feature => $field) {
if (!config::feature_enabled($feature)) {
continue;
}
if (!config::feature_allow_override($feature)) {
continue;
}
$state = $this->resolve_toggle_state($current, $field, $isnew);
$inheritkey = config::feature_enabled($feature) ? 'settingstate_inherit_on' : 'settingstate_inherit_off';
$help = ($field === 'showzip') ? get_string('showzip_help', 'meetletacbfree') : '';
$mform->addElement('static', $field . '_state_ui', get_string($field, 'meetletacbfree'),
$this->render_override_chip_group(
$field . '_state',
$state,
$help,
[config::OVERRIDE_INHERIT => get_string($inheritkey, 'meetletacbfree')]
)
);
$mform->addElement('hidden', $field, $state === config::OVERRIDE_ON ? 1 : 0);
$mform->setType($field, PARAM_INT);
if ($field === 'showzip') {
$mform->removeElement($field . '_state_ui');
$mform->addElement('static', $field . '_state_ui', get_string($field, 'meetletacbfree'),
$this->render_override_chip_group(
$field . '_state',
$state,
get_string('showzip_help', 'meetletacbfree'),
[config::OVERRIDE_INHERIT => get_string($inheritkey, 'meetletacbfree')]
)
);
}
}
$this->standard_grading_coursemodule_elements();
$this->standard_coursemodule_elements();
$this->add_action_buttons();
}
public function definition_after_data() {
if (!registration_manager::is_registered()) {
return;
}
parent::definition_after_data();
}
public function data_preprocessing(&$defaultvalues) {
parent::data_preprocessing($defaultvalues);
$defaultvalues['endtimeenable'] = !empty($defaultvalues['endtime']) ? 1 : 0;
foreach (\meetletacbfree_activity_override_fields() as $field) {
$defaultvalues[$field . '_state'] = $this->resolve_toggle_state((object)$defaultvalues, $field, false);
}
$defaultvalues['participantsmode'] = $this->resolve_participants_mode((object)$defaultvalues, false);
if (!empty($defaultvalues['participants'])) {
$rules = json_decode($defaultvalues['participants'], true);
if (is_array($rules)) {
$defaultvalues['participants'] = json_encode(roles::get_participant_list(
(object)$defaultvalues,
context_course::instance($this->_course->id)
));
}
}
}
public function validation($data, $files) {
$errors = parent::validation($data, $files);
if (!registration_manager::is_registered()) {
$errors['registrationlock'] = get_string('registration_required', 'meetletacbfree');
return $errors;
}
if (!empty($data['endtimeenable']) && (int)$data['endtime'] <= (int)$data['starttime']) {
$errors['endtime'] = get_string('greaterstarttime', 'meetletacbfree');
}
if (!empty($data['meeturl']) && empty($data['linkmode'])
&& !preg_match('/^[a-zA-Z][a-zA-Z0-9\-]*$/', $data['meeturl'])) {
$errors['meeturl'] = get_string('invalidurl', 'meetletacbfree');
}
if (!empty($data['linkmode']) && empty($data['existingscoid'])) {
$errors['existingscoid_ui'] = get_string('errorexistingsco', 'meetletacbfree');
}
return $errors;
}
/**
* Render a compact chip-style radio group inside the activity form.
*
* @param array<int,string> $descriptions
* @param array<int,string>|null $labels
*/
protected function render_override_chip_group(
string $name,
int $selected,
string $help = '',
array $descriptions = [],
?array $labels = null
): string {
$labels = $labels ?? config::override_state_options();
$chips = '';
foreach ($labels as $value => $label) {
$id = 'id_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $name . '_' . $value);
$description = $descriptions[$value] ?? '';
$attrs = [
'type' => 'radio',
'name' => $name,
'id' => $id,
'value' => (int)$value,
'class' => 'meetlet-rolechip__input meetlet-settingchip__input',
'data-meetlet-setting-radio' => '1',
];
if ((int)$selected === (int)$value) {
$attrs['checked'] = 'checked';
}
if ($description !== '') {
$attrs['aria-label'] = $label . ' - ' . $description;
}
$body = \html_writer::empty_tag('input', $attrs)
. \html_writer::span(
\html_writer::span(s($label), 'meetlet-rolechip__text')
. ($description !== ''
? \html_writer::span(s($description), 'meetlet-settingchip__meta', ['aria-hidden' => 'true'])
. \html_writer::span(s($description), 'meetlet-sr-only')
: ''),
'meetlet-settingchip__content'
);
$chips .= \html_writer::tag('label', $body, [
'class' => 'meetlet-rolechip meetlet-settingchip',
'for' => $id,
]);
}
$groupclasses = 'meetlet-rolechip-group meetlet-settingchip-group';
if (count($labels) === 2) {
$groupclasses .= ' meetlet-settingchip-group--binary';
}
$html = \html_writer::div($chips, $groupclasses, [
'role' => 'radiogroup',
]);
if ($help !== '') {
$html .= \html_writer::div(s($help), 'meetlet-settinghint');
}
return $html;
}
protected function resolve_toggle_state(?object $current, string $field, bool $isnew): int {
if ($isnew || $current === null) {
return config::OVERRIDE_INHERIT;
}
if (property_exists($current, 'settingoverrides') || property_exists($current, 'participantsmode')) {
return config::activity_override_state($current, $field);
}
return !empty($current->{$field}) ? config::OVERRIDE_ON : config::OVERRIDE_OFF;
}
protected function resolve_participants_mode(?object $current, bool $isnew): int {
if ($isnew || $current === null) {
return roles::MODE_INHERIT;
}
if (property_exists($current, 'participantsmode')) {
return roles::activity_inherits_defaults($current) ? roles::MODE_INHERIT : roles::MODE_OVERRIDE;
}
return roles::MODE_OVERRIDE;
}
}