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:
2026-09-01 22:07:11 +03:30
commit b04221433a
93 changed files with 20819 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
<?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/>.
/**
* Reset-to-default controls for access settings (role mapping + feature matrix).
*
* @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\admin;
use mod_meetletacbmarketplace\local\icons;
use moodle_url;
defined('MOODLE_INTERNAL') || die();
/**
* Shared reset button, modal, and client strings for the access settings tab.
*/
class access_reset_ui {
/** @var bool */
protected static $assetsrendered = false;
/**
* Toolbar with a section-specific reset button.
*/
public static function toolbar(string $section): string {
if ($section === 'rolemapping') {
$label = get_string('reset_rolemapping', 'meetletacbmarketplace');
} else if ($section === 'featureaccess') {
$label = get_string('reset_featureaccess', 'meetletacbmarketplace');
} else {
return '';
}
return \html_writer::div(
\html_writer::tag('button',
icons::svg('refresh') .
\html_writer::span($label, 'meetlet-btn-with-icon__label'),
[
'type' => 'button',
'class' => 'meetlet-settings-btn meetlet-btn-with-icon meetlet-access-reset-btn',
'data-meetlet-reset-section' => $section,
]
),
'meetlet-access-reset-toolbar'
);
}
/**
* Modal + config root (render once on the access settings tab).
*/
public static function page_assets(moodle_url $ajaxurl): string {
if (self::$assetsrendered) {
return '';
}
self::$assetsrendered = true;
$strings = self::client_strings();
$html = \html_writer::div('', '', [
'id' => 'meetlet-access-reset-root',
'hidden' => 'hidden',
'data-endpoint' => $ajaxurl->out(false),
'data-sesskey' => sesskey(),
'data-strings' => json_encode($strings, JSON_UNESCAPED_UNICODE),
]);
$html .= self::modal_html();
return $html;
}
/**
* @return array<string,string>
*/
public static function client_strings(): array {
return [
'modal_cancel' => get_string('accessreset_modal_cancel', 'meetletacbmarketplace'),
'modal_close' => get_string('accessreset_modal_close', 'meetletacbmarketplace'),
'modal_ok' => get_string('accessreset_modal_ok', 'meetletacbmarketplace'),
'confirm_reset' => get_string('accessreset_confirm_reset', 'meetletacbmarketplace'),
'confirm_rolemapping_title' => get_string('reset_rolemapping_confirm_title', 'meetletacbmarketplace'),
'confirm_rolemapping' => get_string('reset_rolemapping_confirm', 'meetletacbmarketplace'),
'confirm_featureaccess_title' => get_string('reset_featureaccess_confirm_title', 'meetletacbmarketplace'),
'confirm_featureaccess' => get_string('reset_featureaccess_confirm', 'meetletacbmarketplace'),
'success_rolemapping' => get_string('reset_success_rolemapping', 'meetletacbmarketplace'),
'success_featureaccess' => get_string('reset_success_featureaccess', 'meetletacbmarketplace'),
'error' => get_string('reset_error', 'meetletacbmarketplace'),
'busy' => get_string('accessreset_busy', 'meetletacbmarketplace'),
];
}
protected static function modal_html(): string {
return \html_writer::div(
\html_writer::div('', 'meetlet-modal__backdrop', ['data-meetlet-modal-dismiss' => '1']) .
\html_writer::div(
\html_writer::div(
\html_writer::tag('h3', '', [
'class' => 'meetlet-modal__title',
'id' => 'meetlet-access-reset-modal-title',
]) .
\html_writer::tag('button', '×', [
'type' => 'button',
'class' => 'meetlet-modal__close',
'data-meetlet-modal-dismiss' => '1',
'aria-label' => get_string('accessreset_modal_close', 'meetletacbmarketplace'),
]),
'meetlet-modal__head'
) .
\html_writer::div('', 'meetlet-modal__body', [
'id' => 'meetlet-access-reset-modal-body',
]) .
\html_writer::div(
\html_writer::tag('button', get_string('accessreset_modal_cancel', 'meetletacbmarketplace'), [
'type' => 'button',
'class' => 'btn btn-outline-secondary',
'data-meetlet-modal-dismiss' => '1',
]) .
\html_writer::tag('button', get_string('accessreset_confirm_reset', 'meetletacbmarketplace'), [
'type' => 'button',
'class' => 'btn btn-outline-danger',
'id' => 'meetlet-access-reset-modal-confirm',
]),
'meetlet-modal__foot'
),
'meetlet-modal__dialog',
[
'role' => 'dialog',
'aria-modal' => 'true',
'aria-labelledby' => 'meetlet-access-reset-modal-title',
]
),
'meetlet-modal meetlet-modal--confirm',
[
'id' => 'meetlet-access-reset-modal',
'hidden' => 'hidden',
]
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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 setting: site-wide role × feature access matrix.
*
* @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\admin;
use admin_setting;
use mod_meetletacbmarketplace\local\featureaccess;
defined('MOODLE_INTERNAL') || die();
/**
* Stores the site default feature-access JSON used when an activity does not override.
*/
class setting_featureaccess extends admin_setting {
public function __construct() {
parent::__construct(
'meetletacbmarketplace/featureaccess',
get_string('featureaccess', 'meetletacbmarketplace'),
get_string('featureaccess_desc', 'meetletacbmarketplace'),
json_encode(featureaccess::builtin_defaults(), JSON_UNESCAPED_UNICODE)
);
}
public function get_setting() {
return $this->config_read($this->name);
}
public function write_setting($data) {
if (!is_array($data)) {
$data = [];
}
$json = featureaccess::matrix_from_post($data);
return $this->config_write($this->name, $json) ? '' : get_string('errorsetting', 'admin');
}
public function output_html($data, $query = '') {
$matrix = featureaccess::builtin_defaults();
if (is_string($data) && $data !== '') {
$decoded = json_decode($data, true);
if (is_array($decoded) && !empty($decoded)) {
$matrix = featureaccess::normalize($decoded);
}
} else if (is_array($data) && !empty($data)) {
$json = featureaccess::matrix_from_post($data);
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$matrix = featureaccess::normalize($decoded);
}
}
$html = access_reset_ui::toolbar('featureaccess');
$html .= featureaccess::render_matrix_html($matrix, $this->get_full_name());
return format_admin_setting(
$this,
$this->visiblename,
$html,
$this->description,
true,
'',
get_string('featureaccess_usesite', 'meetletacbmarketplace'),
$query
);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Admin UI for voluntary MeetLet site registration (Marketplace free edition).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\admin;
use admin_setting;
use mod_meetletacbmarketplace\local\icons;
use mod_meetletacbmarketplace\registration\manager;
defined('MOODLE_INTERNAL') || die();
/**
* Transparent online activation panel — no license key in this edition.
*/
class setting_registration extends admin_setting {
public function __construct() {
parent::__construct(
'meetletacbmarketplace/registration_panel',
'',
'',
''
);
$this->nosave = true;
}
public function get_setting() {
return true;
}
public function write_setting($data) {
return '';
}
public function output_html($data, $query = '') {
global $OUTPUT, $PAGE;
$summary = manager::status_summary();
$active = !empty($summary['active']);
$candeactivate = !empty($summary['can_deactivate']);
$ajaxurl = new \moodle_url('/mod/meetletacbmarketplace/ajax/registration.php');
$scrolljsurl = new \moodle_url('/mod/meetletacbmarketplace/javascript/meetlet_modal_scroll.js', [
'v' => '2026090146',
]);
$jsurl = new \moodle_url('/mod/meetletacbmarketplace/javascript/settings_registration.js', [
'v' => '2026090146',
]);
$privacytaburl = manager::privacy_tab_url();
$PAGE->requires->js($scrolljsurl);
$PAGE->requires->js($jsurl);
$statusbadge = \html_writer::span(
s($summary['status_label'] ?? ($active
? get_string('registration_status_active', 'meetletacbmarketplace')
: get_string('registration_status_inactive', 'meetletacbmarketplace'))),
'badge meetlet-license-badge ' . ($active ? 'meetlet-license-badge--active' : 'meetlet-license-badge--inactive'),
['id' => 'meetlet-registration-status-badge']
);
$datalist = \html_writer::tag('ul',
\html_writer::tag('li', get_string('registration_data_item_domain', 'meetletacbmarketplace', s($summary['domain']))) .
\html_writer::tag('li', get_string('registration_data_item_moodle', 'meetletacbmarketplace', s($summary['moodle_release']))) .
\html_writer::tag('li', get_string('registration_data_item_plugin', 'meetletacbmarketplace', s($summary['plugin_release']))) .
\html_writer::tag('li', get_string('registration_data_item_installation', 'meetletacbmarketplace', s($summary['installation_id']))) .
\html_writer::tag('li', get_string('registration_data_item_sitename', 'meetletacbmarketplace', s($summary['site_company']))) .
\html_writer::tag('li', get_string('registration_data_item_email', 'meetletacbmarketplace',
$summary['site_admin_email'] !== '' ? s($summary['site_admin_email']) : get_string('registration_data_optional', 'meetletacbmarketplace'))),
['class' => 'meetlet-registration-data']
);
$why = \html_writer::div(
\html_writer::tag('h4', get_string('registration_why_title', 'meetletacbmarketplace')) .
\html_writer::tag('p', get_string('registration_why', 'meetletacbmarketplace')),
'meetlet-guide__section'
);
$what = \html_writer::div(
\html_writer::tag('h4', get_string('registration_data_title', 'meetletacbmarketplace')) .
\html_writer::tag('p', get_string('registration_data_sent', 'meetletacbmarketplace')) .
$datalist .
\html_writer::tag('p',
get_string(
'registration_privacy',
'meetletacbmarketplace',
\html_writer::link($privacytaburl, get_string('registration_privacy_tablink', 'meetletacbmarketplace'))
),
['class' => 'mb-0']
),
'meetlet-guide__section'
);
$inactiveactions = [
'id' => 'meetlet-registration-actions-inactive',
'class' => 'meetlet-license-actions mt-3' . ($active ? ' d-none' : ''),
];
$activeactions = [
'id' => 'meetlet-registration-actions-active',
'class' => 'meetlet-license-actions mt-3' . ($active ? '' : ' d-none'),
];
$html = \html_writer::div(
$why .
$what .
\html_writer::div(
\html_writer::tag('p', get_string('registration_status_label', 'meetletacbmarketplace') . ' ' .
\html_writer::span($statusbadge, '', ['id' => 'meetlet-registration-status-badge'])) .
\html_writer::tag('p', get_string('registration_domain', 'meetletacbmarketplace', s($summary['domain'])), [
'id' => 'meetlet-registration-domain',
]) .
\html_writer::tag('p', get_string('registration_installation', 'meetletacbmarketplace', s($summary['installation_id'])), [
'id' => 'meetlet-registration-installation',
]) .
\html_writer::tag('p', s($summary['product_label'] ?? ''), [
'id' => 'meetlet-registration-product',
'class' => $active ? '' : 'd-none',
]) .
\html_writer::div(
$summary['error'] ? s($summary['error']) : '',
'alert alert-danger mt-2' . (!empty($summary['error']) ? '' : ' d-none'),
['id' => 'meetlet-registration-error']
),
'meetlet-license-status mb-2'
) .
\html_writer::div(
\html_writer::tag('button',
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg>'
. \html_writer::span(get_string('registration_activate', 'meetletacbmarketplace'), 'meetlet-btn-with-icon__label'),
[
'type' => 'button',
'class' => 'meetlet-settings-btn meetlet-btn-with-icon meetlet-license-btn--free',
'id' => 'meetlet-registration-activate',
'title' => get_string('registration_activate_help', 'meetletacbmarketplace'),
]
),
'',
$inactiveactions
) .
\html_writer::div(
\html_writer::tag('button',
icons::svg('refresh')
. \html_writer::span(get_string('registration_recheck', 'meetletacbmarketplace'), 'meetlet-btn-with-icon__label'),
[
'type' => 'button',
'class' => 'btn btn-secondary meetlet-btn-with-icon',
'id' => 'meetlet-registration-recheck',
]
) .
($candeactivate ? ' ' .
\html_writer::tag('button',
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M12 2a10 10 0 1 0 10 10h-2a8 8 0 1 1-8-8V2zm1 5v5.6l3.2 1.9-.8 1.4L11 13.2V7h2z"/></svg>'
. \html_writer::span(get_string('registration_deactivate', 'meetletacbmarketplace'), 'meetlet-btn-with-icon__label'),
[
'type' => 'button',
'class' => 'btn btn-outline-danger meetlet-btn-with-icon',
'id' => 'meetlet-registration-deactivate',
]
) : ''),
'',
$activeactions
) .
\html_writer::div('', 'mt-3 d-none', ['id' => 'meetlet-registration-flash']) .
\html_writer::div('', '', [
'id' => 'meetlet-registration-config',
'data-ajaxurl' => $ajaxurl->out(false),
'data-sesskey' => sesskey(),
'data-api-input' => 'id_s_meetletacbmarketplace_registration_api_url',
'data-label-active' => get_string('registration_status_active', 'meetletacbmarketplace'),
'data-label-inactive' => get_string('registration_status_inactive', 'meetletacbmarketplace'),
'data-label-domain' => get_string('registration_domain', 'meetletacbmarketplace', '__V__'),
'data-label-installation' => get_string('registration_installation', 'meetletacbmarketplace', '__V__'),
'data-confirm-activate-title' => get_string('registration_activate_confirm_title', 'meetletacbmarketplace'),
'data-confirm-activate-ok' => get_string('registration_activate_confirm_ok', 'meetletacbmarketplace'),
'data-confirm-deactivate-title' => get_string('registration_deactivate_confirm_title', 'meetletacbmarketplace'),
'data-confirm-deactivate-ok' => get_string('registration_deactivate_confirm_ok', 'meetletacbmarketplace'),
'data-confirm-deactivate' => get_string('registration_deactivate_confirm', 'meetletacbmarketplace'),
'hidden' => 'hidden',
]) .
self::confirm_modal_html(),
'meetlet-registration-panel meetlet-settings-card'
);
return format_admin_setting($this, '', $html, '', true, '', null, $query);
}
protected static function confirm_modal_html(): string {
$activatebody = \html_writer::tag('p', get_string('registration_activate_confirm_lead', 'meetletacbmarketplace'), [
'class' => 'meetlet-modal__lead',
]) .
\html_writer::tag('p', get_string('registration_activate_confirm_data', 'meetletacbmarketplace'), [
'class' => 'meetlet-modal__keep',
]);
$deactivatebody = \html_writer::tag('p', get_string('registration_deactivate_confirm_lead', 'meetletacbmarketplace'), [
'class' => 'meetlet-modal__lead',
]) .
\html_writer::div(
\html_writer::tag('p', get_string('registration_deactivate_confirm_warning', 'meetletacbmarketplace'), [
'class' => 'meetlet-modal__callout-title',
]),
'meetlet-modal__callout'
);
return \html_writer::div(
\html_writer::div('', 'meetlet-modal__backdrop', ['data-meetlet-modal-dismiss' => '1']) .
\html_writer::div(
\html_writer::div(
\html_writer::tag('h3', '', [
'class' => 'meetlet-modal__title',
'id' => 'meetlet-registration-modal-title',
]) .
\html_writer::tag('button', '×', [
'type' => 'button',
'class' => 'meetlet-modal__close',
'data-meetlet-modal-dismiss' => '1',
'aria-label' => get_string('registration_modal_close', 'meetletacbmarketplace'),
]),
'meetlet-modal__head'
) .
\html_writer::div('', 'meetlet-modal__body', ['id' => 'meetlet-registration-modal-body']) .
\html_writer::tag('template', $activatebody, ['id' => 'meetlet-registration-tpl-activate']) .
\html_writer::tag('template', $deactivatebody, ['id' => 'meetlet-registration-tpl-deactivate']) .
\html_writer::div(
\html_writer::tag('button', get_string('cancel', 'moodle'), [
'type' => 'button',
'class' => 'btn btn-secondary',
'data-meetlet-modal-dismiss' => '1',
]) .
\html_writer::tag('button', '', [
'type' => 'button',
'class' => 'btn btn-primary',
'id' => 'meetlet-registration-modal-confirm',
]),
'meetlet-modal__foot'
),
'meetlet-modal__dialog',
[
'role' => 'dialog',
'aria-modal' => 'true',
'aria-labelledby' => 'meetlet-registration-modal-title',
]
),
'meetlet-modal meetlet-modal--confirm',
[
'id' => 'meetlet-registration-modal',
'hidden' => 'hidden',
]
);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?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 setting: default Moodle role → Adobe Connect role mapping.
*
* @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\admin;
use admin_setting;
use mod_meetletacbmarketplace\local\roles;
defined('MOODLE_INTERNAL') || die();
/**
* Stores default participant rules JSON used for new activities / empty lists.
*/
class setting_rolemapping extends admin_setting {
public function __construct() {
parent::__construct(
'meetletacbmarketplace/default_participants',
get_string('siterolemapping', 'meetletacbmarketplace'),
get_string('siterolemapping_desc', 'meetletacbmarketplace'),
json_encode(roles::builtin_site_defaults(), JSON_UNESCAPED_UNICODE)
);
}
public function get_setting() {
return $this->config_read($this->name);
}
public function write_setting($data) {
if (!is_array($data)) {
$data = [];
}
$json = roles::rules_json_from_matrix_post($data);
return $this->config_write($this->name, $json) ? '' : get_string('errorsetting', 'admin');
}
public function output_html($data, $query = '') {
$rules = roles::builtin_site_defaults();
if (is_string($data) && $data !== '') {
$decoded = json_decode($data, true);
if (is_array($decoded) && !empty($decoded)) {
$rules = roles::normalize_rules($decoded);
}
} else if (is_array($data) && !empty($data) && isset($data[0]) && is_array($data[0])) {
$rules = roles::normalize_rules($data);
} else if (is_array($data) && !empty($data)) {
// Posted radio matrix already (preview / validation).
$json = roles::rules_json_from_matrix_post($data);
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$rules = roles::normalize_rules($decoded);
}
}
$html = access_reset_ui::toolbar('rolemapping');
$html .= roles::render_site_mapping_html($rules, $this->get_full_name());
return format_admin_setting(
$this,
$this->visiblename,
$html,
$this->description,
true,
'',
get_string('siterolemapping_defaultinfo', 'meetletacbmarketplace'),
$query
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?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 settings tabs with a fixed hero above the tab bar.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\admin;
defined('MOODLE_INTERNAL') || die();
/**
* Extends Boost tabbed settings so a hero banner stays above all tabs.
*/
class settingspage_tabs extends \theme_boost_admin_settingspage_tabs {
/** @var string HTML rendered above the tab list. */
protected string $herohtml = '';
/**
* Set hero markup shown above tabs on every tab switch.
*/
public function set_hero(string $html): void {
$this->herohtml = $html;
}
/**
* @return string
*/
public function output_html() {
return $this->herohtml . parent::output_html();
}
}
+398
View File
@@ -0,0 +1,398 @@
<?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 XML API client.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\api;
use mod_meetletacbmarketplace\local\config;
use moodle_exception;
use SimpleXMLElement;
/**
* Lightweight Adobe Connect XML API client with session reuse.
*/
class client {
/** @var string */
protected $apiurl;
/** @var int */
protected $port;
/** @var bool */
protected $https;
/** @var string */
protected $cookie = '';
/** @var string */
protected $lastresponse = '';
/**
* Constructor.
*/
public function __construct(?string $apiurl = null, ?int $port = null, ?bool $https = null) {
$this->apiurl = $apiurl ?? config::get_api_url();
$this->port = $port ?? config::get_port();
$this->https = $https ?? config::use_https();
}
/**
* Login as admin and store session cookie.
*
* @return self
*/
public function login_admin(): self {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$info = $this->request(['action' => 'common-info']);
$this->cookie = (string)($info->common->cookie ?? '');
if ($this->cookie === '') {
throw new moodle_exception('erroremptyxml', 'meetletacbmarketplace');
}
$result = $this->request([
'action' => 'login',
'login' => config::get_admin_login(),
'password' => config::get_admin_password(),
]);
if ((string)$result->status['code'] !== 'ok') {
throw new moodle_exception('errorlogin', 'meetletacbmarketplace');
}
return $this;
}
/**
* External-auth (HTTP header) login for a Connect user.
*
* @param string $connectlogin Adobe Connect login (often email)
* @return string BREEZESESSION cookie value
*/
public function login_external(string $connectlogin): string {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$headername = config::get_http_auth_header();
$candidates = array_values(array_unique(array_filter([
trim($connectlogin),
])));
$lastfail = [];
foreach ($candidates as $login) {
// Preferred: XML params (same as classic adobeconnect plugin).
$cookie = $this->try_external_login_xml($headername, $login, $lastfail);
if ($cookie !== '') {
$this->cookie = $cookie;
return $cookie;
}
// Fallback: form-urlencoded (some Connect setups only accept this).
$cookie = $this->try_external_login_form($headername, $login, $lastfail);
if ($cookie !== '') {
$this->cookie = $cookie;
return $cookie;
}
}
$facts = [
'login' => (string)($lastfail['login'] ?? ($candidates[0] ?? '')),
'adobe_code' => (string)($lastfail['code'] ?? ''),
'adobe_subcode' => (string)($lastfail['subcode'] ?? 'no-login'),
];
throw new \mod_meetletacbmarketplace\local\usererror(
'sso_01',
'errorhttpauth',
array_filter($facts),
(string)($lastfail['xml'] ?? '')
);
}
/**
* Try SSO via XML body.
*/
protected function try_external_login_xml(string $headername, string $login, array &$lastfail): string {
$xml = $this->build_params_xml([
'action' => 'login',
'external-auth' => 'use',
]);
$raw = $this->curl_post($xml, ["{$headername}: {$login}"], true);
$this->lastresponse = $raw;
$lastfail = $this->describe_auth_failure($raw, $login);
if (!$this->response_ok($raw)) {
return '';
}
return $this->extract_breeze_cookie($raw);
}
/**
* Try SSO via application/x-www-form-urlencoded POST.
*/
protected function try_external_login_form(string $headername, string $login, array &$lastfail): string {
$raw = $this->curl_post_form(
'action=login&external-auth=use',
["{$headername}: {$login}"],
true
);
$this->lastresponse = $raw;
$lastfail = $this->describe_auth_failure($raw, $login);
if (!$this->response_ok($raw)) {
return '';
}
return $this->extract_breeze_cookie($raw);
}
/**
* Structured Adobe SSO failure (login, code, subcode, xml).
*
* @return array{login:string,code:string,subcode:string,xml:string}
*/
protected function describe_auth_failure(string $raw, string $login): array {
$xmlstart = strpos($raw, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$code = '';
$sub = '';
$sx = @simplexml_load_string(($xmlstart === false) ? $raw : substr($raw, $xmlstart));
if ($sx !== false) {
$code = (string)($sx->status['code'] ?? '');
$sub = (string)($sx->status['subcode'] ?? '');
}
return [
'login' => $login,
'code' => $code,
'subcode' => $sub,
'xml' => \mod_meetletacbmarketplace\local\usererror::format_xml($raw),
];
}
/**
* POST form fields to Adobe Connect.
*
* @param string $body
* @param array $extraheaders
* @param bool $includeheader
* @return string
*/
protected function curl_post_form(string $body, array $extraheaders = [], bool $includeheader = false): string {
global $CFG;
$url = $this->apiurl;
if ($this->https && stripos($url, 'http://') === 0) {
$url = 'https://' . substr($url, 7);
} else if ($this->https && stripos($url, 'https://') !== 0 && stripos($url, 'http://') !== 0) {
$url = 'https://' . $url;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_PORT, $this->port);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge(
['Content-Type: application/x-www-form-urlencoded'],
$extraheaders
));
curl_setopt($ch, CURLOPT_HEADER, $includeheader ? 1 : 0);
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
}
if ($this->https) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
}
if (!empty($CFG->proxyhost)) {
curl_setopt($ch, CURLOPT_PROXY, $CFG->proxyhost);
if (!empty($CFG->proxyport)) {
curl_setopt($ch, CURLOPT_PROXYPORT, $CFG->proxyport);
}
}
$result = curl_exec($ch);
if ($result === false) {
$err = curl_error($ch);
curl_close($ch);
throw new moodle_exception('erroremptyxml', 'meetletacbmarketplace', '', null, $err);
}
curl_close($ch);
return (string)$result;
}
/**
* Logout current session.
*/
public function logout(): void {
if ($this->cookie === '') {
return;
}
try {
$this->request(['action' => 'logout']);
} catch (\Throwable $e) {
// Ignore logout failures.
}
$this->cookie = '';
}
/**
* Perform an API action and return SimpleXMLElement.
*
* @param array $params
* @return SimpleXMLElement
*/
public function request(array $params): SimpleXMLElement {
$xml = $this->build_params_xml($params);
$raw = $this->curl_post($xml);
$this->lastresponse = $raw;
$xmlstart = strpos($raw, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$body = ($xmlstart === false) ? $raw : substr($raw, $xmlstart);
if ($body === '' || $body === false) {
throw new moodle_exception('erroremptyxml', 'meetletacbmarketplace');
}
$sx = @simplexml_load_string($body);
if ($sx === false) {
throw new moodle_exception('erroremptyxml', 'meetletacbmarketplace');
}
return $sx;
}
/**
* Whether last/raw response status is ok.
*
* @param string|null $raw
* @return bool
*/
public function response_ok(?string $raw = null): bool {
$raw = $raw ?? $this->lastresponse;
$xmlstart = strpos($raw, '<?xml');
if ($xmlstart === false) {
$xmlstart = strpos($raw, '<results');
}
$body = ($xmlstart === false) ? $raw : substr($raw, $xmlstart);
$sx = @simplexml_load_string($body);
if ($sx === false) {
return false;
}
return (string)$sx->status['code'] === 'ok';
}
/**
* Get session cookie.
*/
public function get_cookie(): string {
return $this->cookie;
}
/**
* Get raw last response.
*/
public function get_last_response(): string {
return $this->lastresponse;
}
/**
* Build XML params payload.
*
* @param array $params
* @return string
*/
protected function build_params_xml(array $params): string {
$dom = new \DOMDocument('1.0', 'UTF-8');
$root = $dom->createElement('params');
$dom->appendChild($root);
foreach ($params as $name => $value) {
$child = $dom->createElement('param', htmlspecialchars((string)$value, ENT_XML1 | ENT_COMPAT, 'UTF-8'));
$child->setAttribute('name', (string)$name);
$root->appendChild($child);
}
return $dom->saveXML();
}
/**
* POST XML to Adobe Connect.
*
* @param string $xml
* @param array $extraheaders
* @param bool $includeheader
* @return string
*/
protected function curl_post(string $xml, array $extraheaders = [], bool $includeheader = false): string {
global $CFG;
$url = $this->apiurl;
if ($this->https && stripos($url, 'http://') === 0) {
$url = 'https://' . substr($url, 7);
} else if ($this->https && stripos($url, 'https://') !== 0 && stripos($url, 'http://') !== 0) {
$url = 'https://' . $url;
}
if ($this->cookie !== '') {
$url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($this->cookie);
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
curl_setopt($ch, CURLOPT_PORT, $this->port);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge(['Content-Type: text/xml'], $extraheaders));
curl_setopt($ch, CURLOPT_HEADER, $includeheader ? 1 : 0);
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
}
if ($this->https) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
}
if (!empty($CFG->proxyhost)) {
curl_setopt($ch, CURLOPT_PROXY, $CFG->proxyhost);
if (!empty($CFG->proxyport)) {
curl_setopt($ch, CURLOPT_PROXYPORT, $CFG->proxyport);
}
}
$result = curl_exec($ch);
if ($result === false) {
$err = curl_error($ch);
curl_close($ch);
throw new moodle_exception('erroremptyxml', 'meetletacbmarketplace', '', null, $err);
}
curl_close($ch);
return (string)$result;
}
/**
* Extract BREEZESESSION from header+body response.
*
* @param string $raw
* @return string
*/
protected function extract_breeze_cookie(string $raw): string {
if (preg_match('/BREEZESESSION=([^;\s]+)/', $raw, $m)) {
return $m[1];
}
return '';
}
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
<?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/>.
namespace mod_meetletacbmarketplace\event;
defined('MOODLE_INTERNAL') || die();
/**
* Course module viewed event.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class course_module_viewed extends \core\event\course_module_viewed {
protected function init() {
$this->data['objecttable'] = 'meetletacbmarketplace';
$this->data['crud'] = 'r';
$this->data['edulevel'] = self::LEVEL_PARTICIPATING;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?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/>.
namespace mod_meetletacbmarketplace\event;
defined('MOODLE_INTERNAL') || die();
/**
* User joined a MeetLet meeting.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class meeting_joined extends \core\event\base {
protected function init() {
$this->data['objecttable'] = 'meetletacbmarketplace';
$this->data['crud'] = 'r';
$this->data['edulevel'] = self::LEVEL_PARTICIPATING;
}
public static function get_name() {
return get_string('joinmeeting', 'meetletacbmarketplace');
}
public function get_description() {
return "The user with id '{$this->userid}' joined MeetLet meeting '{$this->objectid}'.";
}
public function get_url() {
return new \moodle_url('/mod/meetletacbmarketplace/view.php', ['id' => $this->contextinstanceid]);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?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/>.
/**
* Ajax: SSO URL to view uploaded meeting content in Adobe Connect.
*
* @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\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\config;
use mod_meetletacbmarketplace\local\content;
use mod_meetletacbmarketplace\local\featureaccess;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\roles;
use mod_meetletacbmarketplace\local\sso;
use mod_meetletacbmarketplace\local\usererror;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Prepare Adobe Connect content view URL with session for current user.
*/
class get_content_launch_url extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'contentscoid' => new external_value(PARAM_INT, 'Content sco id'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, int $contentscoid, int $groupid = 0): array {
global $USER;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'contentscoid' => $contentscoid,
'groupid' => $groupid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
featureaccess::require_feature(featureaccess::CONTENT, $inst->context, $inst->record);
$service = new service();
$service->login_admin();
$item = content::find_in_meeting($service, $inst->require_meeting(), (int)$params['contentscoid']);
$service->logout();
if (!$item || empty($item->urlpath)) {
throw new moodle_exception('nocontent', 'meetletacbmarketplace');
}
try {
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id);
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
$auth = sso::provision_and_login($USER, (int)$params['contentscoid'], $permission);
$url = config::content_view_url((string)$item->urlpath, $auth['cookie']);
return usererror::ws_ok($url);
} catch (\Throwable $e) {
if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) {
throw $e;
}
return usererror::ws_fail($e, $inst->context);
}
}
public static function execute_returns(): external_single_structure {
return new external_single_structure(usererror::ws_url_returns());
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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/>.
/**
* Ajax: build SSO join URL.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\config;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\roles;
use mod_meetletacbmarketplace\local\sso;
use mod_meetletacbmarketplace\local\usererror;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Prepare Adobe Connect join URL for current user.
*/
class get_join_url extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, int $groupid = 0): array {
global $USER;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'groupid' => $groupid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
if (!\mod_meetletacbmarketplace\local\featureaccess::allowed(
\mod_meetletacbmarketplace\local\featureaccess::JOIN,
$inst->context,
$inst->record
)) {
throw new moodle_exception('notparticipant', 'meetletacbmarketplace');
}
if ($inst->cm->groupmode != NOGROUPS) {
$usrgroups = groups_get_user_groups($inst->course->id, $USER->id);
$usrgroups = $usrgroups[0] ?? [];
$aag = has_capability('moodle/site:accessallgroups', $inst->context);
if (!$aag && $params['groupid'] && !in_array($params['groupid'], $usrgroups, false)) {
throw new moodle_exception('usergrouprequired', 'meetletacbmarketplace');
}
}
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id);
if ($adoberole === null) {
throw new moodle_exception('notparticipant', 'meetletacbmarketplace');
}
try {
// Gate join before any Adobe user provisioning / SSO.
$lifecycle = \mod_meetletacbmarketplace\local\meetingstatus::resolve($inst, (int)$USER->id, true);
if (empty($lifecycle['joinable'])) {
$code = !empty($lifecycle['errorcode']) ? $lifecycle['errorcode'] : 'meetingended';
throw new moodle_exception($code, 'meetletacbmarketplace');
}
$scoid = $inst->require_meeting();
$service = new service();
$service->login_admin();
$info = $service->meeting_info($scoid);
$service->logout();
if (!$info) {
throw new moodle_exception('nomeeting', 'meetletacbmarketplace');
}
$auth = sso::provision_and_login($USER, $scoid, $adoberole);
$url = config::with_html_client(config::public_url($info->url, $auth['cookie']));
$event = \mod_meetletacbmarketplace\event\meeting_joined::create([
'context' => $inst->context,
'objectid' => $inst->record->id,
'relateduserid' => $USER->id,
]);
$event->trigger();
return usererror::ws_ok($url);
} catch (\Throwable $e) {
if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) {
throw $e;
}
return usererror::ws_fail($e, $inst->context);
}
}
public static function execute_returns(): external_single_structure {
return new external_single_structure(usererror::ws_url_returns());
}
}
+86
View File
@@ -0,0 +1,86 @@
<?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/>.
/**
* Ajax: poll meeting join status.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\local\featureaccess;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\meetingstatus;
defined('MOODLE_INTERNAL') || die();
/**
* Lightweight status poll for the activity hero badge / join gate.
*/
class get_meeting_status extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, int $groupid = 0): array {
global $USER;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'groupid' => $groupid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
$info = meetingstatus::resolve($inst, (int)$USER->id, true);
$featurejoin = featureaccess::allowed(featureaccess::JOIN, $inst->context, $inst->record);
$joinable = $featurejoin && !empty($info['joinable']);
return [
'status' => (string)$info['status'],
'statuslabel' => (string)$info['statuslabel'],
'joinable' => (bool)$joinable,
'message' => (string)$info['message'],
'errorcode' => (string)$info['errorcode'],
'servertime' => time(),
];
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'status' => new external_value(PARAM_ALPHANUMEXT, 'Status code'),
'statuslabel' => new external_value(PARAM_TEXT, 'Localized status label'),
'joinable' => new external_value(PARAM_BOOL, 'Whether join may be requested'),
'message' => new external_value(PARAM_TEXT, 'User-facing gate message'),
'errorcode' => new external_value(PARAM_ALPHANUMEXT, 'Exception string id when not joinable'),
'servertime' => new external_value(PARAM_INT, 'Server unix time'),
]);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?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/>.
/**
* Ajax: SSO URL to play or edit a recording in Adobe Connect.
*
* @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\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\config;
use mod_meetletacbmarketplace\local\featureaccess;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\roles;
use mod_meetletacbmarketplace\local\sso;
use mod_meetletacbmarketplace\local\usererror;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Prepare Adobe Connect recording play/edit URL with session for current user.
*/
class get_recording_launch_url extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'recordingscoid' => new external_value(PARAM_INT, 'Recording sco id'),
'mode' => new external_value(PARAM_ALPHA, 'play or edit'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, int $recordingscoid, string $mode, int $groupid = 0): array {
global $DB, $USER;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'recordingscoid' => $recordingscoid,
'mode' => $mode,
'groupid' => $groupid,
]);
$mode = strtolower($params['mode']);
if (!in_array($mode, ['play', 'edit'], true)) {
throw new moodle_exception('error', 'meetletacbmarketplace');
}
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
featureaccess::require_feature(featureaccess::RECORDINGS, $inst->context, $inst->record);
if ($mode === 'edit') {
featureaccess::require_feature(featureaccess::ADOBEEDITOR, $inst->context, $inst->record);
}
$record = $DB->get_record('meetlet_marketplace_recordings', [
'instanceid' => $inst->record->id,
'recordingscoid' => $params['recordingscoid'],
]);
if (!$record || empty($record->urlpath)) {
throw new moodle_exception('norecordings', 'meetletacbmarketplace');
}
try {
// Prefer meeting role when available; fall back to view for play / host for edit.
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, $USER->id);
if ($mode === 'edit') {
$permission = ($adoberole === service::ROLE_HOST || $adoberole === service::ROLE_PRESENTER)
? $adoberole
: service::ROLE_HOST;
} else {
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
}
$auth = sso::provision_and_login($USER, (int)$params['recordingscoid'], $permission);
$url = $mode === 'edit'
? config::recording_editor_url((string)$record->urlpath, $auth['cookie'])
: config::recording_play_url((string)$record->urlpath, $auth['cookie']);
return usererror::ws_ok($url);
} catch (\Throwable $e) {
if ($e instanceof moodle_exception && in_array($e->errorcode, ['nopermissions', 'requirelogin', 'invalidparameter'], true)) {
throw $e;
}
return usererror::ws_fail($e, $inst->context);
}
}
public static function execute_returns(): external_single_structure {
return new external_single_structure(usererror::ws_url_returns());
}
}
+346
View File
@@ -0,0 +1,346 @@
<?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/>.
/**
* Ajax: load tab HTML/data.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\config;
use mod_meetletacbmarketplace\local\content;
use mod_meetletacbmarketplace\local\featureaccess;
use mod_meetletacbmarketplace\local\icons;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\recordings;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Return tab payload for the activity UI.
*/
class get_tab extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'tab' => new external_value(PARAM_ALPHA, 'Tab name'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, string $tab, int $groupid = 0): array {
global $PAGE;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'tab' => $tab,
'groupid' => $groupid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
$PAGE->set_context($inst->context);
if (!config::is_configured()) {
return [
'html' => \html_writer::div(get_string('notconfigured', 'meetletacbmarketplace'), 'meetlet-alert'),
'tab' => $params['tab'],
];
}
$html = '';
switch ($params['tab']) {
case 'details':
// Served locally in the activity page; keep endpoint defensive.
$html = \html_writer::div(get_string('emptydetails', 'meetletacbmarketplace'), 'meetlet-empty');
break;
case 'info':
// Legacy tab id — no Adobe call.
$html = \html_writer::div(get_string('emptydetails', 'meetletacbmarketplace'), 'meetlet-empty');
break;
case 'recordings':
featureaccess::require_feature(featureaccess::RECORDINGS, $inst->context, $inst->record);
$html = self::render_recordings($inst);
break;
case 'content':
featureaccess::require_feature(featureaccess::CONTENT, $inst->context, $inst->record);
$html = self::render_content($inst);
break;
default:
throw new moodle_exception('error', 'meetletacbmarketplace');
}
return ['html' => $html, 'tab' => $params['tab']];
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'html' => new external_value(PARAM_RAW, 'Tab HTML'),
'tab' => new external_value(PARAM_ALPHA, 'Tab id'),
]);
}
/**
* Toolbar with refresh for cacheable Adobe tabs.
*/
protected static function wrap_refreshable(string $html, string $tab, bool $pending = false): string {
$refreshlabel = get_string('refreshtab', 'meetletacbmarketplace');
$refreshcontent = icons::svg('refresh')
. \html_writer::span($refreshlabel, 'meetlet-btn-with-icon__label');
$bits = [
\html_writer::tag('button', $refreshcontent, [
'type' => 'button',
'class' => 'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon meetlet-tabtoolbar__refresh',
'data-action' => 'refresh-tab',
'data-tab' => $tab,
'title' => $refreshlabel,
'aria-label' => $refreshlabel,
]),
];
if ($pending) {
$bits[] = \html_writer::div(
\html_writer::span(get_string('jobpollwait', 'meetletacbmarketplace'), 'meetlet-jobpoll__label') .
\html_writer::div(\html_writer::div('', 'meetlet-jobpoll__bar'), 'meetlet-jobpoll__track'),
'meetlet-jobpoll',
['aria-live' => 'polite']
);
}
$bar = \html_writer::div(implode('', $bits), 'meetlet-tabtoolbar');
$attrs = ['data-meetlet-tab' => $tab];
if ($pending) {
$attrs['data-meetlet-jobs-pending'] = '1';
$attrs['data-meetlet-jobs-poll-ms'] = '60000';
}
return \html_writer::div($bar . $html, 'meetlet-tabbody', $attrs);
}
/**
* Toolbar control with icon and visible label (matches refresh button styling).
*
* @param array $attrs Extra attributes (href makes it a link).
*/
protected static function labeled_action(string $icon, string $label, array $attrs = []): string {
$extraclass = $attrs['class'] ?? '';
unset($attrs['class']);
$classes = trim(
'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon ' . $extraclass
);
$attrs['class'] = $classes;
$attrs['title'] = $label;
$attrs['aria-label'] = $label;
$content = icons::svg($icon)
. \html_writer::span($label, 'meetlet-btn-with-icon__label');
if (!empty($attrs['href'])) {
$url = $attrs['href'];
unset($attrs['href']);
$attrs['target'] = $attrs['target'] ?? '_blank';
if (($attrs['target'] ?? '') === '_self') {
unset($attrs['rel']);
} else {
$attrs['rel'] = $attrs['rel'] ?? 'noopener';
}
return \html_writer::link($url, $content, $attrs);
}
$attrs['type'] = $attrs['type'] ?? 'button';
return \html_writer::tag('button', $content, $attrs);
}
/**
* Icon control with hover tip.
*
* @param array $attrs Extra attributes (href makes it a link).
*/
protected static function icon_action(string $icon, string $label, array $attrs = []): string {
$classes = trim('meetlet-iconbtn ' . ($attrs['class'] ?? ''));
unset($attrs['class']);
$attrs['class'] = $classes;
$attrs['data-tip'] = $label;
$attrs['aria-label'] = $label;
$content = icons::svg($icon) . \html_writer::span($label, 'meetlet-sr-only');
if (!empty($attrs['href'])) {
$url = $attrs['href'];
unset($attrs['href']);
$attrs['target'] = $attrs['target'] ?? '_blank';
if (($attrs['target'] ?? '') === '_self') {
unset($attrs['rel']);
} else {
$attrs['rel'] = $attrs['rel'] ?? 'noopener';
}
return \html_writer::link($url, $content, $attrs);
}
$attrs['type'] = $attrs['type'] ?? 'button';
return \html_writer::tag('button', $content, $attrs);
}
protected static function format_jobstatus(string $status, bool $mp4ready = false): string {
return recordings::jobstatus_label($status, $mp4ready);
}
protected static function render_recordings(instance $inst): string {
$rows = recordings::for_tab($inst);
if (empty($rows)) {
return self::wrap_refreshable(
\html_writer::div(get_string('norecordings', 'meetletacbmarketplace'), 'meetlet-empty'),
'recordings'
);
}
$canedit = featureaccess::allowed(featureaccess::EDITRECORDING, $inst->context, $inst->record);
$canadobeedit = featureaccess::allowed(featureaccess::ADOBEEDITOR, $inst->context, $inst->record);
$table = new \html_table();
$table->attributes['class'] = 'generaltable meetlet-table meetlet-table--recordings';
$table->head = [
get_string('recordingname', 'meetletacbmarketplace'),
get_string('recordingdate', 'meetletacbmarketplace'),
get_string('duration', 'meetletacbmarketplace'),
get_string('sessiondetails', 'meetletacbmarketplace'),
];
foreach ($rows as $r) {
$actions = [];
$actions[] = self::icon_action('play', get_string('play', 'meetletacbmarketplace'), [
'class' => 'meetlet-iconbtn--play',
'data-action' => 'launch-recording',
'data-mode' => 'play',
'data-recording' => $r->recordingscoid,
]);
if ($canadobeedit && !empty($r->urlpath)) {
$actions[] = self::icon_action('adobeedit', get_string('adobeeditor', 'meetletacbmarketplace'), [
'class' => 'meetlet-iconbtn--adobeedit',
'data-action' => 'launch-recording',
'data-mode' => 'edit',
'data-recording' => $r->recordingscoid,
]);
}
$namehtml = self::render_recording_name((string)$r->name, (int)$r->recordingscoid, $canedit);
$dur = $r->duration ? gmdate('H:i:s', (int)$r->duration) : '-';
$recdate = !empty($r->timestart) ? userdate((int)$r->timestart) : '—';
$table->data[] = [
$namehtml,
$recdate,
$dur,
\html_writer::div(implode('', $actions), 'meetlet-iconactions'),
];
}
return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'recordings', false);
}
/**
* Recording title with optional inline rename control.
*/
protected static function render_recording_name(string $name, int $recordingscoid, bool $canedit): string {
$text = \html_writer::span(format_string($name), 'meetlet-recname__text');
if (!$canedit) {
return \html_writer::div($text, 'meetlet-recname');
}
$pencil = self::icon_action('edit', get_string('editrecording', 'meetletacbmarketplace'), [
'class' => 'meetlet-iconbtn--edit meetlet-iconbtn--inline',
'data-action' => 'inline-rename',
]);
$input = \html_writer::empty_tag('input', [
'type' => 'text',
'class' => 'meetlet-recname__input',
'value' => $name,
'maxlength' => 255,
'hidden' => 'hidden',
]);
return \html_writer::div($text . $pencil . $input, 'meetlet-recname', [
'data-recording' => $recordingscoid,
'data-name' => $name,
]);
}
protected static function render_content(instance $inst): string {
global $USER;
$service = new service();
$service->login_admin();
$items = $service->list_meeting_content($inst->require_meeting());
$service->logout();
if (empty($items)) {
return self::wrap_refreshable(
\html_writer::div(get_string('nocontent', 'meetletacbmarketplace'), 'meetlet-empty'),
'content'
);
}
$table = new \html_table();
$table->attributes['class'] = 'generaltable meetlet-table meetlet-table--content';
$table->head = [
get_string('name'),
get_string('contenttype', 'meetletacbmarketplace'),
get_string('contentsize', 'meetletacbmarketplace'),
get_string('contentdate', 'meetletacbmarketplace'),
get_string('sessiondetails', 'meetletacbmarketplace'),
];
foreach ($items as $item) {
$scoid = (int)($item->scoid ?? 0);
$actions = [
self::icon_action('play', get_string('view'), [
'class' => 'meetlet-iconbtn--play',
'data-action' => 'launch-content',
'data-content' => $scoid,
]),
];
if ($scoid > 0 && !empty($item->name)) {
$dlurl = content::download_url((int)$USER->id, (int)$inst->cm->id, $scoid)->out(false);
$actions[] = self::icon_action('download', get_string('downloadcontent', 'meetletacbmarketplace'), [
'href' => $dlurl,
'class' => 'meetlet-iconbtn--download',
'target' => '_self',
'download' => $item->name,
'data-action' => 'download-content',
]);
}
$table->data[] = [
format_string($item->name),
s($item->typelabel ?? service::content_type_label(
(string)($item->icon ?? ''),
(string)($item->type ?? ''),
(string)($item->name ?? '')
)),
self::format_bytes((int)($item->bytesize ?? 0)),
s($item->datecreated_fmt !== '' ? $item->datecreated_fmt : '—'),
\html_writer::div(implode('', $actions), 'meetlet-iconactions'),
];
}
return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'content');
}
protected static function format_bytes(int $bytes): string {
if ($bytes <= 0) {
return '—';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
$n = (float)$bytes;
while ($n >= 1024 && $i < count($units) - 1) {
$n /= 1024;
$i++;
}
return format_float($n, $i === 0 ? 0 : 1) . ' ' . $units[$i];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?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/>.
/**
* Ajax: rename a recording SCO.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\instance;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Rename Adobe Connect recording.
*/
class rename_recording extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'cmid' => new external_value(PARAM_INT, 'Course module id'),
'recordingscoid' => new external_value(PARAM_INT, 'Recording sco id'),
'name' => new external_value(PARAM_TEXT, 'New recording name'),
'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0),
]);
}
public static function execute(int $cmid, int $recordingscoid, string $name, int $groupid = 0): array {
global $DB;
$params = self::validate_parameters(self::execute_parameters(), [
'cmid' => $cmid,
'recordingscoid' => $recordingscoid,
'name' => $name,
'groupid' => $groupid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
$inst = instance::get_from_cmid($params['cmid'], $params['groupid']);
self::validate_context($inst->context);
require_capability('mod/meetletacbmarketplace:view', $inst->context);
\mod_meetletacbmarketplace\local\featureaccess::require_feature(
\mod_meetletacbmarketplace\local\featureaccess::EDITRECORDING,
$inst->context,
$inst->record
);
$name = trim($params['name']);
if ($name === '') {
throw new moodle_exception('error', 'meetletacbmarketplace');
}
$service = new service();
$service->login_admin();
$ok = $service->rename_sco((int)$params['recordingscoid'], $name);
$service->logout();
if (!$ok) {
throw new moodle_exception('errorrenamerecording', 'meetletacbmarketplace');
}
$record = $DB->get_record('meetlet_marketplace_recordings', [
'instanceid' => $inst->record->id,
'recordingscoid' => (int)$params['recordingscoid'],
]);
if ($record) {
$record->name = $name;
$record->timemodified = time();
$DB->update_record('meetlet_marketplace_recordings', $record);
}
return [
'success' => true,
'name' => $name,
'message' => get_string('recordingrenamed', 'meetletacbmarketplace'),
];
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'success' => new external_value(PARAM_BOOL, 'OK'),
'name' => new external_value(PARAM_TEXT, 'New name'),
'message' => new external_value(PARAM_TEXT, 'Message'),
]);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?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/>.
/**
* Ajax: search/list existing Adobe meetings for the mod form.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_multiple_structure;
use core_external\external_single_structure;
use core_external\external_value;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\config;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Return a short list of server meetings (latest / search).
*/
class search_server_meetings extends external_api {
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'query' => new external_value(PARAM_TEXT, 'Search query', VALUE_DEFAULT, ''),
'limit' => new external_value(PARAM_INT, 'Max results', VALUE_DEFAULT, 10),
'courseid' => new external_value(PARAM_INT, 'Course id for capability check', VALUE_DEFAULT, 0),
]);
}
public static function execute(string $query = '', int $limit = 10, int $courseid = 0): array {
$params = self::validate_parameters(self::execute_parameters(), [
'query' => $query,
'limit' => $limit,
'courseid' => $courseid,
]);
\mod_meetletacbmarketplace\registration\manager::require_registered();
if (!empty($params['courseid'])) {
$context = \context_course::instance((int)$params['courseid']);
} else {
$context = \context_system::instance();
}
self::validate_context($context);
if (!has_capability('moodle/course:manageactivities', $context)
&& !has_capability('mod/meetletacbmarketplace:addinstance', $context)) {
throw new moodle_exception('nopermissions', 'error', '', 'search meetings');
}
if (!config::is_configured()) {
throw new moodle_exception('notconfigured', 'meetletacbmarketplace');
}
$limit = max(1, min(50, (int)$params['limit']));
$service = new service();
$service->login_admin();
$rows = $service->list_server_meetings((string)$params['query'], $limit);
$service->logout();
$out = [];
foreach ($rows as $m) {
$label = $m->name;
if (!empty($m->urlpath)) {
$label .= ' (' . $m->urlpath . ')';
}
$out[] = [
'scoid' => (int)$m->scoid,
'name' => (string)$m->name,
'label' => $label,
'urlpath' => trim((string)$m->urlpath, '/'),
'starttime' => !empty($m->datebegin) ? (int)strtotime((string)$m->datebegin) : 0,
'endtime' => !empty($m->dateend) ? (int)strtotime((string)$m->dateend) : 0,
];
}
return ['meetings' => $out];
}
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'meetings' => new external_multiple_structure(new external_single_structure([
'scoid' => new external_value(PARAM_INT, 'SCO id'),
'name' => new external_value(PARAM_TEXT, 'Meeting name'),
'label' => new external_value(PARAM_TEXT, 'Select label'),
'urlpath' => new external_value(PARAM_TEXT, 'URL path'),
'starttime' => new external_value(PARAM_INT, 'Start unix time'),
'endtime' => new external_value(PARAM_INT, 'End unix time'),
])),
]);
}
}
+655
View File
@@ -0,0 +1,655 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Plugin config helpers.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
/**
* Read MeetLet / Adobe Connect global settings.
*/
class config {
/** Activity follows site-wide setting. */
public const OVERRIDE_INHERIT = 0;
/** Activity explicitly disables the setting. */
public const OVERRIDE_OFF = 1;
/** Activity explicitly enables the setting. */
public const OVERRIDE_ON = 2;
/**
* API endpoint URL (may already include https://.../api/xml).
*/
public static function get_api_url(): string {
return trim((string)get_config('meetletacbmarketplace', 'host'));
}
/**
* Meeting host domain (no protocol).
*/
public static function get_meet_host(): string {
return trim((string)get_config('meetletacbmarketplace', 'meethost'));
}
public static function get_port(): int {
$port = (int)get_config('meetletacbmarketplace', 'port');
return $port > 0 ? $port : 443;
}
public static function use_https(): bool {
return (bool)get_config('meetletacbmarketplace', 'https');
}
public static function get_admin_login(): string {
return (string)get_config('meetletacbmarketplace', 'admin_login');
}
public static function get_admin_password(): string {
return (string)get_config('meetletacbmarketplace', 'admin_password');
}
public static function get_http_auth_header(): string {
$h = trim((string)get_config('meetletacbmarketplace', 'admin_httpauth'));
return $h !== '' ? $h : 'Generate-Safe-Random-Token';
}
/**
* When true, also look up legacy Connect accounts by email/username
* and realign them to mlu{id}. SSO identity itself is always Moodle id.
*/
public static function use_email_login(): bool {
return (bool)get_config('meetletacbmarketplace', 'email_login');
}
/**
* Site-wide auto MP4 after Adobe Connect class actually ends (room empty).
* Per-activity checkbox is an extra opt-in when this is off.
*/
public static function auto_offline_default(): bool {
if (!self::offline_render_active()) {
return false;
}
return (bool)get_config('meetletacbmarketplace', 'auto_offline');
}
/**
* Minutes between recording-sync / auto-MP4 cron runs (1–60, default 5).
*/
public static function cron_interval_minutes(): int {
$v = (int)get_config('meetletacbmarketplace', 'cron_interval');
if ($v < 1) {
$v = 5;
}
if ($v > 60) {
$v = 60;
}
return $v;
}
/** Send collected jobs with every MeetLet cron run. */
public const DISPATCH_IMMEDIATE = 'immediate';
/** Send collected jobs only during selected site-timezone hours. */
public const DISPATCH_WINDOW = 'window';
/** Default overnight hours (22:00–05:59). */
public const DEFAULT_DISPATCH_HOURS = '22,23,0,1,2,3,4,5';
/**
* How auto-MP4 jobs are handed to the Agent.
*/
public static function agent_dispatch_mode(): string {
$v = trim((string)get_config('meetletacbmarketplace', 'agent_dispatch'));
return $v === self::DISPATCH_WINDOW ? self::DISPATCH_WINDOW : self::DISPATCH_IMMEDIATE;
}
/**
* Selected hours (0–23) in the site timezone. Empty means “none”.
*
* @return int[]
*/
public static function agent_dispatch_hours(): array {
$raw = (string)get_config('meetletacbmarketplace', 'agent_dispatch_hours');
if ($raw === '') {
$raw = self::DEFAULT_DISPATCH_HOURS;
}
return self::parse_dispatch_hours($raw);
}
/**
* @return int[]
*/
public static function parse_dispatch_hours(string $raw): array {
$hours = [];
foreach (preg_split('/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $bit) {
if (!preg_match('/^\d{1,2}$/', $bit)) {
continue;
}
$h = (int)$bit;
if ($h >= 0 && $h <= 23) {
$hours[$h] = $h;
}
}
$hours = array_values($hours);
sort($hours);
return $hours;
}
/**
* Current hour 0–23 in the Moodle server / site timezone.
*/
public static function site_hour_now(): int {
$tz = \core_date::get_server_timezone_object();
return (int)(new \DateTimeImmutable('now', $tz))->format('G');
}
/**
* True when collected jobs may be POSTed to the Agent.
*/
public static function agent_send_allowed_now(): bool {
if (self::agent_dispatch_mode() !== self::DISPATCH_WINDOW) {
return true;
}
$hours = self::agent_dispatch_hours();
if (!$hours) {
return false;
}
return in_array(self::site_hour_now(), $hours, true);
}
/**
* Unix time of the next allowed send hour start, or 0 if sending is allowed now / immediate.
*/
public static function agent_send_next_open(): int {
if (self::agent_send_allowed_now()) {
return 0;
}
$hours = self::agent_dispatch_hours();
if (!$hours) {
return 0;
}
$tz = \core_date::get_server_timezone_object();
$now = new \DateTimeImmutable('now', $tz);
$cur = (int)$now->format('G');
foreach ($hours as $h) {
if ($h > $cur) {
return $now->setTime($h, 0, 0)->getTimestamp();
}
}
return $now->modify('+1 day')->setTime($hours[0], 0, 0)->getTimestamp();
}
/**
* Write the scheduled-task minute field from plugin settings.
*/
public static function apply_cron_interval(): void {
global $DB;
$mins = self::cron_interval_minutes();
$row = $DB->get_record('task_scheduled', ['classname' => '\\mod_meetletacbmarketplace\\task\\sync_recordings']);
if (!$row) {
return;
}
$row->minute = $mins <= 1 ? '*' : ('*/' . $mins);
$row->nextruntime = time() + ($mins * MINSECS);
$DB->update_record('task_scheduled', $row);
}
/**
* Offline MP4 is Premium-only: paid license + mp4_offline entitlement.
* Free / unlicensed sites never activate Agent auto/manual MP4 paths.
*/
public static function offline_render_active(): bool {
return false;
}
/**
* Configured Render Agent / offline listener base URL (may be empty).
*/
public static function agent_url(): string {
return trim((string)get_config('meetletacbmarketplace', 'agent_url'));
}
/**
* Site-wide default meeting UI language (Adobe Connect), empty = server default.
*/
public static function default_meeting_lang(): string {
$v = get_config('meetletacbmarketplace', 'default_meetinglang');
if ($v === false || $v === null) {
return '';
}
return clean_param((string)$v, PARAM_ALPHANUMEXT);
}
/**
* Site-wide default: block participants until host joins.
*/
public static function default_wait_for_host(): bool {
return (bool)get_config('meetletacbmarketplace', 'default_waitforhost');
}
/**
* Language choices for meeting UI (site + activity forms).
*
* @return array<string,string> code => label
*/
public static function meeting_lang_options(): array {
return [
'' => get_string('meetinglang_default', 'meetletacbmarketplace'),
'en' => 'English',
'fa' => 'فارسی',
'ar' => 'العربية',
'de' => 'Deutsch',
'fr' => 'Français',
'es' => 'Español',
'tr' => 'Türkçe',
];
}
/**
* Whether the manual “request offline video” button is allowed (optionally per activity).
*
* @param \stdClass|null $activity
*/
public static function allow_manual_offline(?\stdClass $activity = null): bool {
if (!self::offline_render_active()) {
return false;
}
return self::show_feature('makeoffline', $activity);
}
/**
* Whether a UI feature is enabled globally.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
*/
public static function feature_enabled(string $feature): bool {
// Offline player (pbMode) removed from product — always hidden.
if ($feature === 'offlineplayer') {
return false;
}
if (($feature === 'mp4' || $feature === 'makeoffline') && !self::offline_render_active()) {
return false;
}
$key = 'enable_' . $feature;
$v = get_config('meetletacbmarketplace', $key);
if ($v === false && $feature === 'mp4') {
$legacy = get_config('meetletacbmarketplace', 'allow_manual_offline');
return $legacy === false ? true : (bool)$legacy;
}
// ZIP is opt-in; other features default on when unset.
if ($v === false) {
return $feature !== 'zip';
}
return (bool)$v;
}
/**
* Whether teachers may override a feature per activity.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
*/
public static function feature_allow_override(string $feature): bool {
$key = 'allow_override_' . $feature;
$v = get_config('meetletacbmarketplace', $key);
return $v === false ? true : (bool)$v;
}
/**
* Effective visibility for a feature on an activity instance (site + activity switches only).
* Always combine with featureaccess::allowed() / the role matrix for user actions.
*
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
* @param \stdClass|null $activity
*/
public static function show_feature(string $feature, ?\stdClass $activity = null): bool {
if (!self::feature_enabled($feature)) {
return false;
}
if (!self::feature_allow_override($feature) || $activity === null) {
return true;
}
$fieldmap = [
'zip' => 'showzip',
'offlineplayer' => 'showofflineplayer',
'recordingstab' => 'showrecordingstab',
'contenttab' => 'showcontenttab',
'mp4' => 'showmp4',
'makeoffline' => 'showmakeoffline',
];
$field = $fieldmap[$feature] ?? null;
if ($field === null) {
return true;
}
$siteenabled = true;
if ($field === 'showofflineplayer') {
$siteenabled = false;
}
return self::resolve_activity_toggle($activity, $field, $siteenabled);
}
/**
* Effective wait-for-host value for one activity.
*/
public static function wait_for_host(?\stdClass $activity = null): bool {
$sitevalue = self::default_wait_for_host();
return self::resolve_activity_toggle($activity, 'waitforhost', $sitevalue);
}
/**
* Effective automatic offline-video value for one activity.
*/
public static function auto_offline_enabled(?\stdClass $activity = null): bool {
if (!self::offline_render_active()) {
return false;
}
return self::resolve_activity_toggle($activity, 'autooffline', self::auto_offline_default());
}
/**
* Three-state options for inherit / off / on controls.
*
* @return array<int,string>
*/
public static function override_state_options(): array {
return [
self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbmarketplace'),
self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbmarketplace'),
self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbmarketplace'),
];
}
/**
* Read JSON override map stored on the activity row.
*
* @return array<string,int>
*/
public static function activity_setting_overrides(?\stdClass $activity): array {
if (!$activity || empty($activity->settingoverrides)) {
return [];
}
$decoded = json_decode((string)$activity->settingoverrides, true);
if (!is_array($decoded)) {
return [];
}
$out = [];
foreach ($decoded as $key => $value) {
$state = self::normalize_override_state($value);
if ($state !== self::OVERRIDE_INHERIT) {
$out[(string)$key] = $state;
}
}
return $out;
}
/**
* Effective state for one activity toggle.
*/
public static function activity_override_state(?\stdClass $activity, string $field): int {
$overrides = self::activity_setting_overrides($activity);
if (array_key_exists($field, $overrides)) {
return $overrides[$field];
}
return self::OVERRIDE_INHERIT;
}
/**
* Normalize one override state from DB / form values.
*/
public static function normalize_override_state($value): int {
$value = (int)$value;
if (in_array($value, [self::OVERRIDE_INHERIT, self::OVERRIDE_OFF, self::OVERRIDE_ON], true)) {
return $value;
}
return self::OVERRIDE_INHERIT;
}
/**
* Build normalized JSON for activity setting overrides.
*
* @param array<string,mixed> $states
*/
public static function setting_overrides_json(array $states): string {
$out = [];
foreach ($states as $field => $value) {
$state = self::normalize_override_state($value);
if ($state !== self::OVERRIDE_INHERIT) {
$out[(string)$field] = $state;
}
}
return $out ? json_encode($out, JSON_UNESCAPED_UNICODE) : '';
}
/**
* Resolve a boolean site/activity setting.
*/
protected static function resolve_activity_toggle(?\stdClass $activity, string $field, bool $sitevalue): bool {
if ($activity === null) {
return $sitevalue;
}
$state = self::activity_override_state($activity, $field);
if ($state === self::OVERRIDE_OFF) {
return false;
}
if ($state === self::OVERRIDE_ON) {
return true;
}
if (property_exists($activity, 'settingoverrides') || property_exists($activity, 'participantsmode')) {
return $sitevalue;
}
if (!isset($activity->{$field})) {
return $sitevalue;
}
return !empty($activity->{$field});
}
/**
* ZIP download link: site/activity “show ZIP” AND role matrix.
* Prefer featureaccess::allowed(ZIP) in new code.
*/
public static function user_can_download_zip(\context $context, ?\stdClass $activity = null, ?int $userid = null): bool {
return false;
}
/**
* Whether an email is acceptable for Adobe Connect principal-update.
*/
public static function email_usable_for_adobe(string $email): bool {
$email = trim($email);
if ($email === '' || strpos($email, '@') === false) {
return false;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
// Adobe rejects fake/reserved domains (e.g. test@test.test → invalid/format).
if (preg_match('/\.(test|invalid|localhost|local|example)$/i', $email)) {
return false;
}
if (preg_match('/@(users\.moodle\.local|example\.(com|org|net))$/i', $email)) {
return false;
}
return true;
}
/**
* Synthetic Connect email on the meeting host domain.
*/
public static function synthetic_connect_email(\stdClass $user): string {
$host = self::get_meet_host();
$host = preg_replace('#^https?://#i', '', $host);
$host = explode('/', $host)[0] ?? '';
$host = explode(':', $host)[0] ?? '';
$host = trim((string)$host);
if ($host === '' || !preg_match('/\./', $host)) {
global $CFG;
$www = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
$host = is_string($www) && $www !== '' ? $www : 'meetlet.local';
}
return 'moodleuser' . (int)($user->id ?? 0) . '@' . $host;
}
/**
* Canonical Adobe Connect login for a Moodle user.
*
* Stable across email/username changes: mlu{moodleuserid}.
* Header SSO must send this same string (or the principal's current login
* until a legacy account is realigned).
*/
public static function connect_login(\stdClass $user): string {
$id = (int)($user->id ?? 0);
if ($id > 0) {
return 'mlu' . $id;
}
// Disposable / connection-test users may have no Moodle id.
$username = trim((string)($user->username ?? ''));
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $username);
if ($safe !== '') {
return 'mlu_' . $safe;
}
return 'mlu_tmp' . substr(sha1(($user->email ?? '') . '|' . microtime(true)), 0, 12);
}
/**
* Legacy Connect logins that may already exist for this Moodle user
* (before stable mlu{id} identity). Used only for lookup/migration.
*
* @return string[]
*/
public static function legacy_connect_logins(\stdClass $user): array {
$out = [];
$email = trim((string)($user->email ?? ''));
$username = trim((string)($user->username ?? ''));
foreach ([
self::synthetic_connect_email($user),
'moodleuser' . (int)($user->id ?? 0),
self::email_usable_for_adobe($email) ? $email : '',
($username !== '' && strpos($username, '@') === false) ? $username : '',
self::email_usable_for_adobe($username) ? $username : '',
] as $candidate) {
$candidate = trim((string)$candidate);
if ($candidate === '') {
continue;
}
if (strpos($candidate, '@') !== false && !self::email_usable_for_adobe($candidate)) {
continue;
}
if (!in_array($candidate, $out, true)) {
$out[] = $candidate;
}
}
return $out;
}
/**
* Best-effort email for Adobe principal-update (profile field only; not SSO key).
*/
public static function connect_email(\stdClass $user): string {
$email = trim((string)($user->email ?? ''));
if (self::email_usable_for_adobe($email)) {
return $email;
}
return self::synthetic_connect_email($user);
}
/**
* Build public meeting/content URL.
*/
public static function public_url(string $urlpath, string $session = ''): string {
$protocol = self::use_https() ? 'https://' : 'http://';
$host = self::get_meet_host();
$port = self::get_port();
$portpart = '';
if ($port && $port != 80 && !(self::use_https() && $port == 443)) {
$portpart = ':' . $port;
}
if ($urlpath !== '' && $urlpath[0] !== '/') {
$urlpath = '/' . $urlpath;
}
$url = $protocol . $host . $portpart . $urlpath;
if ($session !== '') {
$url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($session);
}
return $url;
}
/**
* Append query params without duplicating keys.
*
* @param array<string,string|int|bool> $params
*/
public static function append_query(string $url, array $params): string {
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$pair = rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
// Skip if already present.
if (preg_match('/([?&])' . preg_quote((string)$key, '/') . '=/', $url)) {
continue;
}
$url .= (strpos($url, '?') === false ? '?' : '&') . $pair;
}
return $url;
}
/**
* Force HTML client in-browser — skip Flash / desktop-app launcher interstitial
* that otherwise hangs on “preparing the recording/meeting”.
*/
public static function with_html_client(string $url): string {
return self::append_query($url, [
'html-view' => 'true',
'launcher' => 'false',
]);
}
/**
* Recording playback URL (HTML client).
*/
public static function recording_play_url(string $urlpath, string $session = ''): string {
return self::with_html_client(self::public_url($urlpath, $session));
}
/**
* Uploaded meeting content view URL (HTML client).
*/
public static function content_view_url(string $urlpath, string $session = ''): string {
return self::with_html_client(self::public_url($urlpath, $session));
}
/**
* Adobe Connect recording editor URL (pbMode=edit).
*/
public static function recording_editor_url(string $urlpath, string $session = ''): string {
$url = self::public_url($urlpath, $session);
return self::with_html_client(self::append_query($url, [
'pbMode' => 'edit',
'page' => 'm',
]));
}
/**
* Whether settings look complete enough to call the API.
*/
public static function is_configured(): bool {
return self::get_api_url() !== ''
&& self::get_meet_host() !== ''
&& self::get_admin_login() !== ''
&& self::get_admin_password() !== '';
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Stepwise Adobe Connect connection tests for admin Ajax UI.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
use mod_meetletacbmarketplace\api\client;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\usererror;
use Throwable;
/**
* Runs connection checks one step at a time (session-backed).
*/
class conntest_runner {
public const STEPS = ['login', 'folders', 'user', 'sso', 'cleanup'];
/**
* Reset session scratch data for a new run.
*/
public static function reset(): void {
global $SESSION;
$SESSION->meetletacbmarketplace_conntest = [
'service' => null,
'meetings' => null,
'scoid' => null,
'principalid' => null,
'login' => null,
'failed' => false,
];
}
/**
* @return array{label:string,ok:bool,detail:string,done:bool,step:string}
*/
public static function run_step(string $step): array {
global $SESSION;
\mod_meetletacbmarketplace\registration\manager::require_registered();
if (empty($SESSION->meetletacbmarketplace_conntest) || $step === 'login') {
self::reset();
}
$state = &$SESSION->meetletacbmarketplace_conntest;
if (!config::is_configured()) {
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => get_string('notconfigured', 'meetletacbmarketplace'),
'done' => true,
'abort' => true,
];
}
try {
switch ($step) {
case 'login':
return self::step_login($state);
case 'folders':
return self::step_folders($state);
case 'user':
return self::step_user($state);
case 'sso':
return self::step_sso($state);
case 'cleanup':
return self::step_cleanup($state);
default:
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => 'Unknown step',
'done' => true,
'abort' => true,
];
}
} catch (Throwable $e) {
$state['failed'] = true;
return [
'step' => $step,
'label' => get_string('error', 'meetletacbmarketplace'),
'ok' => false,
'detail' => $e->getMessage(),
'done' => false,
'abort' => true,
];
}
}
/**
* Rebuild a fresh service (API client is not serializable in session).
*/
protected static function service(): service {
$service = new service();
$service->login_admin();
return $service;
}
protected static function step_login(array &$state): array {
$service = self::service();
$state['ok_login'] = true;
// Logout immediately; later steps re-login.
$service->logout();
return [
'step' => 'login',
'label' => get_string('testlogin', 'meetletacbmarketplace'),
'ok' => true,
'detail' => '',
'done' => false,
'abort' => false,
];
}
protected static function step_folders(array &$state): array {
$service = self::service();
$content = $service->get_folder_scoid('content');
$archives = $service->get_folder_scoid('forced-archives');
$meetings = $service->get_folder_scoid('meetings');
$state['meetings'] = $meetings;
$ok = ($content && $archives && $meetings);
if (!$ok) {
$state['failed'] = true;
}
$service->logout();
return [
'step' => 'folders',
'label' => get_string('testfolders', 'meetletacbmarketplace'),
'ok' => (bool)$ok,
'detail' => "content={$content} · archives={$archives} · meetings={$meetings}",
'done' => false,
'abort' => !$ok,
];
}
protected static function step_user(array &$state): array {
$service = self::service();
$meetings = (int)($state['meetings'] ?? 0);
if (!$meetings) {
$meetings = (int)$service->get_folder_scoid('meetings');
$state['meetings'] = $meetings;
}
$meeting = (object)[
'name' => 'meetlet_test_' . time(),
'starttime' => time(),
'endtime' => time() + HOURSECS,
'meeturl' => '',
'templatescoid' => 0,
];
$scoid = $service->create_meeting($meeting, $meetings);
$user = (object)[
'id' => random_int(900000000, 999999999),
'firstname' => 'MeetLet',
'lastname' => 'Test',
'username' => 'meetlettest' . random_int(1000, 9999),
'email' => 'meetlet.test.' . random_int(1000, 9999) . '@example.com',
];
$principal = $service->ensure_user($user);
$assigned = $service->assign_permission($principal->principalid, $scoid, service::ROLE_PRESENTER);
$state['scoid'] = $scoid;
$state['principalid'] = $principal->principalid;
$state['login'] = $principal->login;
$service->logout();
if (!$assigned) {
$state['failed'] = true;
}
return [
'step' => 'user',
'label' => get_string('testuser', 'meetletacbmarketplace'),
'ok' => (bool)$assigned,
'detail' => '',
'done' => false,
'abort' => false,
];
}
protected static function step_sso(array &$state): array {
$directok = false;
$directdetail = '';
try {
$sso = new client();
$cookie = $sso->login_external(config::get_admin_login());
$directok = ($cookie !== '');
$directdetail = get_string('testsso_direct_ok', 'meetletacbmarketplace');
} catch (Throwable $e) {
$msg = $e->getMessage();
$directdetail = get_string('testsso_direct_fail', 'meetletacbmarketplace', usererror::label('sso_01') . ': ' . $msg);
}
if ($directok && !empty($state['login'])) {
try {
$sso2 = new client();
$sso2->login_external($state['login']);
$directdetail .= ' · ' . get_string('testsso_userok', 'meetletacbmarketplace');
} catch (Throwable $e) {
$directdetail .= ' · ' . get_string('testsso_userwarn', 'meetletacbmarketplace');
}
}
if (!$directok) {
$state['failed'] = true;
}
return [
'step' => 'sso',
'label' => get_string('testsso', 'meetletacbmarketplace'),
'ok' => $directok,
'detail' => $directdetail,
'done' => false,
'abort' => false,
];
}
protected static function step_cleanup(array &$state): array {
$service = self::service();
$client = $service->get_client();
$ok = true;
$detail = '';
try {
if (!empty($state['scoid'])) {
$service->delete_sco((int)$state['scoid']);
}
if (!empty($state['principalid'])) {
$client->request(['action' => 'principals-delete', 'principal-id' => (int)$state['principalid']]);
}
} catch (Throwable $e) {
$ok = false;
$detail = $e->getMessage();
}
$service->logout();
self::reset();
return [
'step' => 'cleanup',
'label' => get_string('testmeeting', 'meetletacbmarketplace'),
'ok' => $ok,
'detail' => $detail,
'done' => true,
'abort' => false,
];
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Meeting content download helpers (Adobe Connect proxy).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Authenticated Moodle proxy for uploaded meeting content files.
*/
class content {
/**
* Opaque, HMAC-signed, current-user download URL.
*/
public static function download_url(int $userid, int $cmid, int $scoid): \moodle_url {
return new \moodle_url('/mod/meetletacbmarketplace/download_content.php', [
'k' => self::make_download_token($userid, $cmid, $scoid),
]);
}
/**
* Unpack and verify a content download token.
*
* @return array{userid:int,cmid:int,cid:int}
*/
public static function unpack_download_token(string $token): array {
$token = trim($token);
if (!preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$b64 = strtr($token, '-_', '+/');
$pad = strlen($b64) % 4;
if ($pad) {
$b64 .= str_repeat('=', 4 - $pad);
}
$raw = base64_decode($b64, true);
if ($raw === false || strlen($raw) < 44) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$mac = substr($raw, 0, 32);
$payload = substr($raw, 32);
if (strlen($payload) < 32) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
$ids = unpack('Nuid/Ncmid/Ncid/Nexp', substr($payload, 0, 16));
$nonce = substr($payload, 16, 16);
$userid = (int)$ids['uid'];
$cmid = (int)$ids['cmid'];
$cid = (int)$ids['cid'];
$expires = (int)$ids['exp'];
$expected = hash_hmac(
'sha256',
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
if (!hash_equals($expected, $mac)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
}
return ['userid' => $userid, 'cmid' => $cmid, 'cid' => $cid];
}
/**
* Find a content item listed under a meeting folder.
*/
public static function find_in_meeting(service $service, int $meetingscoid, int $scoid): ?stdClass {
foreach ($service->list_meeting_content($meetingscoid) as $item) {
if ((int)($item->scoid ?? 0) === $scoid) {
return $item;
}
}
return null;
}
/**
* Adobe stores uploaded binaries under {url-path}/output/{filename}.
*/
public static function output_file_url(string $urlpath, string $filename): string {
$filename = basename(str_replace('\\', '/', $filename));
if ($filename === '' || strpos($filename, '/') !== false) {
throw new \moodle_exception('filenotfound', 'error');
}
$base = '/' . trim($urlpath, '/');
if ($base === '/') {
throw new \moodle_exception('filenotfound', 'error');
}
return config::public_url($base . '/output/' . rawurlencode($filename));
}
/**
* Stream a meeting content file to the browser via user SSO.
*/
public static function serve_download(instance $inst, int $scoid): void {
global $USER;
$service = new service();
$service->login_admin();
$item = self::find_in_meeting($service, $inst->require_meeting(), $scoid);
$service->logout();
if (!$item || empty($item->name) || empty($item->urlpath)) {
throw new \moodle_exception('filenotfound', 'error');
}
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, (int)$USER->id);
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
$auth = sso::provision_and_login($USER, $scoid, $permission);
$fileurl = self::output_file_url((string)$item->urlpath, (string)$item->name);
$filename = clean_filename((string)$item->name);
if ($filename === '') {
$filename = 'content_' . $scoid;
}
$tmpdir = make_request_directory();
$tmp = $tmpdir . '/content_' . $scoid . '_' . $filename;
self::fetch_to_path($fileurl, $auth['cookie'], $tmp);
\core\session\manager::write_close();
send_temp_file($tmp, $filename);
}
/**
* Download remote content bytes using a BREEZESESSION cookie.
*/
protected static function fetch_to_path(string $url, string $session, string $dest): void {
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
if ($fp === false) {
curl_close($ch);
throw new \moodle_exception('filenotfound', 'error');
}
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 300,
CURLOPT_COOKIE => $session !== '' ? ('BREEZESESSION=' . $session) : '',
]);
$ok = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$size = (int)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
curl_close($ch);
fclose($fp);
if (!$ok || $code < 200 || $code >= 400 || $size <= 0 || !is_file($dest) || filesize($dest) <= 0) {
@unlink($dest);
throw new \moodle_exception('filenotfound', 'error');
}
}
protected static function make_download_token(int $userid, int $cmid, int $scoid): string {
$expires = time() + DAYSECS * 7;
$nonce = random_bytes(16);
$payload = pack('N4', $userid, $cmid, $scoid, $expires) . $nonce;
$mac = hash_hmac(
'sha256',
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $scoid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
return rtrim(strtr(base64_encode($mac . $payload), '+/', '-_'), '=');
}
protected static function download_secret(): string {
return 'mod_meetletacbmarketplace/content/' . get_site_identifier();
}
}
+544
View File
@@ -0,0 +1,544 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Role-based feature access matrix (site + activity).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use context;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Decide which Moodle roles may use which MeetLet features.
*
* =====================================================================
* ACCESS RULE (mandatory for current and future features)
* ---------------------------------------------------------------------
* Every user-facing MeetLet capability MUST go through
* featureaccess::allowed() / require_feature().
*
* allowed() = role×feature matrix (site or activity override)
* AND optional site/activity visibility switch when mapped.
*
* Do NOT grant access via Moodle capabilities alone (except the base
* mod/meetletacbmarketplace:view gate to open the activity page).
* Do NOT special-case admins/hosts outside the matrix.
*
* Adding a new feature:
* 1) Add a constant + entry in features()
* 2) Add accessfeature_* lang strings (fa + en)
* 3) Set defaults in features_for_profile() / profile_for_role()
* 4) If it also has a site “show …” switch, map it in visibility_feature()
* 5) Gate UI and web services with allowed() / require_feature()
* =====================================================================
*/
class featureaccess {
public const JOIN = 'join';
public const RECORDINGS = 'recordings';
public const CONTENT = 'content';
public const EDITRECORDING = 'editrecording';
public const ADOBEEDITOR = 'adobeeditor';
public const ENTERBEFOREHOST = 'enterbeforehost';
/** @var array<int,stdClass>|null */
protected static $rolerecords = null;
/**
* Ordered feature keys for UI.
*
* @return string[]
*/
public static function features(): array {
return [
self::JOIN,
self::ENTERBEFOREHOST,
self::RECORDINGS,
self::CONTENT,
self::EDITRECORDING,
self::ADOBEEDITOR,
];
}
/**
* All defined Moodle roles (shortnames), sorted like the role UI.
*
* @return string[]
*/
public static function role_shortnames(): array {
$out = [];
foreach (self::role_records() as $role) {
if ($role->shortname !== '') {
$out[] = $role->shortname;
}
}
return $out;
}
/**
* @return array<int,stdClass> id => role row (id, shortname, name, archetype, sortorder)
*/
public static function role_records(): array {
global $DB;
if (self::$rolerecords === null) {
self::$rolerecords = $DB->get_records(
'role',
null,
'sortorder ASC, id ASC',
'id, shortname, name, archetype, sortorder'
);
if (!is_array(self::$rolerecords)) {
self::$rolerecords = [];
}
}
return self::$rolerecords;
}
/**
* Reset cached role list (tests / after role changes in same request).
*/
public static function reset_role_cache(): void {
self::$rolerecords = null;
}
/**
* Profile of features enabled for a given access level.
*
* @return string[] feature keys
*/
public static function features_for_profile(string $profile): array {
switch ($profile) {
case 'manager':
case 'editingteacher':
return self::features();
case 'teacher':
return [
self::JOIN, self::ENTERBEFOREHOST,
self::RECORDINGS, self::CONTENT,
];
case 'student':
return [self::JOIN, self::RECORDINGS, self::CONTENT];
default:
return [];
}
}
/**
* Map a Moodle role to a sensible MeetLet access profile.
*/
public static function profile_for_role(stdClass $role): string {
$arch = strtolower(trim((string)($role->archetype ?? '')));
$short = strtolower(trim((string)($role->shortname ?? '')));
$name = strtolower(trim((string)($role->name ?? '')));
$hay = $short . ' ' . $name;
if (in_array($arch, ['manager', 'coursecreator'], true)
|| in_array($short, ['manager', 'coursecreator'], true)) {
return 'manager';
}
if ($arch === 'editingteacher' || $short === 'editingteacher') {
return 'editingteacher';
}
if ($arch === 'teacher' || $short === 'teacher') {
return 'teacher';
}
if ($arch === 'student' || $short === 'student') {
return 'student';
}
if (in_array($arch, ['guest', 'user', 'frontpage'], true)
|| in_array($short, ['guest', 'user', 'frontpage'], true)) {
return 'none';
}
// Custom / plugin roles — infer from shortname & name.
if (preg_match('/\b(host|manager|admin|owner)\b/u', $hay)
|| str_contains($hay, 'adobeconnecthost')) {
return 'editingteacher';
}
if (preg_match('/\b(presenter|editingteacher|instructor|tutor|moderator)\b/u', $hay)
|| str_contains($hay, 'adobeconnectpresenter')) {
return 'teacher';
}
if (preg_match('/\b(participant|student|learner|attendee)\b/u', $hay)
|| str_contains($hay, 'adobeconnectparticipant')) {
return 'student';
}
if (preg_match('/\b(teacher|teacherrole|ostad)\b/u', $hay)) {
return 'teacher';
}
return 'none';
}
/**
* Built-in defaults by role archetype + name heuristics for custom roles.
*
* @return array<string,array<string,int>> role => feature => 0|1
*/
public static function builtin_defaults(): array {
$alloff = array_fill_keys(self::features(), 0);
$matrix = [];
foreach (self::role_records() as $role) {
$row = $alloff;
foreach (self::features_for_profile(self::profile_for_role($role)) as $f) {
$row[$f] = 1;
}
$matrix[$role->shortname] = $row;
}
return $matrix;
}
/**
* Keep explicit non-empty stored rows; fill blank/missing roles from builtins.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize_fill_blank_roles($raw): array {
$defaults = self::builtin_defaults();
if (!is_array($raw)) {
return $defaults;
}
$out = $defaults;
foreach (self::role_shortnames() as $role) {
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
$enabled = 0;
foreach (self::features() as $feature) {
if (!empty($raw[$role][$feature])) {
$enabled++;
}
}
if ($enabled === 0) {
// Entirely empty row → keep sensible builtin defaults for that role.
continue;
}
foreach (self::features() as $feature) {
$out[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
return $out;
}
/**
* Whether the activity form may show a per-activity access matrix.
*/
public static function activity_override_enabled(): bool {
$raw = get_config('meetletacbmarketplace', 'activity_featureaccess');
if ($raw === false || $raw === null || $raw === '') {
return true;
}
return (bool)$raw;
}
/**
* Normalize a stored matrix; missing keys fall back to builtin defaults.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize($raw): array {
$base = self::builtin_defaults();
if (!is_array($raw)) {
return $base;
}
foreach (self::role_shortnames() as $role) {
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
foreach (self::features() as $feature) {
if (array_key_exists($feature, $raw[$role])) {
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
}
return $base;
}
/**
* Build matrix from checkbox POST (missing checkbox = denied).
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function normalize_from_form($raw): array {
$base = [];
foreach (self::role_shortnames() as $role) {
$base[$role] = array_fill_keys(self::features(), 0);
if (empty($raw[$role]) || !is_array($raw[$role])) {
continue;
}
foreach (self::features() as $feature) {
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
}
}
return $base;
}
/**
* Site-wide matrix from plugin config.
*
* @return array<string,array<string,int>>
*/
public static function site_matrix(): array {
$raw = get_config('meetletacbmarketplace', 'featureaccess');
if ($raw === false || $raw === null || $raw === '') {
return self::builtin_defaults();
}
$decoded = json_decode((string)$raw, true);
return self::normalize($decoded);
}
/**
* Effective matrix for an activity (override or site).
*
* @param stdClass|null $activity
* @return array<string,array<string,int>>
*/
public static function matrix_for_activity(?stdClass $activity): array {
if ($activity && !empty($activity->featureaccess)) {
$decoded = json_decode((string)$activity->featureaccess, true);
if (is_array($decoded) && !empty($decoded)) {
return self::normalize($decoded);
}
}
return self::site_matrix();
}
/**
* Whether current/user may use a feature in this module context.
* Decision is based solely on the role × feature matrix (no visibility switch).
*/
public static function user_can(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
global $USER;
$userid = $userid ?? (int)$USER->id;
// Must at least be able to view the activity page.
$viewcap = self::view_capability_for_context($context);
if ($viewcap && !has_capability($viewcap, $context, $userid)) {
return false;
}
$matrix = self::matrix_for_activity($activity);
$roles = get_user_roles($context, $userid, true);
foreach ($roles as $role) {
$short = $role->shortname ?? '';
if ($short === '' || !isset($matrix[$short])) {
continue;
}
if (!empty($matrix[$short][$feature])) {
return true;
}
}
// No matching matrix role grants the feature.
return false;
}
/**
* Optional site/activity visibility key for a matrix feature.
* Null = matrix-only (no enable_* / show_* switch).
*/
public static function visibility_feature(string $feature): ?string {
$map = [
self::RECORDINGS => 'recordingstab',
self::CONTENT => 'contenttab',
];
return $map[$feature] ?? null;
}
/**
* Single entry point: matrix grant AND visibility switch (when mapped).
*
* Use this for tabs, buttons, links, and web-service actions.
*/
public static function allowed(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
if (!\mod_meetletacbmarketplace\registration\manager::is_registered()) {
return false;
}
$vis = self::visibility_feature($feature);
if ($vis !== null && !config::show_feature($vis, $activity)) {
return false;
}
return self::user_can($feature, $context, $activity, $userid);
}
/**
* Require allowed() or throw.
*/
public static function require_feature(string $feature, context $context, ?stdClass $activity = null): void {
\mod_meetletacbmarketplace\registration\manager::require_registered();
if (self::allowed($feature, $context, $activity)) {
return;
}
$vis = self::visibility_feature($feature);
if ($vis !== null && !config::show_feature($vis, $activity)) {
throw new \moodle_exception('featuredisabled', 'meetletacbmarketplace');
}
throw new \moodle_exception('nopermissions', 'error', '', self::feature_label($feature));
}
public static function feature_label(string $feature): string {
$key = 'accessfeature_' . $feature;
if (get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
return get_string($key, 'meetletacbmarketplace');
}
return $feature;
}
/**
* @return array<string,string> feature => label
*/
public static function feature_labels(): array {
$out = [];
foreach (self::features() as $f) {
$out[$f] = self::feature_label($f);
}
return $out;
}
/**
* @return array<string,string> shortname => local name
*/
public static function role_labels(): array {
$out = [];
foreach (self::role_records() as $role) {
$out[$role->shortname] = role_get_name($role);
}
return $out;
}
protected static function view_capability_for_context(context $context): string {
return 'mod/meetletacbmarketplace:view';
}
/**
* Render HTML matrix table (admin or mod form).
*
* @param array $matrix
* @param string $inputname e.g. featureaccess[student][join] or admin name
* @param bool $disabled
*/
public static function render_matrix_html(array $matrix, string $inputname, bool $disabled = false): string {
$matrix = self::normalize($matrix);
$roles = self::role_labels();
$features = self::feature_labels();
$strtogglerow = get_string('accessmatrix_toggle_row', 'meetletacbmarketplace');
$strtogglecol = get_string('accessmatrix_toggle_col', 'meetletacbmarketplace');
$strall = get_string('accessmatrix_all', 'meetletacbmarketplace');
$html = \html_writer::start_div('meetlet-access-matrix', ['data-meetlet-access-matrix' => '1']);
$html .= \html_writer::start_tag('table', ['class' => 'generaltable meetlet-access-table']);
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
foreach ($features as $fkey => $flabel) {
$toggle = \html_writer::tag('button', s($strall), [
'type' => 'button',
'class' => 'meetlet-access-toggle meetlet-access-toggle--col',
'data-access-toggle' => 'col',
'data-feature' => $fkey,
'title' => $strtogglecol,
'aria-label' => $strtogglecol . ': ' . $flabel,
]);
$head .= \html_writer::tag('th',
\html_writer::div(s($flabel), 'meetlet-access-col__label') . $toggle,
['class' => 'meetlet-access-col', 'title' => $flabel]
);
}
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
$body = '';
foreach ($roles as $rshort => $rlabel) {
$rowtoggle = \html_writer::tag('button', s($strall), [
'type' => 'button',
'class' => 'meetlet-access-toggle meetlet-access-toggle--row',
'data-access-toggle' => 'row',
'data-role' => $rshort,
'title' => $strtogglerow,
'aria-label' => $strtogglerow . ': ' . $rlabel,
]);
$cells = \html_writer::tag('th',
\html_writer::span(s($rlabel), 'meetlet-access-role') . $rowtoggle,
['scope' => 'row', 'class' => 'meetlet-access-rowhead']
);
foreach (array_keys($features) as $fkey) {
$checked = !empty($matrix[$rshort][$fkey]);
$attrs = [
'type' => 'checkbox',
'name' => $inputname . '[' . $rshort . '][' . $fkey . ']',
'value' => '1',
'class' => 'meetlet-access-check',
'data-role' => $rshort,
'data-feature' => $fkey,
];
if ($checked) {
$attrs['checked'] = 'checked';
}
if ($disabled) {
$attrs['disabled'] = 'disabled';
}
$cells .= \html_writer::tag('td', \html_writer::empty_tag('input', $attrs), [
'class' => 'meetlet-access-cell',
]);
}
$body .= \html_writer::tag('tr', $cells, ['data-role-row' => $rshort]);
}
$html .= \html_writer::tag('tbody', $body);
$html .= \html_writer::end_tag('table');
$html .= \html_writer::end_div();
return $html;
}
/**
* Copy $from grants onto $to when $to is missing from a stored row.
*
* @param mixed $raw
* @return array<string,array<string,int>>
*/
public static function copy_missing_feature_from($raw, string $from, string $to): array {
$stored = is_array($raw) ? $raw : [];
$out = self::normalize($stored);
foreach (self::role_shortnames() as $role) {
if (empty($stored[$role]) || !is_array($stored[$role])) {
continue;
}
if (array_key_exists($to, $stored[$role])) {
continue;
}
if (array_key_exists($from, $stored[$role])) {
$out[$role][$to] = !empty($stored[$role][$from]) ? 1 : 0;
}
}
return $out;
}
/**
* Parse POSTed matrix checkbox array into normalized JSON.
*
* @param mixed $posted
*/
public static function matrix_from_post($posted): string {
return json_encode(self::normalize_from_form(is_array($posted) ? $posted : []), JSON_UNESCAPED_UNICODE);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Local inline SVG icons (air-gapped — no CDN).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Shared MeetLet icon SVG snippets.
*/
class icons {
/**
* Return inline SVG markup for a named icon.
*/
public static function svg(string $name): string {
$svgs = [
'play' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>',
'zip' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm1 7V3.5L19.5 9H15zM9 4h2v2H9V4zm0 3h2v2H9V7zm0 3h2v2H9v-2zm1 3h1v4H8v-1h2v-3z"/></svg>',
'offline' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7V3zm1 4v5l4 2-.8 1.6L11 12.2V7h2z"/></svg>',
'mp4' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 4v9.2l3.2-3.2 1.4 1.4L12 16.4 7.4 11.8l1.4-1.4L11 13.2V4h1z"/><rect x="5" y="18" width="14" height="2" rx="1" fill="currentColor"/></svg>',
'import' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'request' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6.25C4 4.45 5.45 3 7.25 3h6.5C15.55 3 17 4.45 17 6.25V8.1l4.15-2.3A1.25 1.25 0 0 1 23 6.9v10.2c0 .95-1.03 1.55-1.85 1.1L17 15.9v1.85C17 19.55 15.55 21 13.75 21h-6.5C5.45 21 4 19.55 4 17.75V6.25z"/></svg>',
'edit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.34a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z"/></svg>',
'adobeedit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4h7v2H6v12h12v-5h2v7H4V4zm9 0h7v7h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H13V4z"/></svg>',
'users' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>',
'csv' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 13h2.5l1.25 3.5L13 13h2v6h-1.5v-3.4L12.2 19h-.9L10 15.6V19H8.5v-6H8zm7.5 0H18c.8 0 1.5.7 1.5 1.5v3c0 .8-.7 1.5-1.5 1.5h-2.5v-6zm1.5 4.5h1v-3h-1v3z"/></svg>',
'refresh' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'update' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
'download' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
'details' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2 5 5h-4a1 1 0 0 1-1-1V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z"/></svg>',
'attendance' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-8 14-4-4 1.41-1.41L11 14.17l6.59-6.59L19 9l-8 8z"/></svg>',
'recordings' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>',
'content' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>',
'sessions' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V8h14v11zM7 10h5v5H7v-5z"/></svg>',
];
return $svgs[$name] ?? $svgs['play'];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Activity instance helpers.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use cm_info;
use context_module;
use moodle_exception;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Load and resolve MeetLet instances.
*/
class instance {
/** @var stdClass */
public $record;
/** @var stdClass */
public $course;
/** @var cm_info|stdClass */
public $cm;
/** @var context_module */
public $context;
/** @var int */
public $groupid = 0;
/** @var stdClass|null */
public $meetingmap;
public static function get_from_cmid(int $cmid, int $groupid = 0): self {
list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'meetletacbmarketplace');
return self::from_cm($cm, $course, $groupid);
}
public static function get_from_instanceid(int $instanceid, int $groupid = 0): self {
list($course, $cm) = get_course_and_cm_from_instance($instanceid, 'meetletacbmarketplace');
return self::from_cm($cm, $course, $groupid);
}
protected static function from_cm($cm, $course, int $groupid): self {
global $DB;
$inst = new self();
$inst->cm = $cm;
$inst->course = $course;
$inst->context = context_module::instance($cm->id);
$inst->record = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance], '*', MUST_EXIST);
$inst->groupid = $groupid;
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
'instanceid' => $inst->record->id,
'groupid' => $groupid,
]);
if (!$inst->meetingmap && $groupid !== 0) {
// Fallback to no-group mapping.
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
'instanceid' => $inst->record->id,
'groupid' => 0,
]);
}
return $inst;
}
public function meetingscoid(): int {
return (int)($this->meetingmap->meetingscoid ?? 0);
}
public function require_meeting(): int {
$scoid = $this->meetingscoid();
if (!$scoid) {
throw new moodle_exception('nomeeting', 'meetletacbmarketplace');
}
return $scoid;
}
public function auto_offline_enabled(): bool {
return config::auto_offline_enabled($this->record);
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Meeting lifecycle status (schedule + live presence + wait-for-host).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
defined('MOODLE_INTERNAL') || die();
/**
* Resolve whether the activity meeting can accept join requests.
*/
class meetingstatus {
public const OPEN = 'open';
public const LIVE = 'live';
public const SCHEDULED = 'scheduled';
public const ENDED = 'ended';
public const WAITINGHOST = 'waitinghost';
/** Local-only placeholder until Adobe is polled from the browser. */
public const CHECKING = 'checking';
/**
* Fast local status for first paint — no Adobe API calls.
*
* Join stays blocked until the client polls {@see resolve()} unless the
* schedule alone already forbids join (and the user cannot bypass).
*
* @return array{
* status:string,
* statuslabel:string,
* joinable:bool,
* message:string,
* errorcode:string,
* starttime:int,
* endtime:int,
* waitforhost:bool
* }
*/
public static function resolve_local(instance $inst, ?int $userid = null): array {
global $USER;
$userid = $userid ?? (int)$USER->id;
$rec = $inst->record;
$now = time();
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
$waitforhost = config::wait_for_host($rec);
$canbypass = featureaccess::allowed(
featureaccess::ENTERBEFOREHOST,
$inst->context,
$rec,
$userid
);
$status = self::CHECKING;
$errorcode = '';
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
// Never allow join before Adobe confirms room state.
$joinable = false;
if ($endts > 0 && $endts < $now) {
$status = self::ENDED;
$message = get_string('meetingended', 'meetletacbmarketplace');
$errorcode = 'meetingended';
$joinable = $canbypass;
} else if ($startts > $now) {
$status = self::SCHEDULED;
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
$errorcode = 'meetingnotstarted';
$joinable = $canbypass;
} else if ($waitforhost && !$canbypass) {
// Provisional: assume waiting until Adobe poll confirms a live/open room.
$status = self::WAITINGHOST;
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
$errorcode = 'waitingforhost';
$joinable = false;
} else if (!$canbypass) {
// Until Adobe confirms the room is live, keep Join disabled for participants.
$status = self::CHECKING;
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
$joinable = false;
}
return [
'status' => $status,
'statuslabel' => self::label($status),
'joinable' => $joinable,
'message' => $message,
'errorcode' => $errorcode,
'starttime' => $startts,
'endtime' => $endts,
'waitforhost' => $waitforhost,
];
}
/**
* Full status including Adobe live presence and host-hold.
*
* Priority:
* 1) Live room presence on Adobe Connect
* 2) Moodle schedule (start / end)
* 3) Wait-for-host (Moodle flag and/or Adobe ACL)
*
* @return array{
* status:string,
* statuslabel:string,
* joinable:bool,
* message:string,
* errorcode:string,
* starttime:int,
* endtime:int,
* waitforhost:bool
* }
*/
public static function resolve(instance $inst, ?int $userid = null, bool $checkhost = true): array {
global $USER;
$userid = $userid ?? (int)$USER->id;
$rec = $inst->record;
$now = time();
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
$service = null;
$scoid = 0;
$islive = false;
$adobehold = false;
if (config::is_configured()) {
$scoid = $inst->meetingscoid();
if ($scoid) {
try {
$service = new service();
$service->login_admin();
$info = $service->meeting_info($scoid);
if ($info && !empty($info->starttime) && $startts <= 0) {
$adobestart = strtotime((string)$info->starttime);
if ($adobestart) {
$startts = $adobestart;
}
}
$islive = $service->meeting_is_live($scoid);
if ($checkhost) {
$adobehold = $service->meeting_requires_host_first($scoid);
}
} catch (\Throwable $e) {
$service = null;
$islive = false;
$adobehold = false;
}
}
}
$waitforhost = config::wait_for_host($rec) || $adobehold;
$status = self::OPEN;
$errorcode = '';
$message = '';
$joinable = true;
$canbypass = featureaccess::allowed(
featureaccess::ENTERBEFOREHOST,
$inst->context,
$rec,
$userid
);
if ($islive) {
$status = self::LIVE;
$joinable = true;
} else if ($endts > 0 && $endts < $now) {
$status = self::ENDED;
if (!$canbypass) {
$joinable = false;
$errorcode = 'meetingended';
$message = get_string('meetingended', 'meetletacbmarketplace');
}
} else if ($startts > $now) {
$status = self::SCHEDULED;
if (!$canbypass) {
$joinable = false;
$errorcode = 'meetingnotstarted';
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
}
} else if (!$islive && $checkhost && !$canbypass) {
// Nobody is in the Adobe room yet. Participants must not get a join URL —
// Connect would only show "Meeting Host Expected". Moderators with
// enterbeforehost may still join to start the session.
$status = self::WAITINGHOST;
$joinable = false;
$errorcode = 'waitingforhost';
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
} else if (!$islive && $canbypass && ($waitforhost || $adobehold)) {
// Host/moderator may enter; still show waiting-for-host for clarity.
$status = self::WAITINGHOST;
$joinable = true;
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
}
if ($service instanceof service) {
try {
$service->logout();
} catch (\Throwable $e) {
// Ignore.
}
}
if ($inst->meetingmap) {
try {
if (roomsession::note_presence($inst, $islive)) {
recordings::sync($inst);
}
} catch (\Throwable $e) {
debugging('MeetLet room session observe failed: ' . $e->getMessage(), DEBUG_DEVELOPER);
}
}
return [
'status' => $status,
'statuslabel' => self::label($status),
'joinable' => $joinable,
'message' => $message !== '' ? $message : get_string('statushelp', 'meetletacbmarketplace'),
'errorcode' => $errorcode,
'starttime' => $startts,
'endtime' => $endts,
'waitforhost' => $waitforhost,
];
}
public static function label(string $status): string {
switch ($status) {
case self::LIVE:
return get_string('statuslive', 'meetletacbmarketplace');
case self::ENDED:
return get_string('statusended', 'meetletacbmarketplace');
case self::SCHEDULED:
return get_string('statusscheduled', 'meetletacbmarketplace');
case self::WAITINGHOST:
return get_string('statuswaitinghost', 'meetletacbmarketplace');
case self::CHECKING:
return get_string('statuschecking', 'meetletacbmarketplace');
default:
return get_string('statusopen', 'meetletacbmarketplace');
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Admin HTML for the in-plugin privacy policy tab (Marketplace edition).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Renders the Privacy admin settings tab.
*/
class privacy_policy {
/**
* Build privacy policy HTML (safe for admin settings heading).
*/
public static function render(): string {
$plugin = 'meetletacbmarketplace';
$sections = [
self::section('scope', 'privacy_tab_scope_title', 'privacy_tab_scope_body'),
self::section('registration', 'privacy_tab_registration_title', 'privacy_tab_registration_body'),
self::section('purpose', 'privacy_tab_purpose_title', 'privacy_tab_purpose_body'),
self::section('service', 'privacy_tab_service_title', 'privacy_tab_service_body'),
self::section('adobe', 'privacy_tab_adobe_title', 'privacy_tab_adobe_body'),
self::section('local', 'privacy_tab_local_title', 'privacy_tab_local_body'),
self::section('retention', 'privacy_tab_retention_title', 'privacy_tab_retention_body'),
self::section('sharing', 'privacy_tab_sharing_title', 'privacy_tab_sharing_body'),
self::section('control', 'privacy_tab_control_title', 'privacy_tab_control_body'),
self::section('contact', 'privacy_tab_contact_title', 'privacy_tab_contact_body'),
];
return \html_writer::div(
\html_writer::tag('h3', get_string('privacy_tab_heading', $plugin), ['class' => 'meetlet-guide__heading']) .
\html_writer::tag('p', get_string('privacy_tab_intro', $plugin), ['class' => 'meetlet-guide__intro']) .
\html_writer::tag('p', get_string('privacy_tab_effective', $plugin), [
'class' => 'meetlet-guide__intro text-muted',
]) .
implode('', $sections),
'meetlet-guide meetlet-settings-card meetlet-privacy'
);
}
/**
* @param string $id Section id for anchoring.
* @param string $titlekey Lang string key for section title.
* @param string $bodykey Lang string key or pre-formatted body HTML.
*/
protected static function section(string $id, string $titlekey, string $bodykey): string {
$plugin = 'meetletacbmarketplace';
$title = get_string($titlekey, $plugin);
$body = strpos($bodykey, '<') !== false
? $bodykey
: get_string($bodykey, $plugin);
return \html_writer::div(
\html_writer::tag('h4', $title) .
\html_writer::div($body, 'meetlet-guide__body'),
'meetlet-guide__section',
['id' => 'meetlet-privacy-' . $id]
);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Recording list sync (community edition — no offline MP4 / agent).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
class recordings {
public static function for_tab(instance $instance): array {
self::sync($instance);
return self::load_local($instance);
}
public static function sync(instance $instance, ?service $service = null): array {
global $DB;
$ownsclient = false;
if ($service === null) {
$service = new service();
$service->login_admin();
$ownsclient = true;
}
$scoid = $instance->meetingscoid();
if (!$scoid) {
if ($ownsclient) {
$service->logout();
}
return [];
}
$remote = $service->list_recordings($scoid);
$now = time();
$out = [];
foreach ($remote as $rec) {
$record = $DB->get_record('meetlet_marketplace_recordings', [
'instanceid' => $instance->record->id,
'recordingscoid' => $rec->scoid,
]);
$zipurl = service::zip_url($rec->urlpath);
if (!$record) {
$record = new stdClass();
$record->instanceid = $instance->record->id;
$record->meetingscoid = $scoid;
$record->recordingscoid = $rec->scoid;
$record->name = $rec->name;
$record->urlpath = $rec->urlpath;
$record->zipurl = $zipurl;
$record->duration = $rec->duration;
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
$record->timemodified = $now;
$record->id = $DB->insert_record('meetlet_marketplace_recordings', $record);
} else {
$record->name = $rec->name;
$record->urlpath = $rec->urlpath;
$record->zipurl = $zipurl;
$record->duration = $rec->duration;
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
$record->timemodified = $now;
$DB->update_record('meetlet_marketplace_recordings', $record);
}
$record->playurl = config::public_url($rec->urlpath);
$record->editurl = config::recording_editor_url($rec->urlpath);
$out[] = $record;
}
if ($ownsclient) {
$service->logout();
}
return $out;
}
public static function load_local(instance $instance): array {
global $DB;
$rows = $DB->get_records(
'meetlet_marketplace_recordings',
['instanceid' => $instance->record->id],
'timestart DESC, id DESC'
);
foreach ($rows as $record) {
$record->playurl = config::public_url((string)$record->urlpath);
$record->editurl = config::recording_editor_url((string)$record->urlpath);
$record->mp4ready = false;
$record->mp4url = '';
}
return $rows;
}
public static function jobstatus_label(string $status, bool $mp4ready = false): string {
return $status !== '' ? $status : '—';
}
public static function job_is_busy(string $status): bool {
return false;
}
}
+486
View File
@@ -0,0 +1,486 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* BBB-style participant role mapping for Adobe Connect.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use context;
use mod_meetletacbmarketplace\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Participant list helpers (BBB pattern: all/role/user → Adobe role).
*/
class roles {
public const ROLE_HOST = 'host';
public const ROLE_PRESENTER = 'presenter';
public const ROLE_PARTICIPANT = 'participant';
public const MODE_INHERIT = 0;
public const MODE_OVERRIDE = 1;
/**
* Map form role id to Adobe permission-id.
*/
public static function to_adobe_permission(string $role): string {
switch ($role) {
case self::ROLE_HOST:
return service::ROLE_HOST;
case self::ROLE_PRESENTER:
return service::ROLE_PRESENTER;
default:
return service::ROLE_PARTICIPANT;
}
}
public static function role_rank(string $role): int {
switch ($role) {
case self::ROLE_HOST:
return 3;
case self::ROLE_PRESENTER:
return 2;
case self::ROLE_PARTICIPANT:
return 1;
default:
return 0;
}
}
/**
* Built-in site defaults (no owner user rule).
*/
public static function builtin_site_defaults(): array {
global $DB;
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]];
foreach (['manager', 'coursecreator', 'editingteacher'] as $shortname) {
if ($role = $DB->get_record('role', ['shortname' => $shortname], 'id')) {
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => self::ROLE_HOST,
];
}
}
if ($role = $DB->get_record('role', ['shortname' => 'teacher'], 'id')) {
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => self::ROLE_PRESENTER,
];
}
return $list;
}
/**
* Site-wide default participant rules from plugin config (or built-in).
*/
public static function get_site_default_rules(): array {
$raw = get_config('meetletacbmarketplace', 'default_participants');
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded) && !empty($decoded)) {
return self::normalize_rules($decoded);
}
}
return self::builtin_site_defaults();
}
/**
* Normalize and sanitize a participant rule list.
*/
public static function normalize_rules(array $rules): array {
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
$out = [];
$hasall = false;
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
$type = (string)($rule['selectiontype'] ?? '');
$id = (string)($rule['selectionid'] ?? '');
$role = (string)($rule['role'] ?? self::ROLE_PARTICIPANT);
if (!in_array($role, $allowed, true)) {
$role = self::ROLE_PARTICIPANT;
}
if ($type === 'all') {
$hasall = true;
$out[] = ['selectiontype' => 'all', 'selectionid' => 'all', 'role' => $role];
continue;
}
if ($type === 'role' && ctype_digit($id) && (int)$id > 0) {
$out[] = ['selectiontype' => 'role', 'selectionid' => $id, 'role' => $role];
continue;
}
if ($type === 'user' && ctype_digit($id) && (int)$id > 0) {
$out[] = ['selectiontype' => 'user', 'selectionid' => $id, 'role' => $role];
}
}
if (!$hasall) {
array_unshift($out, [
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]);
}
return $out;
}
/**
* Default participant rules for a new / empty activity.
*/
public static function get_participant_list_default(context $context, ?int $ownerid = null): array {
$list = self::get_site_default_rules();
// Activity owner as host when enrolled (activity-only convenience rule).
if (!empty($ownerid) && is_enrolled($context, $ownerid)) {
$list[] = [
'selectiontype' => 'user',
'selectionid' => (string)$ownerid,
'role' => self::ROLE_HOST,
];
}
return self::normalize_rules($list);
}
/**
* Whether one activity should inherit the site role mapping.
*/
public static function activity_inherits_defaults(?stdClass $instance): bool {
if ($instance === null) {
return true;
}
if (!property_exists($instance, 'participantsmode')) {
return false;
}
return (int)$instance->participantsmode !== self::MODE_OVERRIDE;
}
/**
* Convert rule list → admin matrix keyed by _all / role shortname.
*
* @return array<string,string>
*/
public static function matrix_from_rules(array $rules): array {
$matrix = ['_all' => self::ROLE_PARTICIPANT];
foreach (featureaccess::role_records() as $role) {
if ($role->shortname !== '') {
$matrix[$role->shortname] = self::ROLE_PARTICIPANT;
}
}
$byid = featureaccess::role_records();
foreach (self::normalize_rules($rules) as $rule) {
$adobe = $rule['role'] ?? self::ROLE_PARTICIPANT;
if (($rule['selectiontype'] ?? '') === 'all') {
$matrix['_all'] = $adobe;
} else if (($rule['selectiontype'] ?? '') === 'role') {
$rid = (int)($rule['selectionid'] ?? 0);
if (isset($byid[$rid]) && $byid[$rid]->shortname !== '') {
$matrix[$byid[$rid]->shortname] = $adobe;
}
}
}
return $matrix;
}
/**
* Convert posted admin matrix → JSON rule list for config storage.
*
* @param array $data role shortname / _all => adobe role
*/
public static function rules_json_from_matrix_post(array $data): string {
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
$allrole = (string)($data['_all'] ?? self::ROLE_PARTICIPANT);
if (!in_array($allrole, $allowed, true)) {
$allrole = self::ROLE_PARTICIPANT;
}
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => $allrole,
]];
foreach (featureaccess::role_records() as $role) {
if ($role->shortname === '') {
continue;
}
$adobe = (string)($data[$role->shortname] ?? self::ROLE_PARTICIPANT);
if (!in_array($adobe, $allowed, true)) {
$adobe = self::ROLE_PARTICIPANT;
}
// Skip redundant participant rows that match the “everyone” default.
if ($adobe === $allrole && $adobe === self::ROLE_PARTICIPANT) {
continue;
}
$list[] = [
'selectiontype' => 'role',
'selectionid' => (string)$role->id,
'role' => $adobe,
];
}
return json_encode(self::normalize_rules($list), JSON_UNESCAPED_UNICODE);
}
/**
* Adobe Connect role options for selects/radios.
*
* @return array<string,string> value => label
*/
public static function adobe_role_labels(): array {
return [
self::ROLE_HOST => get_string('role_host', 'meetletacbmarketplace'),
self::ROLE_PRESENTER => get_string('role_presenter', 'meetletacbmarketplace'),
self::ROLE_PARTICIPANT => get_string('role_participant', 'meetletacbmarketplace'),
];
}
/**
* Render site admin Moodle-role → Adobe-role mapping table.
*/
public static function render_site_mapping_html(array $rules, string $inputname): string {
$matrix = self::matrix_from_rules($rules);
$adobelabels = self::adobe_role_labels();
$rolelabels = featureaccess::role_labels();
$html = \html_writer::start_div('meetlet-rolemapping', ['data-meetlet-rolemapping' => '1']);
$html .= \html_writer::start_tag('table', [
'class' => 'generaltable meetlet-access-table meetlet-rolemapping-table',
]);
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
$head .= \html_writer::tag('th', get_string('adoberole', 'meetletacbmarketplace'));
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
$body = '';
// Everyone row first.
$body .= self::mapping_row_html(
get_string('participant_type_all', 'meetletacbmarketplace'),
$inputname . '[_all]',
$matrix['_all'] ?? self::ROLE_PARTICIPANT,
$adobelabels,
'_all'
);
foreach ($rolelabels as $shortname => $label) {
$body .= self::mapping_row_html(
$label,
$inputname . '[' . $shortname . ']',
$matrix[$shortname] ?? self::ROLE_PARTICIPANT,
$adobelabels,
$shortname
);
}
$html .= \html_writer::tag('tbody', $body);
$html .= \html_writer::end_tag('table');
$html .= \html_writer::end_div();
return $html;
}
/**
* One mapping table row with Adobe role radios.
*
* @param array<string,string> $adobelabels
*/
protected static function mapping_row_html(
string $moodlelabel,
string $inputname,
string $selected,
array $adobelabels,
string $rowkey
): string {
$cells = \html_writer::tag('th', s($moodlelabel), [
'scope' => 'row',
'class' => 'meetlet-access-rowhead',
]);
$chips = '';
foreach ($adobelabels as $value => $label) {
$id = 'meetlet-map-' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $rowkey . '-' . $value);
$attrs = [
'type' => 'radio',
'name' => $inputname,
'id' => $id,
'value' => $value,
'class' => 'meetlet-rolechip__input',
];
if ($selected === $value) {
$attrs['checked'] = 'checked';
}
$chips .= \html_writer::tag('label',
\html_writer::empty_tag('input', $attrs) .
\html_writer::span(s($label), 'meetlet-rolechip__text'),
[
'class' => 'meetlet-rolechip',
'for' => $id,
'data-role-value' => $value,
]
);
}
$group = \html_writer::div($chips, 'meetlet-rolechip-group', [
'role' => 'radiogroup',
'aria-label' => $moodlelabel,
]);
$cells .= \html_writer::tag('td', $group, ['class' => 'meetlet-rolemapping-cell']);
return \html_writer::tag('tr', $cells, ['data-role-row' => $rowkey]);
}
public static function get_participant_list(?stdClass $instance, context $context): array {
global $USER;
if ($instance === null || self::activity_inherits_defaults($instance)
|| empty($instance->participants) || $instance->participants === '[]') {
return self::get_participant_list_default($context, $USER->id);
}
$rules = json_decode($instance->participants, true);
if (!is_array($rules) || empty($rules)) {
// Legacy format support.
if (isset($rules['hosts']) || (is_array($rules) && isset($rules[0]) === false && isset($rules['participants']))) {
return self::legacy_to_rules($rules);
}
return self::get_participant_list_default($context, $USER->id);
}
// Detect legacy associative map.
if (isset($rules['hosts']) || isset($rules['presenters'])) {
return self::legacy_to_rules($rules);
}
return $rules;
}
protected static function legacy_to_rules(array $map): array {
$list = [[
'selectiontype' => 'all',
'selectionid' => 'all',
'role' => self::ROLE_PARTICIPANT,
]];
foreach ($map['hosts'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_HOST];
}
foreach ($map['presenters'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PRESENTER];
}
foreach ($map['participants'] ?? [] as $id) {
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PARTICIPANT];
}
return $list;
}
public static function get_participant_data(context $context, ?stdClass $instance = null): array {
$data = [
'all' => [
'name' => get_string('participant_type_all', 'meetletacbmarketplace'),
'children' => [],
],
'role' => [
'name' => get_string('participant_type_role', 'meetletacbmarketplace'),
'children' => self::get_roles_select($context),
],
'user' => [
'name' => get_string('participant_type_user', 'meetletacbmarketplace'),
'children' => [],
],
];
if ($instance) {
$data['user']['children'] = self::get_users_array($context);
}
return $data;
}
protected static function get_roles_select(context $context): array {
$roles = get_viewable_roles($context);
$out = [];
foreach ($roles as $id => $name) {
$out[$id] = ['id' => $id, 'name' => $name];
}
return $out;
}
protected static function get_users_array(context $context): array {
$users = get_enrolled_users($context, '', 0, 'u.id, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic, u.middlename, u.alternatename');
$out = [];
foreach ($users as $u) {
$out[$u->id] = ['id' => $u->id, 'name' => fullname($u)];
}
return $out;
}
public static function get_participant_selection_data(): array {
return [
'type_options' => [
'all' => get_string('participant_type_all', 'meetletacbmarketplace'),
'role' => get_string('participant_type_role', 'meetletacbmarketplace'),
'user' => get_string('participant_type_user', 'meetletacbmarketplace'),
],
'type_selected' => 'role',
'options' => ['all' => '---------------'],
'selected' => 'all',
];
}
/**
* Resolve Adobe permission for user from participant rules.
*
* @return string|null Adobe permission-id or null if denied
*/
public static function resolve_adobe_role(stdClass $instance, context $context, int $userid): ?string {
if (has_capability('mod/meetletacbmarketplace:host', $context, $userid)) {
return service::ROLE_HOST;
}
if (has_capability('mod/meetletacbmarketplace:presenter', $context, $userid)) {
return service::ROLE_PRESENTER;
}
$rules = self::get_participant_list($instance, $context);
$userroles = get_user_roles($context, $userid);
$roleids = [];
foreach ($userroles as $r) {
$roleids[] = (int)$r->roleid;
}
$best = null;
$bestrank = 0;
foreach ($rules as $rule) {
$match = false;
if (($rule['selectiontype'] ?? '') === 'all') {
$match = true;
} else if (($rule['selectiontype'] ?? '') === 'role') {
$match = in_array((int)$rule['selectionid'], $roleids, true);
} else if (($rule['selectiontype'] ?? '') === 'user') {
$match = ((int)$rule['selectionid'] === (int)$userid);
}
if (!$match) {
continue;
}
$role = $rule['role'] ?? self::ROLE_PARTICIPANT;
$rank = self::role_rank($role);
if ($rank > $bestrank) {
$bestrank = $rank;
$best = $role;
}
}
if ($best === null) {
return !empty($instance->meetingpublic) ? service::ROLE_PARTICIPANT : null;
}
return self::to_adobe_permission($best);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Track Adobe Connect room occupancy for auto MP4 after the class actually ends.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
defined('MOODLE_INTERNAL') || die();
/**
* Persist live/closed room state on meetlet_marketplace_meetings and decide when to sweep recordings.
*/
class roomsession {
/** Seconds to keep looking for new recordings after the room empties. */
public const SWEEP_GRACE = 20 * MINSECS;
/**
* Record occupancy. Returns true for an opportunistic queue (just closed / grace window).
*
* Open = at least one person in the Adobe room. Closed = it was open, then empty.
* Moodle schedule endtime is intentionally ignored here.
* Cron retries leftover recordings forever while the room is empty; this return
* only gates page-view / join-status opportunistic queues during the grace window.
*/
public static function note_presence(instance $instance, bool $islive): bool {
global $DB;
$map = $instance->meetingmap;
if (!$map || empty($map->id)) {
return false;
}
$row = $DB->get_record('meetlet_marketplace_meetings', ['id' => (int)$map->id]);
if (!$row) {
return false;
}
$now = time();
$waslive = !empty($row->islive);
if ($islive) {
$row->islive = 1;
$row->liveat = $now;
$DB->update_record('meetlet_marketplace_meetings', $row);
$instance->meetingmap = $row;
return false;
}
if ($waslive) {
$row->islive = 0;
$row->closedat = $now;
$row->sweepuntil = $now + self::sweep_window();
}
$insweep = !empty($row->sweepuntil) && $now <= (int)$row->sweepuntil;
$due = $insweep && $instance->auto_offline_enabled()
&& ($now - (int)($row->sweptat ?? 0) >= 45);
if ($due) {
$row->sweptat = $now;
}
if ($waslive || $due) {
$DB->update_record('meetlet_marketplace_meetings', $row);
}
$instance->meetingmap = $row;
return $due;
}
protected static function sweep_window(): int {
$mins = config::cron_interval_minutes();
return max(self::SWEEP_GRACE, $mins * MINSECS * 4);
}
}
+159
View File
@@ -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']
);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Adobe Connect SSO helpers for the current Moodle user.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use mod_meetletacbmarketplace\api\client;
use mod_meetletacbmarketplace\api\service;
use moodle_exception;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Build Connect logins and BREEZESESSION for Moodle users.
*/
class sso {
/**
* Ordered Connect login candidates for a Moodle user.
*
* @return string[]
*/
public static function login_candidates(stdClass $user, ?stdClass $adobeuser = null): array {
$reserved = [
'administrators', 'authors', 'everyone', 'live-admins', 'admins',
'course-admins', 'event-administrators', 'event-managers', 'learners',
'seminar-administrators',
// Generic Moodle accounts must never be sent as Connect SSO identities.
'admin', 'guest', 'root', 'administrator', 'moodle',
];
$candidates = [];
// Prefer ext-login (refresh_principal_login) then stable mlu{id}, then legacy logins.
$seed = [];
if (!empty($adobeuser->login)) {
$seed[] = (string)$adobeuser->login;
}
if (!empty($adobeuser->internallogin)) {
$seed[] = (string)$adobeuser->internallogin;
}
$seed[] = config::connect_login($user);
foreach (array_merge($seed, config::legacy_connect_logins($user)) as $candidate) {
$candidate = trim((string)$candidate);
if ($candidate === '' || in_array(strtolower($candidate), $reserved, true)) {
continue;
}
if (!in_array($candidate, $candidates, true)) {
$candidates[] = $candidate;
}
}
return $candidates;
}
/**
* Obtain a BREEZESESSION cookie via HTTP header SSO for the given logins.
*
* @param string[] $logincandidates
*/
public static function breeze_session(array $logincandidates): string {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$cookie = '';
$lasterror = null;
$primary = $logincandidates[0] ?? '';
foreach ($logincandidates as $logintry) {
try {
$sso = new client();
$cookie = $sso->login_external($logintry);
if ($cookie !== '') {
return $cookie;
}
} catch (\Throwable $e) {
$lasterror = $e;
}
}
$parsed = $lasterror ? usererror::from_throwable($lasterror) : ['helpid' => 'sso_01', 'facts' => [], 'xml' => ''];
$facts = $parsed['facts'];
if ($primary !== '' && empty($facts['login'])) {
$facts['login'] = $primary;
}
$ip = self::guess_egress_ip();
if ($ip !== '') {
$facts['egress_ip'] = $ip;
}
$helpid = $parsed['helpid'];
if ($lasterror instanceof moodle_exception && $lasterror->errorcode === 'agentrequestfailed') {
$helpid = 'sso_02';
$facts['agent'] = is_scalar($lasterror->a) ? (string)$lasterror->a : $lasterror->getMessage();
}
if ($lasterror instanceof usererror && $lasterror->helpid === 'sso_01') {
$helpid = 'sso_01';
}
throw new usererror($helpid, 'errorhttpauth', $facts, $parsed['xml']);
}
/**
* Best-effort public IP Adobe will see for Moodle API calls.
*/
public static function guess_egress_ip(): string {
try {
$curl = new \curl(['ignoresecurity' => true]);
$curl->setopt(['CURLOPT_TIMEOUT' => 5]);
$raw = $curl->get('https://api.ipify.org');
$ip = trim((string)$raw);
if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) {
return $ip;
}
} catch (\Throwable $e) {
// Ignore.
}
return '';
}
/**
* Ensure Connect user exists, grant permission on a SCO (and parent meeting for
* recordings), then SSO login.
*
* @return array{cookie:string,adobeuser:stdClass}
*/
public static function provision_and_login(stdClass $user, int $scoid, string $permission): array {
\mod_meetletacbmarketplace\registration\manager::require_registered();
$service = new service();
$service->login_admin();
$adobeuser = $service->ensure_user($user);
$adobeuser = $service->refresh_principal_login($adobeuser);
if (trim((string)($adobeuser->login ?? '')) === '') {
throw new usererror('usr_01', 'errorcreateuser', ['login' => config::connect_login($user)]);
}
$targets = [$scoid];
try {
$info = $service->get_client()->request([
'action' => 'sco-info',
'sco-id' => $scoid,
]);
$folder = (int)($info->sco['folder-id'] ?? 0);
// Parent meeting folder ACL is required for recordings and uploaded content.
if ($folder > 0) {
$targets[] = $folder;
}
} catch (\Throwable $e) {
// Best-effort enrichment only.
}
foreach (array_unique($targets) as $aclid) {
if ($aclid > 0) {
$service->assign_permission((int)$adobeuser->principalid, (int)$aclid, $permission);
}
}
$service->logout();
$cookie = self::breeze_session(self::login_candidates($user, $adobeuser));
return ['cookie' => $cookie, 'adobeuser' => $adobeuser];
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Audience-aware MeetLet errors (student vs teacher/admin) with help codes.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\local;
use moodle_exception;
defined('MOODLE_INTERNAL') || die();
/**
* Structured plugin error with a public help code (ML-…) and staff-only detail.
*/
class usererror extends moodle_exception {
/** @var string Internal catalog id, e.g. sso_01 */
public string $helpid = 'err_01';
/** @var array<string, string> Staff fact rows (login, adobe_code, …) */
public array $facts = [];
/** @var string Optional Adobe XML / raw snippet */
public string $xml = '';
/**
* @param string $helpid Catalog id (sso_01, api_01, …)
* @param string $errorcode Moodle lang string id
* @param array<string, string> $facts
* @param string $xml
*/
public function __construct(string $helpid, string $errorcode, array $facts = [], string $xml = '') {
$this->helpid = $helpid !== '' ? $helpid : 'err_01';
$this->facts = $facts;
$this->xml = trim($xml);
$debug = $this->facts;
if ($this->xml !== '') {
$debug['xml'] = $this->xml;
}
parent::__construct($errorcode, 'meetletacbmarketplace', '', null, json_encode($debug, JSON_UNESCAPED_UNICODE));
}
/**
* Teachers, managers, and site admins see technical detail; students do not.
*/
public static function is_staff(\context $context, ?int $userid = null): bool {
return has_any_capability([
'moodle/site:config',
'moodle/course:update',
'moodle/course:manageactivities',
'mod/meetletacbmarketplace:addinstance',
'mod/meetletacbmarketplace:host',
'mod/meetletacbmarketplace:presenter',
], $context, $userid);
}
/**
* Help-code catalog shown in the server-setup guide.
*
* @return array<string, array{label:string,titlekey:string,fixkey:string}>
*/
public static function catalog(): array {
return [
'sso_01' => [
'label' => 'ML-SSO-01',
'titlekey' => 'helpcode_sso_01_title',
'fixkey' => 'helpcode_sso_01_fix',
],
'api_01' => [
'label' => 'ML-API-01',
'titlekey' => 'helpcode_api_01_title',
'fixkey' => 'helpcode_api_01_fix',
],
'api_02' => [
'label' => 'ML-API-02',
'titlekey' => 'helpcode_api_02_title',
'fixkey' => 'helpcode_api_02_fix',
],
'usr_01' => [
'label' => 'ML-USR-01',
'titlekey' => 'helpcode_usr_01_title',
'fixkey' => 'helpcode_usr_01_fix',
],
'mtg_01' => [
'label' => 'ML-MTG-01',
'titlekey' => 'helpcode_mtg_01_title',
'fixkey' => 'helpcode_mtg_01_fix',
],
'cfg_01' => [
'label' => 'ML-CFG-01',
'titlekey' => 'helpcode_cfg_01_title',
'fixkey' => 'helpcode_cfg_01_fix',
],
'err_01' => [
'label' => 'ML-ERR-01',
'titlekey' => 'helpcode_err_01_title',
'fixkey' => 'helpcode_err_01_fix',
],
];
}
/**
* Public label for a catalog id (ML-SSO-01).
*/
public static function label(string $helpid): string {
$cat = self::catalog();
return $cat[$helpid]['label'] ?? 'ML-ERR-01';
}
/**
* Ajax payload for join / recording launch failures.
*
* @return array{code:string,message:string,detailhtml:string,staff:bool}
*/
public static function payload(\Throwable $e, \context $context): array {
if (self::is_gate_exception($e)) {
return [
'code' => '',
'message' => $e->getMessage(),
'detailhtml' => '',
'staff' => false,
];
}
$parsed = self::from_throwable($e);
$label = self::label($parsed['helpid']);
$staff = self::is_staff($context);
if (!$staff) {
return [
'code' => $label,
'message' => get_string('error_user_unavailable', 'meetletacbmarketplace', $label),
'detailhtml' => '',
'staff' => false,
];
}
return [
'code' => $label,
'message' => self::staff_title($parsed['helpid'], $e),
'detailhtml' => self::render_staff_html($parsed),
'staff' => true,
];
}
/**
* WS success envelope.
*
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
*/
public static function ws_ok(string $url): array {
return [
'url' => $url,
'ok' => true,
'helpcode' => '',
'message' => '',
'detailhtml' => '',
];
}
/**
* WS failure envelope (HTTP 200 so the UI can format HTML).
*
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
*/
public static function ws_fail(\Throwable $e, \context $context): array {
$p = self::payload($e, $context);
return [
'url' => '',
'ok' => false,
'helpcode' => $p['code'],
'message' => $p['message'],
'detailhtml' => $p['detailhtml'],
];
}
/**
* @return array{url: \core_external\external_value, ok: \core_external\external_value, helpcode: \core_external\external_value, message: \core_external\external_value, detailhtml: \core_external\external_value}
*/
public static function ws_url_returns(): array {
return [
'url' => new \core_external\external_value(PARAM_RAW, 'URL with session when ok'),
'ok' => new \core_external\external_value(PARAM_BOOL, 'False when the session could not be opened'),
'helpcode' => new \core_external\external_value(PARAM_TEXT, 'Public help code, e.g. ML-SSO-01'),
'message' => new \core_external\external_value(PARAM_RAW, 'Audience-appropriate short message'),
'detailhtml' => new \core_external\external_value(PARAM_RAW, 'Staff-only HTML; empty for students'),
];
}
/**
* Pretty-print an Adobe XML snippet for a code box.
*/
public static function format_xml(string $raw): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
$start = strpos($raw, '<?xml');
if ($start === false) {
$start = strpos($raw, '<results');
}
$chunk = $start === false ? $raw : substr($raw, $start);
if (preg_match('/<results\b[\s\S]*?<\/results>/i', $chunk, $m)) {
$chunk = $m[0];
} else {
$chunk = substr($chunk, 0, 1200);
}
$dom = new \DOMDocument();
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (@$dom->loadXML($chunk)) {
$out = $dom->saveXML($dom->documentElement);
return $out !== false ? trim($out) : trim($chunk);
}
return trim($chunk);
}
/**
* @return array{helpid:string,facts:array<string,string>,xml:string}
*/
public static function from_throwable(\Throwable $e): array {
if ($e instanceof self) {
return [
'helpid' => $e->helpid,
'facts' => $e->facts,
'xml' => $e->xml,
];
}
$code = $e instanceof moodle_exception ? (string)$e->errorcode : '';
$helpid = self::helpid_from_moodle($code);
$facts = [];
$xml = '';
if ($e instanceof moodle_exception && is_scalar($e->a) && (string)$e->a !== '') {
$legacy = self::parse_legacy_detail((string)$e->a);
$facts = $legacy['facts'];
$xml = $legacy['xml'];
}
return ['helpid' => $helpid, 'facts' => $facts, 'xml' => $xml];
}
protected static function is_gate_exception(\Throwable $e): bool {
if (!$e instanceof moodle_exception) {
return false;
}
return in_array($e->errorcode, [
'meetingended',
'meetingnotstarted',
'waitingforhost',
'notparticipant',
'usergrouprequired',
'joinblocked_waitinghost',
], true);
}
protected static function helpid_from_moodle(string $errorcode): string {
$map = [
'errorhttpauth' => 'sso_01',
'erroremptyxml' => 'api_01',
'errorlogin' => 'api_02',
'errorcreateuser' => 'usr_01',
'errorcreatemeeting' => 'mtg_01',
'nomeeting' => 'mtg_01',
'errorexistingsco' => 'mtg_01',
'notconfigured' => 'cfg_01',
];
return $map[$errorcode] ?? 'err_01';
}
/**
* @return array{facts:array<string,string>,xml:string}
*/
protected static function parse_legacy_detail(string $detail): array {
$facts = [];
$xml = '';
if (preg_match('/login=([^\s·]+)/u', $detail, $m)) {
$facts['login'] = $m[1];
}
if (preg_match('/\bcode=([^\s·]+)/u', $detail, $m)) {
$facts['adobe_code'] = $m[1];
}
if (preg_match('/subcode=([^\s·]+)/u', $detail, $m)) {
$facts['adobe_subcode'] = $m[1];
}
$bodypos = stripos($detail, 'body=');
if ($bodypos !== false) {
$xml = self::format_xml(substr($detail, $bodypos + 5));
}
return ['facts' => $facts, 'xml' => $xml];
}
protected static function staff_title(string $helpid, \Throwable $e): string {
$cat = self::catalog();
$key = $cat[$helpid]['titlekey'] ?? '';
if ($key !== '' && get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
return get_string($key, 'meetletacbmarketplace');
}
if ($e instanceof moodle_exception) {
return $e->getMessage();
}
return get_string('error', 'meetletacbmarketplace');
}
/**
* @param array{helpid:string,facts:array<string,string>,xml:string} $parsed
*/
protected static function render_staff_html(array $parsed): string {
$helpid = $parsed['helpid'];
$label = self::label($helpid);
$cat = self::catalog();
$titlekey = $cat[$helpid]['titlekey'] ?? '';
$fixkey = $cat[$helpid]['fixkey'] ?? '';
$title = ($titlekey !== '' && get_string_manager()->string_exists($titlekey, 'meetletacbmarketplace'))
? get_string($titlekey, 'meetletacbmarketplace')
: get_string('error', 'meetletacbmarketplace');
$codebox = \html_writer::div(
\html_writer::span(get_string('error_helpcode_label', 'meetletacbmarketplace'), 'meetlet-err__codelabel') .
\html_writer::tag('code', s($label), ['data-copy-code' => $label]),
'meetlet-err__codebox'
);
$rows = '';
$factorder = ['login', 'adobe_code', 'adobe_subcode', 'egress_ip'];
$facts = $parsed['facts'];
foreach ($factorder as $key) {
if (empty($facts[$key])) {
continue;
}
$dtkey = 'errorfact_' . $key;
$dt = get_string_manager()->string_exists($dtkey, 'meetletacbmarketplace')
? get_string($dtkey, 'meetletacbmarketplace')
: $key;
$rows .= \html_writer::tag(
'div',
\html_writer::tag('dt', s($dt)) .
\html_writer::tag('dd', \html_writer::tag('code', s((string)$facts[$key]))),
['class' => 'meetlet-err__fact']
);
}
foreach ($facts as $key => $val) {
if (in_array($key, $factorder, true) || (string)$val === '') {
continue;
}
$rows .= \html_writer::tag(
'div',
\html_writer::tag('dt', s((string)$key)) .
\html_writer::tag('dd', \html_writer::tag('code', s((string)$val))),
['class' => 'meetlet-err__fact']
);
}
$factshtml = $rows !== '' ? \html_writer::tag('dl', $rows, ['class' => 'meetlet-err__facts']) : '';
$fix = '';
if ($fixkey !== '' && get_string_manager()->string_exists($fixkey, 'meetletacbmarketplace')) {
$fix = \html_writer::div(get_string($fixkey, 'meetletacbmarketplace'), 'meetlet-err__fix');
}
$xmlhtml = '';
if ($parsed['xml'] !== '') {
$xmlhtml = \html_writer::tag('p', get_string('error_adobe_response', 'meetletacbmarketplace'), ['class' => 'meetlet-err__prelabel']) .
\html_writer::tag('pre', s($parsed['xml']), ['class' => 'meetlet-err__xml']);
}
$guide = \html_writer::tag('p', get_string('error_see_serverguide', 'meetletacbmarketplace'), ['class' => 'meetlet-err__guide']);
return \html_writer::div(
\html_writer::tag('p', s($title), ['class' => 'meetlet-err__lead']) .
$codebox .
$factshtml .
$fix .
$xmlhtml .
$guide,
'meetlet-err meetlet-err--staff'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?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 renderer.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\output;
use plugin_renderer_base;
defined('MOODLE_INTERNAL') || die();
/**
* MeetLet renderer.
*/
class renderer extends plugin_renderer_base {
public function render_view_page(view_page $page): string {
return $this->render_from_template('mod_meetletacbmarketplace/view_page', $page->export_for_template($this));
}
}
+130
View File
@@ -0,0 +1,130 @@
<?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/>.
/**
* View page renderable.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\output;
use mod_meetletacbmarketplace\local\featureaccess;
use mod_meetletacbmarketplace\local\icons;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\meetingstatus;
use renderable;
use renderer_base;
use templatable;
defined('MOODLE_INTERNAL') || die();
/**
* Main activity shell with tabs.
*/
class view_page implements renderable, templatable {
/** @var instance */
protected $instance;
/** @var bool */
protected $configured;
/** @var array|null */
protected $lifecycle;
/** @var bool */
protected $registrationusable = true;
public function __construct(
instance $instance,
bool $configured,
?array $lifecycle = null,
bool $registrationusable = true
) {
$this->instance = $instance;
$this->configured = $configured;
$this->lifecycle = $lifecycle;
$this->registrationusable = $registrationusable;
}
public function export_for_template(renderer_base $output): array {
global $USER;
$rec = $this->instance->record;
$lifecycle = $this->lifecycle ?? meetingstatus::resolve_local($this->instance, (int)$USER->id);
$status = $lifecycle['status'];
$statuslabel = $lifecycle['statuslabel'];
$startts = (int)$lifecycle['starttime'];
$endts = (int)$lifecycle['endtime'];
$intro = format_module_intro('meetletacbmarketplace', $rec, $this->instance->cm->id);
$tabs = [
[
'id' => 'details',
'label' => get_string('tabdetails', 'meetletacbmarketplace'),
'icon' => icons::svg('details'),
'active' => true,
],
];
if (featureaccess::allowed(featureaccess::RECORDINGS, $this->instance->context, $rec)) {
$tabs[] = [
'id' => 'recordings',
'label' => get_string('tabrecordings', 'meetletacbmarketplace'),
'icon' => icons::svg('recordings'),
'active' => false,
];
}
if (featureaccess::allowed(featureaccess::CONTENT, $this->instance->context, $rec)) {
$tabs[] = [
'id' => 'content',
'label' => get_string('tabcontent', 'meetletacbmarketplace'),
'icon' => icons::svg('content'),
'active' => false,
];
}
$featurejoin = featureaccess::allowed(featureaccess::JOIN, $this->instance->context, $rec);
$joinable = $featurejoin && !empty($lifecycle['joinable']);
return [
'cmid' => (int)$this->instance->cm->id,
'groupid' => (int)$this->instance->groupid,
'name' => format_string($rec->name),
'intro' => $intro,
'hasintro' => trim(strip_tags($intro)) !== '',
'detailstitle' => get_string('tabdetails', 'meetletacbmarketplace'),
'emptydetails' => get_string('emptydetails', 'meetletacbmarketplace'),
'configured' => $this->configured,
'notconfiguredmsg' => get_string('notconfigured', 'meetletacbmarketplace'),
'subtitle' => get_string('subtitle', 'meetletacbmarketplace'),
'logourl' => $output->image_url('adobeconnect', 'meetletacbmarketplace')->out(false),
'status' => $status,
'statuslabel' => $statuslabel,
'statusmessage' => (string)$lifecycle['message'],
'starttime' => $startts ? userdate($startts) : '',
'endtime' => $endts ? userdate($endts) : '',
'hasend' => !empty($endts),
'tabs' => $tabs,
'loading' => get_string('loading', 'meetletacbmarketplace'),
'joining' => get_string('joining', 'meetletacbmarketplace'),
'joiningtitle' => get_string('joiningtitle', 'meetletacbmarketplace'),
'joinlabel' => get_string('joinmeeting', 'meetletacbmarketplace'),
'canjoin' => $featurejoin,
'joinable' => $joinable,
'statuspollseconds' => $this->registrationusable ? 15 : 0,
'registrationusable' => $this->registrationusable,
];
}
}
+373
View File
@@ -0,0 +1,373 @@
<?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/>.
/**
* Privacy API for mod_meetletacbmarketplace.
*
* @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\privacy;
defined('MOODLE_INTERNAL') || die();
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\helper;
use core_privacy\local\request\transform;
use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;
/**
* Privacy subsystem implementation.
*
* Local personal data is mainly the activity creator (userid) and optional
* per-user rows in the participants JSON. Profile fields are also sent to
* Adobe Connect for SSO.
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\plugin\provider,
\core_privacy\local\request\core_userlist_provider {
/**
* Describe stored / exported personal data.
*
* @param collection $collection
* @return collection
*/
public static function get_metadata(collection $collection): collection {
$collection->add_database_table('meetletacbmarketplace', [
'userid' => 'privacy:metadata:meetletacbmarketplace:userid',
'name' => 'privacy:metadata:meetletacbmarketplace:name',
'intro' => 'privacy:metadata:meetletacbmarketplace:intro',
'participants' => 'privacy:metadata:meetletacbmarketplace:participants',
'timecreated' => 'privacy:metadata:meetletacbmarketplace:timecreated',
'timemodified' => 'privacy:metadata:meetletacbmarketplace:timemodified',
], 'privacy:metadata:meetletacbmarketplace');
$collection->add_database_table('meetlet_marketplace_meetings', [
'groupid' => 'privacy:metadata:meetlet_marketplace_meetings:groupid',
'meetingscoid' => 'privacy:metadata:meetlet_marketplace_meetings:meetingscoid',
'urlpath' => 'privacy:metadata:meetlet_marketplace_meetings:urlpath',
], 'privacy:metadata:meetlet_marketplace_meetings');
$collection->add_database_table('meetlet_marketplace_recordings', [
'name' => 'privacy:metadata:meetlet_marketplace_recordings:name',
'urlpath' => 'privacy:metadata:meetlet_marketplace_recordings:urlpath',
'jobstatus' => 'privacy:metadata:meetlet_marketplace_recordings:jobstatus',
], 'privacy:metadata:meetlet_marketplace_recordings');
$collection->add_external_location_link('adobeconnect', [
'firstname' => 'privacy:metadata:adobeconnect:firstname',
'lastname' => 'privacy:metadata:adobeconnect:lastname',
'email' => 'privacy:metadata:adobeconnect:email',
'username' => 'privacy:metadata:adobeconnect:username',
], 'privacy:metadata:adobeconnect');
$collection->add_database_table('meetlet_marketplace_license', [
'installation_id' => 'privacy:metadata:meetlet_marketplace_license:installation_id',
'status' => 'privacy:metadata:meetlet_marketplace_license:status',
'last_validated' => 'privacy:metadata:meetlet_marketplace_license:last_validated',
], 'privacy:metadata:meetlet_marketplace_license');
$collection->add_external_location_link('meetlet_registration', [
'domain' => 'privacy:metadata:meetlet_registration:domain',
'installation_id' => 'privacy:metadata:meetlet_registration:installation_id',
'company' => 'privacy:metadata:meetlet_registration:company',
'email' => 'privacy:metadata:meetlet_registration:email',
'plugin' => 'privacy:metadata:meetlet_registration:plugin',
'plugin_release' => 'privacy:metadata:meetlet_registration:plugin_release',
'platform' => 'privacy:metadata:meetlet_registration:platform',
'platform_version' => 'privacy:metadata:meetlet_registration:platform_version',
], 'privacy:metadata:meetlet_registration');
return $collection;
}
/**
* Contexts that hold data for a user.
*
* @param int $userid
* @return contextlist
*/
public static function get_contexts_for_userid(int $userid): contextlist {
$contextlist = new contextlist();
$sql = "SELECT c.id
FROM {context} c
JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
JOIN {modules} m ON m.id = cm.module AND m.name = :modname
JOIN {meetletacbmarketplace} a ON a.id = cm.instance
WHERE a.userid = :userid";
$contextlist->add_from_sql($sql, [
'contextlevel' => CONTEXT_MODULE,
'modname' => 'meetletacbmarketplace',
'userid' => $userid,
]);
// Per-user participant mapping rows (selectiontype = user).
global $DB;
$pat1 = '%"selectiontype":"user","selectionid":"' . $userid . '"%';
$pat2 = '%"selectionid":"' . $userid . '","selectiontype":"user"%';
$sql = "SELECT c.id
FROM {context} c
JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
JOIN {modules} m ON m.id = cm.module AND m.name = :modname
JOIN {meetletacbmarketplace} a ON a.id = cm.instance
WHERE " . $DB->sql_like('a.participants', ':pat1') . "
OR " . $DB->sql_like('a.participants', ':pat2');
$contextlist->add_from_sql($sql, [
'contextlevel' => CONTEXT_MODULE,
'modname' => 'meetletacbmarketplace',
'pat1' => $pat1,
'pat2' => $pat2,
]);
return $contextlist;
}
/**
* Users who have data in a module context.
*
* @param userlist $userlist
*/
public static function get_users_in_context(userlist $userlist): void {
$context = $userlist->get_context();
if (!$context instanceof \context_module) {
return;
}
$params = [
'instanceid' => $context->instanceid,
'modulename' => 'meetletacbmarketplace',
];
$sql = "SELECT a.userid
FROM {course_modules} cm
JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
JOIN {meetletacbmarketplace} a ON a.id = cm.instance
WHERE cm.id = :instanceid AND a.userid <> 0";
$userlist->add_from_sql('userid', $sql, $params);
global $DB;
$instance = $DB->get_record_sql(
"SELECT a.participants
FROM {course_modules} cm
JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
JOIN {meetletacbmarketplace} a ON a.id = cm.instance
WHERE cm.id = :instanceid",
$params
);
if (!$instance || empty($instance->participants)) {
return;
}
$rules = json_decode((string)$instance->participants, true);
if (!is_array($rules)) {
return;
}
$userids = [];
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
if (($rule['selectiontype'] ?? '') === 'user' && !empty($rule['selectionid'])) {
$userids[] = (int)$rule['selectionid'];
}
}
$userlist->add_users(array_values(array_unique(array_filter($userids))));
}
/**
* Export user data for approved module contexts.
*
* @param approved_contextlist $contextlist
*/
public static function export_user_data(approved_contextlist $contextlist): void {
global $DB;
$user = $contextlist->get_user();
$userid = (int)$user->id;
foreach ($contextlist->get_contexts() as $context) {
if ($context->contextlevel !== CONTEXT_MODULE) {
continue;
}
$cm = get_coursemodule_from_id('meetletacbmarketplace', $context->instanceid);
if (!$cm) {
continue;
}
$activity = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance]);
if (!$activity) {
continue;
}
$contextdata = helper::get_context_data($context, $user);
$exportdata = [
'name' => format_string($activity->name, true, ['context' => $context]),
'intro' => format_text($activity->intro ?? '', $activity->introformat ?? FORMAT_HTML,
['context' => $context]),
'is_creator' => ((int)$activity->userid === $userid),
'timecreated' => empty($activity->timecreated) ? null : transform::datetime($activity->timecreated),
'timemodified' => empty($activity->timemodified) ? null : transform::datetime($activity->timemodified),
'adobeconnect_note' => get_string('privacy:export:adobeconnect', 'meetletacbmarketplace'),
];
$userrules = [];
$rules = json_decode((string)($activity->participants ?? ''), true);
if (is_array($rules)) {
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
if (($rule['selectiontype'] ?? '') === 'user' && (int)($rule['selectionid'] ?? 0) === $userid) {
$userrules[] = [
'role' => (string)($rule['role'] ?? ''),
];
}
}
}
if ($userrules) {
$exportdata['participant_mappings'] = $userrules;
}
writer::with_context($context)->export_data([], (object)array_merge((array)$contextdata, $exportdata));
helper::export_context_files($context, $user);
}
}
/**
* Delete all user data in a module context (anonymise creator + scrub user rules).
*
* @param \context $context
*/
public static function delete_data_for_all_users_in_context(\context $context): void {
global $DB;
if (!$context instanceof \context_module) {
return;
}
$cm = get_coursemodule_from_id('meetletacbmarketplace', $context->instanceid);
if (!$cm) {
return;
}
$activity = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance]);
if (!$activity) {
return;
}
$activity->userid = 0;
$activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), null);
$activity->timemodified = time();
$DB->update_record('meetletacbmarketplace', $activity);
}
/**
* Delete one user's data in the approved contexts.
*
* @param approved_contextlist $contextlist
*/
public static function delete_data_for_user(approved_contextlist $contextlist): void {
global $DB;
$userid = (int)$contextlist->get_user()->id;
foreach ($contextlist->get_contexts() as $context) {
if ($context->contextlevel !== CONTEXT_MODULE) {
continue;
}
$cm = get_coursemodule_from_id('meetletacbmarketplace', $context->instanceid);
if (!$cm) {
continue;
}
$activity = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance]);
if (!$activity) {
continue;
}
if ((int)$activity->userid === $userid) {
$activity->userid = 0;
}
$activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), $userid);
$activity->timemodified = time();
$DB->update_record('meetletacbmarketplace', $activity);
}
}
/**
* Delete data for users in a context (bulk).
*
* @param approved_userlist $userlist
*/
public static function delete_data_for_users(approved_userlist $userlist): void {
global $DB;
$context = $userlist->get_context();
if (!$context instanceof \context_module) {
return;
}
$userids = $userlist->get_userids();
if (!$userids) {
return;
}
$cm = get_coursemodule_from_id('meetletacbmarketplace', $context->instanceid);
if (!$cm) {
return;
}
$activity = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance]);
if (!$activity) {
return;
}
if (in_array((int)$activity->userid, $userids, true)) {
$activity->userid = 0;
}
foreach ($userids as $userid) {
$activity->participants = self::strip_user_rules((string)($activity->participants ?? ''), (int)$userid);
}
$activity->timemodified = time();
$DB->update_record('meetletacbmarketplace', $activity);
}
/**
* Remove user selection rules from participants JSON.
*
* @param string $json
* @param int|null $userid null = remove every user-type rule
* @return string
*/
protected static function strip_user_rules(string $json, ?int $userid): string {
if ($json === '') {
return $json;
}
$rules = json_decode($json, true);
if (!is_array($rules)) {
return $json;
}
$out = [];
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
if (($rule['selectiontype'] ?? '') === 'user') {
if ($userid === null || (int)($rule['selectionid'] ?? 0) === $userid) {
continue;
}
}
$out[] = $rule;
}
return json_encode($out, JSON_UNESCAPED_UNICODE);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* HTTP client for MeetLet registration API.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Talks to MeetLet /api/v1 licensing endpoints (free-tier registration).
*/
class api_client {
/** @var string */
protected $baseurl;
public function __construct(?string $baseurl = null) {
$url = $baseurl ?? manager::api_base_url();
$this->baseurl = rtrim(trim($url), '/');
}
public function activate(array $body): array {
return $this->request('/api/v1/activations', $body);
}
public function validate(array $body): array {
return $this->request('/api/v1/validations', $body);
}
public function refresh(array $body): array {
return $this->request('/api/v1/authorizations/refresh', $body);
}
public function deactivate(string $installationid, array $body): array {
$path = '/api/v1/activations/' . rawurlencode($installationid) . '/deactivate';
return $this->request($path, $body);
}
/**
* @return array data from API envelope
*/
protected function request(string $path, array $body): array {
global $CFG;
require_once($CFG->libdir . '/filelib.php');
$url = $this->baseurl . $path;
$json = json_encode($body, JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new registration_exception(error_mapper::LOCAL_NETWORK_ERROR, 'Could not encode request JSON');
}
$curl = new \curl(['ignoresecurity' => true]);
$raw = $curl->post($url, $json, [
'CURLOPT_HTTPHEADER' => [
'Content-Type: application/json',
'Accept: application/json',
],
'CURLOPT_TIMEOUT' => 30,
]);
$info = $curl->get_info();
$httpcode = (int)($info['http_code'] ?? 0);
if ($curl->get_errno()) {
throw new registration_exception(
error_mapper::LOCAL_NETWORK_ERROR,
$curl->error . ' (url=' . $url . ')'
);
}
$raw = is_string($raw) ? trim($raw) : '';
if ($raw === '') {
throw new registration_exception(
error_mapper::LOCAL_NETWORK_ERROR,
'Empty response HTTP ' . $httpcode . ' from ' . $url
);
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$snippet = \core_text::substr(preg_replace('/\s+/', ' ', $raw), 0, 180);
throw new registration_exception(
error_mapper::LOCAL_NETWORK_ERROR,
'Invalid JSON (HTTP ' . $httpcode . '): ' . $snippet
);
}
$requestid = isset($decoded['request_id']) ? (string)$decoded['request_id'] : null;
if (empty($decoded['success'])) {
$err = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
$code = (string)($err['code'] ?? 'UNKNOWN');
$message = (string)($err['message'] ?? 'Registration request failed');
$details = isset($err['details']) && is_array($err['details']) ? $err['details'] : null;
throw new registration_exception($code, $message, $details, $requestid);
}
$data = $decoded['data'] ?? [];
if (!is_array($data)) {
$data = [];
}
if ($requestid !== null && $requestid !== '') {
$data['_request_id'] = $requestid;
}
return $data;
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Ed25519 + canonical JSON helpers (compatible with @meetlet/crypto).
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Crypto primitives for MeetLet registration protocol.
*/
class crypto {
/** MeetLet platform public key (SPKI PEM) — verify runtime authorizations only. */
public const PLATFORM_PUBLIC_KEY_PEM = <<<'PEM'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAYyZTlJQzdGHyZ2aAZ45kJIOx4Rq1Opyy6OPpgRFoEtU=
-----END PUBLIC KEY-----
PEM;
/**
* Canonical JSON: sorted object keys, no whitespace.
*
* @param mixed $value
*/
public static function canonicalize($value): string {
return json_encode(self::sort_value($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* @param mixed $value
* @return mixed
*/
protected static function sort_value($value) {
if ($value instanceof \stdClass) {
$asarray = get_object_vars($value);
if ($asarray === []) {
return new \stdClass();
}
ksort($asarray);
$out = new \stdClass();
foreach ($asarray as $k => $v) {
$out->{$k} = self::sort_value($v);
}
return $out;
}
if (is_array($value)) {
if (self::is_list($value)) {
return array_map([self::class, 'sort_value'], $value);
}
ksort($value);
$out = [];
foreach ($value as $k => $v) {
$out[$k] = self::sort_value($v);
}
return $out;
}
return $value;
}
/**
* @param array $arr
*/
protected static function is_list(array $arr): bool {
if ($arr === []) {
return true;
}
return array_keys($arr) === range(0, count($arr) - 1);
}
public static function base64url_encode(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
public static function base64url_decode(string $b64): string {
$pad = 4 - (strlen($b64) % 4);
if ($pad < 4) {
$b64 .= str_repeat('=', $pad);
}
$raw = base64_decode(strtr($b64, '-_', '+/'), true);
if ($raw === false) {
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
}
return $raw;
}
/**
* @return array{private_pem:string,public_pem:string,secret:string,public:string}
*/
public static function generate_installation_keypair(): array {
if (!function_exists('sodium_crypto_sign_keypair')) {
throw new \moodle_exception('registration_sodium_required', 'meetletacbmarketplace');
}
$kp = sodium_crypto_sign_keypair();
$secret = sodium_crypto_sign_secretkey($kp);
$public = sodium_crypto_sign_publickey($kp);
$seed = substr($secret, 0, 32);
return [
'private_pem' => self::seed_to_pkcs8_pem($seed),
'public_pem' => self::raw_public_to_spki_pem($public),
'secret' => $secret,
'public' => $public,
];
}
public static function seed_to_pkcs8_pem(string $seed32): string {
$der = hex2bin('302e020100300506032b657004220420') . $seed32;
return "-----BEGIN PRIVATE KEY-----\n" .
chunk_split(base64_encode($der), 64, "\n") .
"-----END PRIVATE KEY-----\n";
}
public static function raw_public_to_spki_pem(string $public32): string {
$der = hex2bin('302a300506032b6570032100') . $public32;
return "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($der), 64, "\n") .
"-----END PUBLIC KEY-----\n";
}
public static function spki_pem_to_raw(string $pem): string {
$pem = trim($pem);
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
$b64 = preg_replace('/\s+/', '', $b64);
$der = base64_decode($b64, true);
if ($der === false || strlen($der) < 32) {
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
}
return substr($der, -32);
}
public static function pkcs8_pem_to_secret(string $pem): string {
$pem = trim($pem);
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
$b64 = preg_replace('/\s+/', '', $b64);
$der = base64_decode($b64, true);
if ($der === false || strlen($der) < 48) {
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
}
$seed = substr($der, -32);
$kp = sodium_crypto_sign_seed_keypair($seed);
return sodium_crypto_sign_secretkey($kp);
}
public static function sign(string $privatepem, $payload): string {
$secret = self::pkcs8_pem_to_secret($privatepem);
$msg = self::canonicalize($payload);
$sig = sodium_crypto_sign_detached($msg, $secret);
return self::base64url_encode($sig);
}
public static function verify(string $publicpem, $payload, string $signatureb64url): bool {
try {
$public = self::spki_pem_to_raw($publicpem);
$msg = self::canonicalize($payload);
$sig = self::base64url_decode($signatureb64url);
return sodium_crypto_sign_verify_detached($sig, $msg, $public);
} catch (\Throwable $e) {
return false;
}
}
public static function random_nonce(): string {
return self::base64url_encode(random_bytes(16));
}
public static function random_installation_id(): string {
return 'mdl-' . bin2hex(random_bytes(16));
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Map registration API / local error codes → product status and UX behaviour.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Branch on stable error.code only (never on message text).
*/
class error_mapper {
public const STATUS_ACTIVE = 'active';
public const STATUS_GRACE = 'grace';
public const STATUS_OFFLINE_GRACE_EXPIRED = 'offline_grace_expired';
public const STATUS_INVALID_KEY = 'invalid_key';
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_REVOKED = 'revoked';
public const STATUS_DOMAIN_MISMATCH = 'domain_mismatch';
public const STATUS_ACTIVATION_LIMIT = 'activation_limit';
public const STATUS_NETWORK_ERROR = 'network_error';
public const STATUS_MISCONFIGURED = 'misconfigured';
public const STATUS_UNKNOWN = 'unknown';
public const STATUS_INACTIVE = 'inactive';
public const LOCAL_NETWORK_ERROR = 'NETWORK_ERROR';
public const LOCAL_INVALID_AUTHORIZATION_SIGNATURE = 'INVALID_AUTHORIZATION_SIGNATURE';
/**
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array}
*/
public static function map(string $code, ?array $details = null, bool $hasgraceauth = false): array {
$code = strtoupper(trim($code));
if ($code === '') {
$code = 'UNKNOWN';
}
switch ($code) {
case 'INVALID_LICENSE':
case 'INSTALLATION_NOT_FOUND':
return self::result($code, self::STATUS_INVALID_KEY, true, 'registration_err_invalid_key', $details);
case 'LICENSE_EXPIRED':
return self::result($code, self::STATUS_EXPIRED, true, 'registration_err_expired', $details);
case 'LICENSE_REVOKED':
case 'INSTALLATION_REVOKED':
return self::result($code, self::STATUS_REVOKED, true, 'registration_err_revoked', $details);
case 'LICENSE_SUSPENDED':
case 'CUSTOMER_SUSPENDED':
case 'PRODUCT_SUSPENDED':
return self::result($code, self::STATUS_SUSPENDED, true, 'registration_err_suspended', $details);
case 'PRODUCT_NOT_FOUND':
case 'PRODUCT_NOT_ENTITLED':
case 'PRODUCT_MISMATCH':
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_product_mismatch', $details);
case 'DOMAIN_NOT_ALLOWED':
return self::result($code, self::STATUS_DOMAIN_MISMATCH, true, 'registration_err_domain', $details);
case 'ACTIVATION_LIMIT_REACHED':
case 'ACTIVATION_LIMIT':
return self::result($code, self::STATUS_ACTIVATION_LIMIT, true, 'registration_err_activation_limit', $details);
case 'INVALID_REQUEST_SIGNATURE':
case 'INVALID_SIGNATURE':
case 'TIMESTAMP_INVALID':
case 'VALIDATION_ERROR':
case 'INVALID_REQUEST':
return self::result($code, self::STATUS_MISCONFIGURED, false, 'registration_err_misconfigured', $details);
case self::LOCAL_INVALID_AUTHORIZATION_SIGNATURE:
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_bad_signature', $details);
case 'REPLAY_DETECTED':
case 'RATE_LIMITED':
case 'TEMPORARY_LICENSE_SERVICE_UNAVAILABLE':
case self::LOCAL_NETWORK_ERROR:
if ($hasgraceauth) {
return self::result($code, self::STATUS_ACTIVE, false, '', $details);
}
return self::result($code, self::STATUS_NETWORK_ERROR, false, 'registration_err_network', $details);
default:
return self::result($code, self::STATUS_UNKNOWN, false, 'registration_err_unknown', $details);
}
}
/**
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array,request_id:?string,message:string}
*/
public static function from_exception(registration_exception $e, bool $hasgraceauth = false): array {
$mapped = self::map($e->get_error_code(), $e->get_details(), $hasgraceauth);
$mapped['request_id'] = $e->get_request_id();
$mapped['message'] = self::user_message($mapped['message_key'], $e->get_api_message());
return $mapped;
}
public static function user_message(string $messagekey, string $fallback = ''): string {
if ($messagekey !== '' && get_string_manager()->string_exists($messagekey, 'meetletacbmarketplace')) {
return get_string($messagekey, 'meetletacbmarketplace');
}
return $fallback !== '' ? $fallback : $messagekey;
}
/**
* @param array|null $details
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array}
*/
protected static function result(
string $code,
string $status,
bool $clearauth,
string $messagekey,
?array $details
): array {
return [
'code' => $code,
'status' => $status,
'clear_auth' => $clearauth,
'message_key' => $messagekey,
'details' => $details,
];
}
}
+616
View File
@@ -0,0 +1,616 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Free online activation for MeetLet Marketplace edition.
*
* Hard-locks all plugin features until the administrator completes voluntary
* online activation. After activation, every Marketplace feature unlocks.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Activate / validate / deactivate free-tier site registration and gate features.
*/
class manager {
/** Free product slug on the MeetLet license platform. */
public const PRODUCT_SLUG = 'free-adobe-connect-bridge';
public const CONFIG_API_URL = 'registration_api_url';
public const DEFAULT_API_URL = 'https://license.webatsign.com';
public static function api_base_url(): string {
$url = trim((string)get_config('meetletacbmarketplace', self::CONFIG_API_URL));
$url = rtrim($url, '/');
return $url !== '' ? $url : self::DEFAULT_API_URL;
}
/**
* Whether the site administrator has completed free-tier registration.
*/
public static function is_registered(): bool {
$row = store::get_record();
if (!$row || ($row->status ?? '') !== error_mapper::STATUS_ACTIVE) {
return false;
}
return self::get_verified_authorization() !== null;
}
public static function is_usable(): bool {
return self::is_registered();
}
/**
* Moodle tabbed admin settings URL for a specific tab.
*/
public static function settings_tab_url(string $tab): \moodle_url {
return new \moodle_url('/admin/settings.php', [
'section' => 'modsettingmeetletacbmarketplace',
], $tab);
}
public static function settings_url(): \moodle_url {
return self::settings_tab_url('meetletacbmarketplace_registration');
}
public static function privacy_tab_url(): \moodle_url {
return self::settings_tab_url('meetletacbmarketplace_privacy');
}
/**
* Throw registration_required with continue link to Online activation tab.
*/
public static function require_registered(): void {
if (self::is_registered()) {
return;
}
try {
if (store::get_authorization() !== null) {
self::recheck(true);
}
} catch (\Throwable $e) {
// Fall through.
}
if (self::is_registered()) {
return;
}
throw new \moodle_exception('registration_required', 'meetletacbmarketplace', self::settings_url());
}
/** @deprecated Use require_registered() */
public static function require_active(): void {
self::require_registered();
}
public static function admin_gate_notice_html(): string {
if (self::is_registered()) {
return '';
}
$url = self::settings_url();
return \html_writer::div(
\html_writer::tag('strong', get_string('registration_gate_title', 'meetletacbmarketplace')) .
' ' . get_string('registration_gate_body', 'meetletacbmarketplace') . ' ' .
\html_writer::link($url, get_string('registration_gate_link', 'meetletacbmarketplace')),
'alert alert-warning meetlet-registration-gate mb-3'
);
}
public static function admin_hero_banner_html(): string {
return '';
}
public static function activate_free(): array {
return self::activate();
}
public static function activate(): array {
if (self::is_registered()) {
throw new \moodle_exception('registration_already_active', 'meetletacbmarketplace');
}
$row = store::get_record();
$stale = $row && in_array($row->status ?? '', [
error_mapper::STATUS_REVOKED,
error_mapper::STATUS_INVALID_KEY,
error_mapper::STATUS_EXPIRED,
error_mapper::STATUS_OFFLINE_GRACE_EXPIRED,
error_mapper::STATUS_SUSPENDED,
error_mapper::STATUS_DOMAIN_MISMATCH,
error_mapper::STATUS_ACTIVATION_LIMIT,
], true);
if ($stale) {
store::rotate_identity();
}
return self::activate_with_current_identity(true);
}
public static function validate(bool $force = true): array {
return self::validate_or_refresh($force);
}
public static function recheck(bool $force = true): array {
return self::validate_or_refresh($force);
}
public static function deactivate(): void {
$identity = store::ensure_identity();
$signed = self::signed_request($identity, ['action' => 'deactivate']);
try {
(new api_client())->deactivate($identity->installation_id, $signed);
} catch (\Throwable $e) {
// Still clear local state.
}
store::update([
'auth_json' => null,
'license_uuid' => null,
'status' => 'inactive',
'last_error' => null,
]);
store::rotate_identity();
}
/**
* @return array<string,mixed>
*/
public static function status_summary(): array {
self::enforce_local_expiry();
$row = store::get_record();
$storedstatus = (string)($row->status ?? 'inactive');
$hardblock = in_array($storedstatus, [
error_mapper::STATUS_SUSPENDED,
error_mapper::STATUS_REVOKED,
error_mapper::STATUS_EXPIRED,
error_mapper::STATUS_INVALID_KEY,
error_mapper::STATUS_DOMAIN_MISMATCH,
error_mapper::STATUS_ACTIVATION_LIMIT,
], true);
$lasterror = store::get_last_error();
$auth = $hardblock ? null : self::get_verified_authorization();
$payload = is_array($auth['payload'] ?? null) ? $auth['payload'] : null;
$status = $storedstatus;
if ($payload !== null && $auth !== null) {
if ($status !== error_mapper::STATUS_ACTIVE) {
store::update([
'status' => error_mapper::STATUS_ACTIVE,
'last_error' => null,
]);
}
$status = error_mapper::STATUS_ACTIVE;
} else if ($auth === null && $status === error_mapper::STATUS_ACTIVE) {
$status = error_mapper::STATUS_INACTIVE;
}
$active = $auth !== null && !$hardblock;
return [
'active' => $active,
'registered' => $active,
'status' => $status,
'status_label' => self::status_label($active ? error_mapper::STATUS_ACTIVE : $status),
'product' => $active ? self::PRODUCT_SLUG : '',
'product_label' => $active ? get_string('registration_product_free', 'meetletacbmarketplace') : '',
'can_deactivate' => self::can_deactivate($row),
'auth_expires_at' => self::parse_unix_timestamp($payload['expires_at'] ?? null),
'features' => is_array($payload['features'] ?? null) ? $payload['features'] : [],
'installation_id' => $row->installation_id ?? '',
'domain' => self::site_domain(),
'api_url' => self::api_base_url(),
'last_validated' => !empty($row->last_validated) ? (int)$row->last_validated : null,
'error' => $active ? null : self::status_error_message($status, $lasterror),
'last_error' => $lasterror,
'license_uuid' => $row->license_uuid ?? null,
'site_company' => self::site_company(),
'site_admin_email' => self::site_admin_email(),
'moodle_release' => self::moodle_release(),
'plugin_release' => self::plugin_release(),
];
}
public static function heartbeat(): void {
$row = store::get_record();
if (!$row || empty($row->auth_json)) {
mtrace('MeetLet registration: not registered — skip heartbeat.');
return;
}
if (self::enforce_local_expiry()) {
mtrace('MeetLet registration: authorization expired — cleared auth.');
return;
}
try {
self::validate_or_refresh(true);
mtrace('MeetLet registration: heartbeat OK.');
} catch (\Throwable $e) {
mtrace('MeetLet registration heartbeat failed: ' . $e->getMessage());
}
}
public static function site_domain(): string {
global $CFG;
$host = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
return self::normalize_domain(is_string($host) ? $host : 'localhost');
}
public static function normalize_domain(string $input): string {
$host = trim(strtolower($input));
if ($host === '') {
return 'localhost';
}
if (preg_match('~(://|/|\?|#)~', $host)) {
$parsed = parse_url((strpos($host, '://') === false ? 'https://' : '') . $host, PHP_URL_HOST);
$host = is_string($parsed) ? strtolower($parsed) : $host;
}
$host = rtrim($host, '.');
if (strpos($host, ':') !== false) {
$host = explode(':', $host, 2)[0];
}
if (str_starts_with($host, 'www.')) {
$host = substr($host, 4);
}
if ($host !== '' && function_exists('idn_to_ascii') && preg_match('/[^\x20-\x7f]/', $host)) {
$ascii = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if (is_string($ascii) && $ascii !== '') {
$host = strtolower($ascii);
}
}
return $host !== '' ? $host : 'localhost';
}
public static function status_label(string $status): string {
$key = 'registration_status_' . $status;
if (get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
return get_string($key, 'meetletacbmarketplace');
}
return $status;
}
public static function parse_unix_timestamp($value): ?int {
if ($value === null || $value === '') {
return null;
}
if (is_int($value) || is_float($value)) {
$n = (int) $value;
} else if (is_string($value) && is_numeric($value)) {
$n = (int) $value;
} else if (is_string($value)) {
$parsed = strtotime($value);
return ($parsed !== false && $parsed > 0) ? $parsed : null;
} else {
return null;
}
if ($n > 9999999999) {
$n = (int) floor($n / 1000);
}
return $n > 0 ? $n : null;
}
public static function enforce_local_expiry(): bool {
$auth = store::get_authorization();
if ($auth === null) {
return false;
}
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
return false;
}
$expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null);
if ($expires !== null && time() >= $expires) {
self::invalidate_local([
'code' => 'LICENSE_EXPIRED',
'message' => get_string('registration_err_expired', 'meetletacbmarketplace'),
'at' => time(),
], error_mapper::STATUS_EXPIRED);
return true;
}
return false;
}
/**
* @return array{payload:array,signature:string}|null
*/
public static function get_verified_authorization(): ?array {
$row = store::get_record();
if ($row && in_array($row->status ?? '', [
error_mapper::STATUS_SUSPENDED,
error_mapper::STATUS_REVOKED,
error_mapper::STATUS_EXPIRED,
error_mapper::STATUS_INVALID_KEY,
error_mapper::STATUS_DOMAIN_MISMATCH,
error_mapper::STATUS_ACTIVATION_LIMIT,
], true)) {
return null;
}
$auth = store::get_authorization();
if ($auth === null) {
return null;
}
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
return null;
}
$expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null);
if ($expires !== null && time() >= $expires) {
return null;
}
return $auth;
}
/**
* @param \stdClass|null $row
*/
public static function can_deactivate(?\stdClass $row = null): bool {
$row = $row ?? store::get_record();
if (!$row) {
return false;
}
if (!empty($row->auth_json)) {
return true;
}
return in_array($row->status ?? '', [
error_mapper::STATUS_ACTIVE,
error_mapper::STATUS_GRACE,
error_mapper::STATUS_EXPIRED,
error_mapper::STATUS_OFFLINE_GRACE_EXPIRED,
error_mapper::STATUS_SUSPENDED,
error_mapper::STATUS_REVOKED,
error_mapper::STATUS_INVALID_KEY,
error_mapper::STATUS_DOMAIN_MISMATCH,
error_mapper::STATUS_ACTIVATION_LIMIT,
], true);
}
protected static function validate_or_refresh(bool $force = false): array {
self::enforce_local_expiry();
$identity = store::ensure_identity();
$auth = store::get_authorization();
$now = time();
if (!$force && $auth && !empty($auth['payload']['expires_at'])) {
$expires = self::parse_unix_timestamp($auth['payload']['expires_at']);
if ($expires !== null && $now < ($expires - 3600) && self::get_verified_authorization() !== null) {
return $auth;
}
}
$api = new api_client();
if (!$force && $auth) {
try {
$signedrefresh = self::signed_request($identity, new \stdClass());
$data = $api->refresh($signedrefresh);
return self::persist_authorization($data, $identity);
} catch (\Throwable $e) {
$mapped = self::apply_mapped_error($e, false);
if (!empty($mapped['clear_auth'])) {
throw registration_exception::from_throwable($e);
}
}
}
$body = [
'product' => self::PRODUCT_SLUG,
'domain' => self::site_domain(),
'reported_host' => self::site_domain(),
'current_version' => self::plugin_release(),
];
$signed = self::signed_request($identity, $body);
try {
$data = $api->validate($signed);
} catch (\Throwable $e) {
self::apply_mapped_error($e);
if (self::get_verified_authorization() !== null) {
store::update(['last_validated' => $now]);
$cached = self::get_verified_authorization();
if ($cached !== null) {
return $cached;
}
}
throw registration_exception::from_throwable($e);
}
return self::persist_authorization($data, $identity);
}
protected static function activate_with_current_identity(bool $allowrotate): array {
$identity = store::ensure_identity();
$body = [
'product' => self::PRODUCT_SLUG,
'installation_id' => $identity->installation_id,
'installation_public_key' => $identity->public_key_pem,
'domain' => self::site_domain(),
'environment' => self::environment(),
'platform' => 'moodle',
'platform_version' => self::moodle_release(),
'runtime' => 'php',
'runtime_version' => PHP_VERSION,
'metadata' => [
'plugin' => 'mod_meetletacbmarketplace',
'plugin_release' => self::plugin_release(),
],
];
$company = self::site_company();
$email = self::site_admin_email();
if ($company !== '') {
$body['metadata']['company'] = $company;
}
if ($email !== '') {
$body['metadata']['email'] = $email;
$body['metadata']['admin_email'] = $email;
}
try {
$data = (new api_client())->activate($body);
} catch (\Throwable $e) {
$ex = registration_exception::from_throwable($e);
$code = $ex->get_error_code();
if ($allowrotate && in_array($code, ['INVALID_LICENSE', 'INSTALLATION_REVOKED'], true)) {
store::rotate_identity();
return self::activate_with_current_identity(false);
}
self::apply_mapped_error($ex);
throw $ex;
}
return self::persist_authorization($data, store::ensure_identity());
}
protected static function persist_authorization(array $data, \stdClass $identity): array {
$auth = $data['authorization'] ?? null;
if (!is_array($auth) || empty($auth['payload']) || empty($auth['signature'])) {
$ex = new registration_exception('INVALID_REQUEST', 'Missing authorization in API response');
self::apply_mapped_error($ex);
throw $ex;
}
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
$ex = new registration_exception(
error_mapper::LOCAL_INVALID_AUTHORIZATION_SIGNATURE,
'Authorization signature verification failed'
);
self::apply_mapped_error($ex);
throw $ex;
}
$licenseuuid = $data['license_id'] ?? ($auth['payload']['license_id'] ?? null);
store::update([
'auth_json' => json_encode($auth, JSON_UNESCAPED_SLASHES),
'license_uuid' => $licenseuuid,
'status' => error_mapper::STATUS_ACTIVE,
'last_error' => null,
'last_validated' => time(),
]);
return $auth;
}
/**
* @param array|string|null $error
*/
public static function invalidate_local($error, string $status = 'inactive'): void {
$fields = [
'auth_json' => null,
'status' => $status,
'last_validated' => time(),
];
if (is_array($error)) {
$fields['last_error'] = json_encode([
'code' => (string)($error['code'] ?? 'UNKNOWN'),
'message' => (string)($error['message'] ?? ''),
'details' => $error['details'] ?? null,
'request_id' => $error['request_id'] ?? null,
'at' => (int)($error['at'] ?? time()),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$fields['last_error'] = $error;
}
store::update($fields);
}
protected static function apply_mapped_error(\Throwable $e, bool $persist = true): array {
$hasgrace = self::get_verified_authorization() !== null;
$ex = registration_exception::from_throwable($e);
$mapped = error_mapper::from_exception($ex, $hasgrace);
if (!$persist) {
return $mapped;
}
$lasterror = $ex->to_last_error();
$lasterror['message'] = $mapped['message'] ?? $lasterror['message'];
if (!empty($mapped['request_id'])) {
$lasterror['request_id'] = $mapped['request_id'];
}
if (!empty($mapped['clear_auth'])) {
self::invalidate_local($lasterror, $mapped['status']);
return $mapped;
}
store::update([
'status' => $mapped['status'],
'last_error' => json_encode($lasterror, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'last_validated' => time(),
]);
return $mapped;
}
protected static function signed_request(\stdClass $identity, $body): array {
$timestamp = time();
$nonce = crypto::random_nonce();
$bodyforcanon = (is_array($body) && $body === []) ? new \stdClass() : $body;
$parts = [
'installation_id' => $identity->installation_id,
'timestamp' => $timestamp,
'nonce' => $nonce,
'body' => $bodyforcanon,
];
$signature = crypto::sign($identity->private_key_pem, $parts);
$out = [
'installation_id' => $identity->installation_id,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
];
if (is_array($body)) {
foreach ($body as $k => $v) {
$out[$k] = $v;
}
}
return $out;
}
/**
* @param array|null $lasterror
*/
protected static function status_error_message(string $status, ?array $lasterror): ?string {
if ($status === error_mapper::STATUS_EXPIRED) {
return get_string('registration_err_expired', 'meetletacbmarketplace');
}
return $lasterror['message'] ?? null;
}
protected static function site_company(): string {
global $SITE;
$name = trim(strip_tags((string)($SITE->fullname ?? '')));
if ($name === '') {
$name = trim(strip_tags((string)($SITE->shortname ?? '')));
}
return $name !== '' ? $name : self::site_domain();
}
protected static function site_admin_email(): string {
global $CFG, $USER;
$candidates = [trim((string)($CFG->supportemail ?? ''))];
$admin = get_admin();
if ($admin && !empty($admin->email)) {
$candidates[] = trim((string)$admin->email);
}
if (!empty($USER->email)) {
$candidates[] = trim((string)$USER->email);
}
foreach ($candidates as $email) {
if ($email !== '' && validate_email($email)) {
return $email;
}
}
return '';
}
protected static function environment(): string {
global $CFG;
if (!empty($CFG->debugdeveloper)) {
return 'development';
}
return 'production';
}
protected static function moodle_release(): string {
global $CFG;
return isset($CFG->release) ? (string)$CFG->release : '';
}
public static function plugin_release(): string {
global $CFG;
$plugin = new \stdClass();
$versionfile = $CFG->dirroot . '/mod/meetletacbmarketplace/version.php';
if (is_readable($versionfile)) {
include($versionfile);
}
if (!empty($plugin->release)) {
return (string)$plugin->release;
}
$ver = get_config('meetletacbmarketplace', 'version');
return $ver !== false ? (string)$ver : '0';
}
}
@@ -0,0 +1,88 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Structured registration API / local client exception.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Carries stable error.code from the platform envelope (or a local SDK code).
*/
class registration_exception extends \moodle_exception {
/** @var string */
protected $apicode;
/** @var string */
protected $apimessage;
/** @var array|null */
protected $details;
/** @var string|null */
protected $requestid;
public function __construct(string $code, string $message = '', ?array $details = null, ?string $requestid = null) {
$code = strtoupper(trim($code));
if ($code === '') {
$code = 'UNKNOWN';
}
$this->apicode = $code;
$this->apimessage = trim($message) !== '' ? trim($message) : $code;
$this->details = is_array($details) ? $details : null;
$this->requestid = $requestid !== null && $requestid !== '' ? $requestid : null;
parent::__construct(
'registration_api_error',
'meetletacbmarketplace',
'',
$this->apicode . ': ' . $this->apimessage
);
}
public function get_error_code(): string {
return $this->apicode;
}
public function get_api_message(): string {
return $this->apimessage;
}
public function get_details(): ?array {
return $this->details;
}
public function get_request_id(): ?string {
return $this->requestid;
}
/**
* @return array{code:string,message:string,details:?array,request_id:?string,at:int}
*/
public function to_last_error(): array {
return [
'code' => $this->apicode,
'message' => $this->apimessage,
'details' => $this->details,
'request_id' => $this->requestid,
'at' => time(),
];
}
public static function from_throwable(\Throwable $e): self {
if ($e instanceof self) {
return $e;
}
$msg = $e->getMessage();
if (preg_match('/^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$/s', $msg, $m)) {
return new self($m[1], $m[2]);
}
return new self('UNKNOWN', $msg);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Persist MeetLet installation identity + cached runtime authorization.
*
* @package mod_meetletacbmarketplace
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbmarketplace\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Single-row registration store in meetlet_marketplace_license.
*/
class store {
public const TABLE = 'meetlet_marketplace_license';
/**
* @return \stdClass|null
*/
public static function get_record() {
global $DB;
if (!$DB->get_manager()->table_exists(self::TABLE)) {
return null;
}
$rows = $DB->get_records(self::TABLE, null, 'id ASC', '*', 0, 1);
return $rows ? reset($rows) : null;
}
/**
* @return \stdClass
*/
public static function ensure_identity(): \stdClass {
global $DB;
$row = self::get_record();
if ($row && !empty($row->installation_id) && !empty($row->private_key_pem)) {
return $row;
}
$kp = crypto::generate_installation_keypair();
$now = time();
if ($row) {
$row->installation_id = $row->installation_id ?: crypto::random_installation_id();
$row->private_key_pem = $kp['private_pem'];
$row->public_key_pem = $kp['public_pem'];
$row->timemodified = $now;
$DB->update_record(self::TABLE, $row);
return $row;
}
$rec = (object)[
'installation_id' => crypto::random_installation_id(),
'private_key_pem' => $kp['private_pem'],
'public_key_pem' => $kp['public_pem'],
'auth_json' => null,
'license_uuid' => null,
'status' => 'inactive',
'last_error' => null,
'last_validated' => 0,
'timecreated' => $now,
'timemodified' => $now,
];
$rec->id = $DB->insert_record(self::TABLE, $rec);
return $rec;
}
/**
* @param array $fields
*/
public static function update(array $fields): \stdClass {
global $DB;
$row = self::ensure_identity();
foreach ($fields as $k => $v) {
$row->{$k} = $v;
}
$row->timemodified = time();
$DB->update_record(self::TABLE, $row);
return $row;
}
/**
* @return array{payload:array,signature:string}|null
*/
public static function get_authorization(): ?array {
$row = self::get_record();
if (!$row || empty($row->auth_json)) {
return null;
}
$decoded = json_decode($row->auth_json, true);
if (!is_array($decoded) || empty($decoded['payload']) || empty($decoded['signature'])) {
return null;
}
return $decoded;
}
/**
* @return array{code:string,message:string,details?:?array,request_id?:?string,at:int}|null
*/
public static function get_last_error(): ?array {
$row = self::get_record();
if (!$row || empty($row->last_error)) {
return null;
}
$raw = (string)$row->last_error;
$decoded = json_decode($raw, true);
if (is_array($decoded) && !empty($decoded['code'])) {
return [
'code' => (string)$decoded['code'],
'message' => (string)($decoded['message'] ?? $decoded['code']),
'details' => isset($decoded['details']) && is_array($decoded['details']) ? $decoded['details'] : null,
'request_id' => isset($decoded['request_id']) ? (string)$decoded['request_id'] : null,
'at' => (int)($decoded['at'] ?? ($row->timemodified ?? 0)),
];
}
return [
'code' => 'UNKNOWN',
'message' => $raw,
'details' => null,
'request_id' => null,
'at' => (int)($row->timemodified ?? 0),
];
}
public static function rotate_identity(): \stdClass {
$kp = crypto::generate_installation_keypair();
return self::update([
'installation_id' => crypto::random_installation_id(),
'private_key_pem' => $kp['private_pem'],
'public_key_pem' => $kp['public_pem'],
'auth_json' => null,
'license_uuid' => null,
'status' => 'inactive',
'last_error' => null,
'last_validated' => 0,
]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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/>.
/**
* Scheduled task: optional MeetLet site registration heartbeat.
*
* @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\task;
use mod_meetletacbmarketplace\registration\manager;
defined('MOODLE_INTERNAL') || die();
/**
* Re-validate site registration once per day when the admin has opted in.
*/
class registration_heartbeat extends \core\task\scheduled_task {
public function get_name() {
return get_string('task_registration_heartbeat', 'meetletacbmarketplace');
}
public function execute() {
manager::heartbeat();
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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/>.
/**
* Scheduled task: sync Adobe Connect recordings into Moodle.
*
* @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\task;
use mod_meetletacbmarketplace\api\service;
use mod_meetletacbmarketplace\local\instance;
use mod_meetletacbmarketplace\local\recordings;
defined('MOODLE_INTERNAL') || die();
/**
* Sync recordings for all activity instances.
*/
class sync_recordings extends \core\task\scheduled_task {
public function get_name() {
return get_string('tasksync', 'meetletacbmarketplace');
}
public function execute() {
if (!\mod_meetletacbmarketplace\registration\manager::is_registered()) {
mtrace('MeetLet registration: inactive — skip recording sync.');
return;
}
global $DB;
foreach ($DB->get_records('meetletacbmarketplace') as $activity) {
foreach ($DB->get_records('meetlet_marketplace_meetings', ['instanceid' => $activity->id]) as $map) {
try {
$inst = instance::get_from_instanceid((int)$activity->id, (int)$map->groupid);
$service = new service();
$service->login_admin();
recordings::sync($inst, $service);
$service->logout();
} catch (\Throwable $e) {
mtrace('MeetLet marketplace sync failed for instance ' . $activity->id . ': ' . $e->getMessage());
}
}
}
}
}