Adobe Connect Bridge Marketplace edition — initial release
This commit is contained in:
@@ -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',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+126
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+346
@@ -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
@@ -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
@@ -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'),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Plugin config helpers.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
/**
|
||||
* Read MeetLet / Adobe Connect global settings.
|
||||
*/
|
||||
class config {
|
||||
/** Activity follows site-wide setting. */
|
||||
public const OVERRIDE_INHERIT = 0;
|
||||
/** Activity explicitly disables the setting. */
|
||||
public const OVERRIDE_OFF = 1;
|
||||
/** Activity explicitly enables the setting. */
|
||||
public const OVERRIDE_ON = 2;
|
||||
/**
|
||||
* API endpoint URL (may already include https://.../api/xml).
|
||||
*/
|
||||
public static function get_api_url(): string {
|
||||
return trim((string)get_config('meetletacbmarketplace', 'host'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting host domain (no protocol).
|
||||
*/
|
||||
public static function get_meet_host(): string {
|
||||
return trim((string)get_config('meetletacbmarketplace', 'meethost'));
|
||||
}
|
||||
|
||||
public static function get_port(): int {
|
||||
$port = (int)get_config('meetletacbmarketplace', 'port');
|
||||
return $port > 0 ? $port : 443;
|
||||
}
|
||||
|
||||
public static function use_https(): bool {
|
||||
return (bool)get_config('meetletacbmarketplace', 'https');
|
||||
}
|
||||
|
||||
public static function get_admin_login(): string {
|
||||
return (string)get_config('meetletacbmarketplace', 'admin_login');
|
||||
}
|
||||
|
||||
public static function get_admin_password(): string {
|
||||
return (string)get_config('meetletacbmarketplace', 'admin_password');
|
||||
}
|
||||
|
||||
public static function get_http_auth_header(): string {
|
||||
$h = trim((string)get_config('meetletacbmarketplace', 'admin_httpauth'));
|
||||
return $h !== '' ? $h : 'Generate-Safe-Random-Token';
|
||||
}
|
||||
|
||||
/**
|
||||
* When true, also look up legacy Connect accounts by email/username
|
||||
* and realign them to mlu{id}. SSO identity itself is always Moodle id.
|
||||
*/
|
||||
public static function use_email_login(): bool {
|
||||
return (bool)get_config('meetletacbmarketplace', 'email_login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide auto MP4 after Adobe Connect class actually ends (room empty).
|
||||
* Per-activity checkbox is an extra opt-in when this is off.
|
||||
*/
|
||||
public static function auto_offline_default(): bool {
|
||||
if (!self::offline_render_active()) {
|
||||
return false;
|
||||
}
|
||||
return (bool)get_config('meetletacbmarketplace', 'auto_offline');
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes between recording-sync / auto-MP4 cron runs (1–60, default 5).
|
||||
*/
|
||||
public static function cron_interval_minutes(): int {
|
||||
$v = (int)get_config('meetletacbmarketplace', 'cron_interval');
|
||||
if ($v < 1) {
|
||||
$v = 5;
|
||||
}
|
||||
if ($v > 60) {
|
||||
$v = 60;
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** Send collected jobs with every MeetLet cron run. */
|
||||
public const DISPATCH_IMMEDIATE = 'immediate';
|
||||
/** Send collected jobs only during selected site-timezone hours. */
|
||||
public const DISPATCH_WINDOW = 'window';
|
||||
/** Default overnight hours (22:00–05:59). */
|
||||
public const DEFAULT_DISPATCH_HOURS = '22,23,0,1,2,3,4,5';
|
||||
|
||||
/**
|
||||
* How auto-MP4 jobs are handed to the Agent.
|
||||
*/
|
||||
public static function agent_dispatch_mode(): string {
|
||||
$v = trim((string)get_config('meetletacbmarketplace', 'agent_dispatch'));
|
||||
return $v === self::DISPATCH_WINDOW ? self::DISPATCH_WINDOW : self::DISPATCH_IMMEDIATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected hours (0–23) in the site timezone. Empty means “none”.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public static function agent_dispatch_hours(): array {
|
||||
$raw = (string)get_config('meetletacbmarketplace', 'agent_dispatch_hours');
|
||||
if ($raw === '') {
|
||||
$raw = self::DEFAULT_DISPATCH_HOURS;
|
||||
}
|
||||
return self::parse_dispatch_hours($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public static function parse_dispatch_hours(string $raw): array {
|
||||
$hours = [];
|
||||
foreach (preg_split('/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $bit) {
|
||||
if (!preg_match('/^\d{1,2}$/', $bit)) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)$bit;
|
||||
if ($h >= 0 && $h <= 23) {
|
||||
$hours[$h] = $h;
|
||||
}
|
||||
}
|
||||
$hours = array_values($hours);
|
||||
sort($hours);
|
||||
return $hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current hour 0–23 in the Moodle server / site timezone.
|
||||
*/
|
||||
public static function site_hour_now(): int {
|
||||
$tz = \core_date::get_server_timezone_object();
|
||||
return (int)(new \DateTimeImmutable('now', $tz))->format('G');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when collected jobs may be POSTed to the Agent.
|
||||
*/
|
||||
public static function agent_send_allowed_now(): bool {
|
||||
if (self::agent_dispatch_mode() !== self::DISPATCH_WINDOW) {
|
||||
return true;
|
||||
}
|
||||
$hours = self::agent_dispatch_hours();
|
||||
if (!$hours) {
|
||||
return false;
|
||||
}
|
||||
return in_array(self::site_hour_now(), $hours, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix time of the next allowed send hour start, or 0 if sending is allowed now / immediate.
|
||||
*/
|
||||
public static function agent_send_next_open(): int {
|
||||
if (self::agent_send_allowed_now()) {
|
||||
return 0;
|
||||
}
|
||||
$hours = self::agent_dispatch_hours();
|
||||
if (!$hours) {
|
||||
return 0;
|
||||
}
|
||||
$tz = \core_date::get_server_timezone_object();
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
$cur = (int)$now->format('G');
|
||||
foreach ($hours as $h) {
|
||||
if ($h > $cur) {
|
||||
return $now->setTime($h, 0, 0)->getTimestamp();
|
||||
}
|
||||
}
|
||||
return $now->modify('+1 day')->setTime($hours[0], 0, 0)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the scheduled-task minute field from plugin settings.
|
||||
*/
|
||||
public static function apply_cron_interval(): void {
|
||||
global $DB;
|
||||
$mins = self::cron_interval_minutes();
|
||||
$row = $DB->get_record('task_scheduled', ['classname' => '\\mod_meetletacbmarketplace\\task\\sync_recordings']);
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
$row->minute = $mins <= 1 ? '*' : ('*/' . $mins);
|
||||
$row->nextruntime = time() + ($mins * MINSECS);
|
||||
$DB->update_record('task_scheduled', $row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline MP4 is Premium-only: paid license + mp4_offline entitlement.
|
||||
* Free / unlicensed sites never activate Agent auto/manual MP4 paths.
|
||||
*/
|
||||
public static function offline_render_active(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured Render Agent / offline listener base URL (may be empty).
|
||||
*/
|
||||
public static function agent_url(): string {
|
||||
return trim((string)get_config('meetletacbmarketplace', 'agent_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide default meeting UI language (Adobe Connect), empty = server default.
|
||||
*/
|
||||
public static function default_meeting_lang(): string {
|
||||
$v = get_config('meetletacbmarketplace', 'default_meetinglang');
|
||||
if ($v === false || $v === null) {
|
||||
return '';
|
||||
}
|
||||
return clean_param((string)$v, PARAM_ALPHANUMEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide default: block participants until host joins.
|
||||
*/
|
||||
public static function default_wait_for_host(): bool {
|
||||
return (bool)get_config('meetletacbmarketplace', 'default_waitforhost');
|
||||
}
|
||||
|
||||
/**
|
||||
* Language choices for meeting UI (site + activity forms).
|
||||
*
|
||||
* @return array<string,string> code => label
|
||||
*/
|
||||
public static function meeting_lang_options(): array {
|
||||
return [
|
||||
'' => get_string('meetinglang_default', 'meetletacbmarketplace'),
|
||||
'en' => 'English',
|
||||
'fa' => 'فارسی',
|
||||
'ar' => 'العربية',
|
||||
'de' => 'Deutsch',
|
||||
'fr' => 'Français',
|
||||
'es' => 'Español',
|
||||
'tr' => 'Türkçe',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the manual “request offline video” button is allowed (optionally per activity).
|
||||
*
|
||||
* @param \stdClass|null $activity
|
||||
*/
|
||||
public static function allow_manual_offline(?\stdClass $activity = null): bool {
|
||||
if (!self::offline_render_active()) {
|
||||
return false;
|
||||
}
|
||||
return self::show_feature('makeoffline', $activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a UI feature is enabled globally.
|
||||
*
|
||||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||||
*/
|
||||
public static function feature_enabled(string $feature): bool {
|
||||
// Offline player (pbMode) removed from product — always hidden.
|
||||
if ($feature === 'offlineplayer') {
|
||||
return false;
|
||||
}
|
||||
if (($feature === 'mp4' || $feature === 'makeoffline') && !self::offline_render_active()) {
|
||||
return false;
|
||||
}
|
||||
$key = 'enable_' . $feature;
|
||||
$v = get_config('meetletacbmarketplace', $key);
|
||||
if ($v === false && $feature === 'mp4') {
|
||||
$legacy = get_config('meetletacbmarketplace', 'allow_manual_offline');
|
||||
return $legacy === false ? true : (bool)$legacy;
|
||||
}
|
||||
// ZIP is opt-in; other features default on when unset.
|
||||
if ($v === false) {
|
||||
return $feature !== 'zip';
|
||||
}
|
||||
return (bool)$v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether teachers may override a feature per activity.
|
||||
*
|
||||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||||
*/
|
||||
public static function feature_allow_override(string $feature): bool {
|
||||
$key = 'allow_override_' . $feature;
|
||||
$v = get_config('meetletacbmarketplace', $key);
|
||||
return $v === false ? true : (bool)$v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective visibility for a feature on an activity instance (site + activity switches only).
|
||||
* Always combine with featureaccess::allowed() / the role matrix for user actions.
|
||||
*
|
||||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||||
* @param \stdClass|null $activity
|
||||
*/
|
||||
public static function show_feature(string $feature, ?\stdClass $activity = null): bool {
|
||||
if (!self::feature_enabled($feature)) {
|
||||
return false;
|
||||
}
|
||||
if (!self::feature_allow_override($feature) || $activity === null) {
|
||||
return true;
|
||||
}
|
||||
$fieldmap = [
|
||||
'zip' => 'showzip',
|
||||
'offlineplayer' => 'showofflineplayer',
|
||||
'recordingstab' => 'showrecordingstab',
|
||||
'contenttab' => 'showcontenttab',
|
||||
'mp4' => 'showmp4',
|
||||
'makeoffline' => 'showmakeoffline',
|
||||
];
|
||||
$field = $fieldmap[$feature] ?? null;
|
||||
if ($field === null) {
|
||||
return true;
|
||||
}
|
||||
$siteenabled = true;
|
||||
if ($field === 'showofflineplayer') {
|
||||
$siteenabled = false;
|
||||
}
|
||||
return self::resolve_activity_toggle($activity, $field, $siteenabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective wait-for-host value for one activity.
|
||||
*/
|
||||
public static function wait_for_host(?\stdClass $activity = null): bool {
|
||||
$sitevalue = self::default_wait_for_host();
|
||||
return self::resolve_activity_toggle($activity, 'waitforhost', $sitevalue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective automatic offline-video value for one activity.
|
||||
*/
|
||||
public static function auto_offline_enabled(?\stdClass $activity = null): bool {
|
||||
if (!self::offline_render_active()) {
|
||||
return false;
|
||||
}
|
||||
return self::resolve_activity_toggle($activity, 'autooffline', self::auto_offline_default());
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-state options for inherit / off / on controls.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function override_state_options(): array {
|
||||
return [
|
||||
self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbmarketplace'),
|
||||
self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbmarketplace'),
|
||||
self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbmarketplace'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON override map stored on the activity row.
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public static function activity_setting_overrides(?\stdClass $activity): array {
|
||||
if (!$activity || empty($activity->settingoverrides)) {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode((string)$activity->settingoverrides, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($decoded as $key => $value) {
|
||||
$state = self::normalize_override_state($value);
|
||||
if ($state !== self::OVERRIDE_INHERIT) {
|
||||
$out[(string)$key] = $state;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective state for one activity toggle.
|
||||
*/
|
||||
public static function activity_override_state(?\stdClass $activity, string $field): int {
|
||||
$overrides = self::activity_setting_overrides($activity);
|
||||
if (array_key_exists($field, $overrides)) {
|
||||
return $overrides[$field];
|
||||
}
|
||||
return self::OVERRIDE_INHERIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one override state from DB / form values.
|
||||
*/
|
||||
public static function normalize_override_state($value): int {
|
||||
$value = (int)$value;
|
||||
if (in_array($value, [self::OVERRIDE_INHERIT, self::OVERRIDE_OFF, self::OVERRIDE_ON], true)) {
|
||||
return $value;
|
||||
}
|
||||
return self::OVERRIDE_INHERIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build normalized JSON for activity setting overrides.
|
||||
*
|
||||
* @param array<string,mixed> $states
|
||||
*/
|
||||
public static function setting_overrides_json(array $states): string {
|
||||
$out = [];
|
||||
foreach ($states as $field => $value) {
|
||||
$state = self::normalize_override_state($value);
|
||||
if ($state !== self::OVERRIDE_INHERIT) {
|
||||
$out[(string)$field] = $state;
|
||||
}
|
||||
}
|
||||
return $out ? json_encode($out, JSON_UNESCAPED_UNICODE) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a boolean site/activity setting.
|
||||
*/
|
||||
protected static function resolve_activity_toggle(?\stdClass $activity, string $field, bool $sitevalue): bool {
|
||||
if ($activity === null) {
|
||||
return $sitevalue;
|
||||
}
|
||||
$state = self::activity_override_state($activity, $field);
|
||||
if ($state === self::OVERRIDE_OFF) {
|
||||
return false;
|
||||
}
|
||||
if ($state === self::OVERRIDE_ON) {
|
||||
return true;
|
||||
}
|
||||
if (property_exists($activity, 'settingoverrides') || property_exists($activity, 'participantsmode')) {
|
||||
return $sitevalue;
|
||||
}
|
||||
if (!isset($activity->{$field})) {
|
||||
return $sitevalue;
|
||||
}
|
||||
return !empty($activity->{$field});
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP download link: site/activity “show ZIP” AND role matrix.
|
||||
* Prefer featureaccess::allowed(ZIP) in new code.
|
||||
*/
|
||||
public static function user_can_download_zip(\context $context, ?\stdClass $activity = null, ?int $userid = null): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an email is acceptable for Adobe Connect principal-update.
|
||||
*/
|
||||
public static function email_usable_for_adobe(string $email): bool {
|
||||
$email = trim($email);
|
||||
if ($email === '' || strpos($email, '@') === false) {
|
||||
return false;
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return false;
|
||||
}
|
||||
// Adobe rejects fake/reserved domains (e.g. test@test.test → invalid/format).
|
||||
if (preg_match('/\.(test|invalid|localhost|local|example)$/i', $email)) {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('/@(users\.moodle\.local|example\.(com|org|net))$/i', $email)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic Connect email on the meeting host domain.
|
||||
*/
|
||||
public static function synthetic_connect_email(\stdClass $user): string {
|
||||
$host = self::get_meet_host();
|
||||
$host = preg_replace('#^https?://#i', '', $host);
|
||||
$host = explode('/', $host)[0] ?? '';
|
||||
$host = explode(':', $host)[0] ?? '';
|
||||
$host = trim((string)$host);
|
||||
if ($host === '' || !preg_match('/\./', $host)) {
|
||||
global $CFG;
|
||||
$www = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
|
||||
$host = is_string($www) && $www !== '' ? $www : 'meetlet.local';
|
||||
}
|
||||
return 'moodleuser' . (int)($user->id ?? 0) . '@' . $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical Adobe Connect login for a Moodle user.
|
||||
*
|
||||
* Stable across email/username changes: mlu{moodleuserid}.
|
||||
* Header SSO must send this same string (or the principal's current login
|
||||
* until a legacy account is realigned).
|
||||
*/
|
||||
public static function connect_login(\stdClass $user): string {
|
||||
$id = (int)($user->id ?? 0);
|
||||
if ($id > 0) {
|
||||
return 'mlu' . $id;
|
||||
}
|
||||
// Disposable / connection-test users may have no Moodle id.
|
||||
$username = trim((string)($user->username ?? ''));
|
||||
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $username);
|
||||
if ($safe !== '') {
|
||||
return 'mlu_' . $safe;
|
||||
}
|
||||
return 'mlu_tmp' . substr(sha1(($user->email ?? '') . '|' . microtime(true)), 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Connect logins that may already exist for this Moodle user
|
||||
* (before stable mlu{id} identity). Used only for lookup/migration.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function legacy_connect_logins(\stdClass $user): array {
|
||||
$out = [];
|
||||
$email = trim((string)($user->email ?? ''));
|
||||
$username = trim((string)($user->username ?? ''));
|
||||
foreach ([
|
||||
self::synthetic_connect_email($user),
|
||||
'moodleuser' . (int)($user->id ?? 0),
|
||||
self::email_usable_for_adobe($email) ? $email : '',
|
||||
($username !== '' && strpos($username, '@') === false) ? $username : '',
|
||||
self::email_usable_for_adobe($username) ? $username : '',
|
||||
] as $candidate) {
|
||||
$candidate = trim((string)$candidate);
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($candidate, '@') !== false && !self::email_usable_for_adobe($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($candidate, $out, true)) {
|
||||
$out[] = $candidate;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort email for Adobe principal-update (profile field only; not SSO key).
|
||||
*/
|
||||
public static function connect_email(\stdClass $user): string {
|
||||
$email = trim((string)($user->email ?? ''));
|
||||
if (self::email_usable_for_adobe($email)) {
|
||||
return $email;
|
||||
}
|
||||
return self::synthetic_connect_email($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build public meeting/content URL.
|
||||
*/
|
||||
public static function public_url(string $urlpath, string $session = ''): string {
|
||||
$protocol = self::use_https() ? 'https://' : 'http://';
|
||||
$host = self::get_meet_host();
|
||||
$port = self::get_port();
|
||||
$portpart = '';
|
||||
if ($port && $port != 80 && !(self::use_https() && $port == 443)) {
|
||||
$portpart = ':' . $port;
|
||||
}
|
||||
if ($urlpath !== '' && $urlpath[0] !== '/') {
|
||||
$urlpath = '/' . $urlpath;
|
||||
}
|
||||
$url = $protocol . $host . $portpart . $urlpath;
|
||||
if ($session !== '') {
|
||||
$url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($session);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append query params without duplicating keys.
|
||||
*
|
||||
* @param array<string,string|int|bool> $params
|
||||
*/
|
||||
public static function append_query(string $url, array $params): string {
|
||||
foreach ($params as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$pair = rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
|
||||
// Skip if already present.
|
||||
if (preg_match('/([?&])' . preg_quote((string)$key, '/') . '=/', $url)) {
|
||||
continue;
|
||||
}
|
||||
$url .= (strpos($url, '?') === false ? '?' : '&') . $pair;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force HTML client in-browser — skip Flash / desktop-app launcher interstitial
|
||||
* that otherwise hangs on “preparing the recording/meeting”.
|
||||
*/
|
||||
public static function with_html_client(string $url): string {
|
||||
return self::append_query($url, [
|
||||
'html-view' => 'true',
|
||||
'launcher' => 'false',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording playback URL (HTML client).
|
||||
*/
|
||||
public static function recording_play_url(string $urlpath, string $session = ''): string {
|
||||
return self::with_html_client(self::public_url($urlpath, $session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploaded meeting content view URL (HTML client).
|
||||
*/
|
||||
public static function content_view_url(string $urlpath, string $session = ''): string {
|
||||
return self::with_html_client(self::public_url($urlpath, $session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adobe Connect recording editor URL (pbMode=edit).
|
||||
*/
|
||||
public static function recording_editor_url(string $urlpath, string $session = ''): string {
|
||||
$url = self::public_url($urlpath, $session);
|
||||
return self::with_html_client(self::append_query($url, [
|
||||
'pbMode' => 'edit',
|
||||
'page' => 'm',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether settings look complete enough to call the API.
|
||||
*/
|
||||
public static function is_configured(): bool {
|
||||
return self::get_api_url() !== ''
|
||||
&& self::get_meet_host() !== ''
|
||||
&& self::get_admin_login() !== ''
|
||||
&& self::get_admin_password() !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Stepwise Adobe Connect connection tests for admin Ajax UI.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
use mod_meetletacbmarketplace\api\client;
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
use mod_meetletacbmarketplace\local\usererror;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Runs connection checks one step at a time (session-backed).
|
||||
*/
|
||||
class conntest_runner {
|
||||
public const STEPS = ['login', 'folders', 'user', 'sso', 'cleanup'];
|
||||
|
||||
/**
|
||||
* Reset session scratch data for a new run.
|
||||
*/
|
||||
public static function reset(): void {
|
||||
global $SESSION;
|
||||
$SESSION->meetletacbmarketplace_conntest = [
|
||||
'service' => null,
|
||||
'meetings' => null,
|
||||
'scoid' => null,
|
||||
'principalid' => null,
|
||||
'login' => null,
|
||||
'failed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,ok:bool,detail:string,done:bool,step:string}
|
||||
*/
|
||||
public static function run_step(string $step): array {
|
||||
global $SESSION;
|
||||
|
||||
\mod_meetletacbmarketplace\registration\manager::require_registered();
|
||||
|
||||
if (empty($SESSION->meetletacbmarketplace_conntest) || $step === 'login') {
|
||||
self::reset();
|
||||
}
|
||||
$state = &$SESSION->meetletacbmarketplace_conntest;
|
||||
|
||||
if (!config::is_configured()) {
|
||||
return [
|
||||
'step' => $step,
|
||||
'label' => get_string('error', 'meetletacbmarketplace'),
|
||||
'ok' => false,
|
||||
'detail' => get_string('notconfigured', 'meetletacbmarketplace'),
|
||||
'done' => true,
|
||||
'abort' => true,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($step) {
|
||||
case 'login':
|
||||
return self::step_login($state);
|
||||
case 'folders':
|
||||
return self::step_folders($state);
|
||||
case 'user':
|
||||
return self::step_user($state);
|
||||
case 'sso':
|
||||
return self::step_sso($state);
|
||||
case 'cleanup':
|
||||
return self::step_cleanup($state);
|
||||
default:
|
||||
return [
|
||||
'step' => $step,
|
||||
'label' => get_string('error', 'meetletacbmarketplace'),
|
||||
'ok' => false,
|
||||
'detail' => 'Unknown step',
|
||||
'done' => true,
|
||||
'abort' => true,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$state['failed'] = true;
|
||||
return [
|
||||
'step' => $step,
|
||||
'label' => get_string('error', 'meetletacbmarketplace'),
|
||||
'ok' => false,
|
||||
'detail' => $e->getMessage(),
|
||||
'done' => false,
|
||||
'abort' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a fresh service (API client is not serializable in session).
|
||||
*/
|
||||
protected static function service(): service {
|
||||
$service = new service();
|
||||
$service->login_admin();
|
||||
return $service;
|
||||
}
|
||||
|
||||
protected static function step_login(array &$state): array {
|
||||
$service = self::service();
|
||||
$state['ok_login'] = true;
|
||||
// Logout immediately; later steps re-login.
|
||||
$service->logout();
|
||||
return [
|
||||
'step' => 'login',
|
||||
'label' => get_string('testlogin', 'meetletacbmarketplace'),
|
||||
'ok' => true,
|
||||
'detail' => '',
|
||||
'done' => false,
|
||||
'abort' => false,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function step_folders(array &$state): array {
|
||||
$service = self::service();
|
||||
$content = $service->get_folder_scoid('content');
|
||||
$archives = $service->get_folder_scoid('forced-archives');
|
||||
$meetings = $service->get_folder_scoid('meetings');
|
||||
$state['meetings'] = $meetings;
|
||||
$ok = ($content && $archives && $meetings);
|
||||
if (!$ok) {
|
||||
$state['failed'] = true;
|
||||
}
|
||||
$service->logout();
|
||||
return [
|
||||
'step' => 'folders',
|
||||
'label' => get_string('testfolders', 'meetletacbmarketplace'),
|
||||
'ok' => (bool)$ok,
|
||||
'detail' => "content={$content} · archives={$archives} · meetings={$meetings}",
|
||||
'done' => false,
|
||||
'abort' => !$ok,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function step_user(array &$state): array {
|
||||
$service = self::service();
|
||||
$meetings = (int)($state['meetings'] ?? 0);
|
||||
if (!$meetings) {
|
||||
$meetings = (int)$service->get_folder_scoid('meetings');
|
||||
$state['meetings'] = $meetings;
|
||||
}
|
||||
$meeting = (object)[
|
||||
'name' => 'meetlet_test_' . time(),
|
||||
'starttime' => time(),
|
||||
'endtime' => time() + HOURSECS,
|
||||
'meeturl' => '',
|
||||
'templatescoid' => 0,
|
||||
];
|
||||
$scoid = $service->create_meeting($meeting, $meetings);
|
||||
$user = (object)[
|
||||
'id' => random_int(900000000, 999999999),
|
||||
'firstname' => 'MeetLet',
|
||||
'lastname' => 'Test',
|
||||
'username' => 'meetlettest' . random_int(1000, 9999),
|
||||
'email' => 'meetlet.test.' . random_int(1000, 9999) . '@example.com',
|
||||
];
|
||||
$principal = $service->ensure_user($user);
|
||||
$assigned = $service->assign_permission($principal->principalid, $scoid, service::ROLE_PRESENTER);
|
||||
$state['scoid'] = $scoid;
|
||||
$state['principalid'] = $principal->principalid;
|
||||
$state['login'] = $principal->login;
|
||||
$service->logout();
|
||||
if (!$assigned) {
|
||||
$state['failed'] = true;
|
||||
}
|
||||
return [
|
||||
'step' => 'user',
|
||||
'label' => get_string('testuser', 'meetletacbmarketplace'),
|
||||
'ok' => (bool)$assigned,
|
||||
'detail' => '',
|
||||
'done' => false,
|
||||
'abort' => false,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function step_sso(array &$state): array {
|
||||
$directok = false;
|
||||
$directdetail = '';
|
||||
try {
|
||||
$sso = new client();
|
||||
$cookie = $sso->login_external(config::get_admin_login());
|
||||
$directok = ($cookie !== '');
|
||||
$directdetail = get_string('testsso_direct_ok', 'meetletacbmarketplace');
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
$directdetail = get_string('testsso_direct_fail', 'meetletacbmarketplace', usererror::label('sso_01') . ': ' . $msg);
|
||||
}
|
||||
if ($directok && !empty($state['login'])) {
|
||||
try {
|
||||
$sso2 = new client();
|
||||
$sso2->login_external($state['login']);
|
||||
$directdetail .= ' · ' . get_string('testsso_userok', 'meetletacbmarketplace');
|
||||
} catch (Throwable $e) {
|
||||
$directdetail .= ' · ' . get_string('testsso_userwarn', 'meetletacbmarketplace');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$directok) {
|
||||
$state['failed'] = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'step' => 'sso',
|
||||
'label' => get_string('testsso', 'meetletacbmarketplace'),
|
||||
'ok' => $directok,
|
||||
'detail' => $directdetail,
|
||||
'done' => false,
|
||||
'abort' => false,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function step_cleanup(array &$state): array {
|
||||
$service = self::service();
|
||||
$client = $service->get_client();
|
||||
$ok = true;
|
||||
$detail = '';
|
||||
try {
|
||||
if (!empty($state['scoid'])) {
|
||||
$service->delete_sco((int)$state['scoid']);
|
||||
}
|
||||
if (!empty($state['principalid'])) {
|
||||
$client->request(['action' => 'principals-delete', 'principal-id' => (int)$state['principalid']]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$ok = false;
|
||||
$detail = $e->getMessage();
|
||||
}
|
||||
$service->logout();
|
||||
self::reset();
|
||||
return [
|
||||
'step' => 'cleanup',
|
||||
'label' => get_string('testmeeting', 'meetletacbmarketplace'),
|
||||
'ok' => $ok,
|
||||
'detail' => $detail,
|
||||
'done' => true,
|
||||
'abort' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Meeting content download helpers (Adobe Connect proxy).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet <info@meetlet.net>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Authenticated Moodle proxy for uploaded meeting content files.
|
||||
*/
|
||||
class content {
|
||||
/**
|
||||
* Opaque, HMAC-signed, current-user download URL.
|
||||
*/
|
||||
public static function download_url(int $userid, int $cmid, int $scoid): \moodle_url {
|
||||
return new \moodle_url('/mod/meetletacbmarketplace/download_content.php', [
|
||||
'k' => self::make_download_token($userid, $cmid, $scoid),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack and verify a content download token.
|
||||
*
|
||||
* @return array{userid:int,cmid:int,cid:int}
|
||||
*/
|
||||
public static function unpack_download_token(string $token): array {
|
||||
$token = trim($token);
|
||||
if (!preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
|
||||
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
|
||||
}
|
||||
$b64 = strtr($token, '-_', '+/');
|
||||
$pad = strlen($b64) % 4;
|
||||
if ($pad) {
|
||||
$b64 .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
$raw = base64_decode($b64, true);
|
||||
if ($raw === false || strlen($raw) < 44) {
|
||||
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
|
||||
}
|
||||
$mac = substr($raw, 0, 32);
|
||||
$payload = substr($raw, 32);
|
||||
if (strlen($payload) < 32) {
|
||||
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
|
||||
}
|
||||
|
||||
$ids = unpack('Nuid/Ncmid/Ncid/Nexp', substr($payload, 0, 16));
|
||||
$nonce = substr($payload, 16, 16);
|
||||
$userid = (int)$ids['uid'];
|
||||
$cmid = (int)$ids['cmid'];
|
||||
$cid = (int)$ids['cid'];
|
||||
$expires = (int)$ids['exp'];
|
||||
$expected = hash_hmac(
|
||||
'sha256',
|
||||
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce,
|
||||
self::download_secret(),
|
||||
true
|
||||
);
|
||||
if (!hash_equals($expected, $mac)) {
|
||||
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
|
||||
}
|
||||
if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) {
|
||||
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace');
|
||||
}
|
||||
return ['userid' => $userid, 'cmid' => $cmid, 'cid' => $cid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a content item listed under a meeting folder.
|
||||
*/
|
||||
public static function find_in_meeting(service $service, int $meetingscoid, int $scoid): ?stdClass {
|
||||
foreach ($service->list_meeting_content($meetingscoid) as $item) {
|
||||
if ((int)($item->scoid ?? 0) === $scoid) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adobe stores uploaded binaries under {url-path}/output/{filename}.
|
||||
*/
|
||||
public static function output_file_url(string $urlpath, string $filename): string {
|
||||
$filename = basename(str_replace('\\', '/', $filename));
|
||||
if ($filename === '' || strpos($filename, '/') !== false) {
|
||||
throw new \moodle_exception('filenotfound', 'error');
|
||||
}
|
||||
$base = '/' . trim($urlpath, '/');
|
||||
if ($base === '/') {
|
||||
throw new \moodle_exception('filenotfound', 'error');
|
||||
}
|
||||
return config::public_url($base . '/output/' . rawurlencode($filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a meeting content file to the browser via user SSO.
|
||||
*/
|
||||
public static function serve_download(instance $inst, int $scoid): void {
|
||||
global $USER;
|
||||
|
||||
$service = new service();
|
||||
$service->login_admin();
|
||||
$item = self::find_in_meeting($service, $inst->require_meeting(), $scoid);
|
||||
$service->logout();
|
||||
|
||||
if (!$item || empty($item->name) || empty($item->urlpath)) {
|
||||
throw new \moodle_exception('filenotfound', 'error');
|
||||
}
|
||||
|
||||
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, (int)$USER->id);
|
||||
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
|
||||
$auth = sso::provision_and_login($USER, $scoid, $permission);
|
||||
|
||||
$fileurl = self::output_file_url((string)$item->urlpath, (string)$item->name);
|
||||
$filename = clean_filename((string)$item->name);
|
||||
if ($filename === '') {
|
||||
$filename = 'content_' . $scoid;
|
||||
}
|
||||
|
||||
$tmpdir = make_request_directory();
|
||||
$tmp = $tmpdir . '/content_' . $scoid . '_' . $filename;
|
||||
self::fetch_to_path($fileurl, $auth['cookie'], $tmp);
|
||||
|
||||
\core\session\manager::write_close();
|
||||
send_temp_file($tmp, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download remote content bytes using a BREEZESESSION cookie.
|
||||
*/
|
||||
protected static function fetch_to_path(string $url, string $session, string $dest): void {
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
if ($fp === false) {
|
||||
curl_close($ch);
|
||||
throw new \moodle_exception('filenotfound', 'error');
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_COOKIE => $session !== '' ? ('BREEZESESSION=' . $session) : '',
|
||||
]);
|
||||
$ok = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$size = (int)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
|
||||
if (!$ok || $code < 200 || $code >= 400 || $size <= 0 || !is_file($dest) || filesize($dest) <= 0) {
|
||||
@unlink($dest);
|
||||
throw new \moodle_exception('filenotfound', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
protected static function make_download_token(int $userid, int $cmid, int $scoid): string {
|
||||
$expires = time() + DAYSECS * 7;
|
||||
$nonce = random_bytes(16);
|
||||
$payload = pack('N4', $userid, $cmid, $scoid, $expires) . $nonce;
|
||||
$mac = hash_hmac(
|
||||
'sha256',
|
||||
'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $scoid . ':' . $expires . '|' . $nonce,
|
||||
self::download_secret(),
|
||||
true
|
||||
);
|
||||
return rtrim(strtr(base64_encode($mac . $payload), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
protected static function download_secret(): string {
|
||||
return 'mod_meetletacbmarketplace/content/' . get_site_identifier();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Role-based feature access matrix (site + activity).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use context;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Decide which Moodle roles may use which MeetLet features.
|
||||
*
|
||||
* =====================================================================
|
||||
* ACCESS RULE (mandatory for current and future features)
|
||||
* ---------------------------------------------------------------------
|
||||
* Every user-facing MeetLet capability MUST go through
|
||||
* featureaccess::allowed() / require_feature().
|
||||
*
|
||||
* allowed() = role×feature matrix (site or activity override)
|
||||
* AND optional site/activity visibility switch when mapped.
|
||||
*
|
||||
* Do NOT grant access via Moodle capabilities alone (except the base
|
||||
* mod/meetletacbmarketplace:view gate to open the activity page).
|
||||
* Do NOT special-case admins/hosts outside the matrix.
|
||||
*
|
||||
* Adding a new feature:
|
||||
* 1) Add a constant + entry in features()
|
||||
* 2) Add accessfeature_* lang strings (fa + en)
|
||||
* 3) Set defaults in features_for_profile() / profile_for_role()
|
||||
* 4) If it also has a site “show …” switch, map it in visibility_feature()
|
||||
* 5) Gate UI and web services with allowed() / require_feature()
|
||||
* =====================================================================
|
||||
*/
|
||||
class featureaccess {
|
||||
public const JOIN = 'join';
|
||||
public const RECORDINGS = 'recordings';
|
||||
public const CONTENT = 'content';
|
||||
public const EDITRECORDING = 'editrecording';
|
||||
public const ADOBEEDITOR = 'adobeeditor';
|
||||
public const ENTERBEFOREHOST = 'enterbeforehost';
|
||||
|
||||
/** @var array<int,stdClass>|null */
|
||||
protected static $rolerecords = null;
|
||||
|
||||
/**
|
||||
* Ordered feature keys for UI.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function features(): array {
|
||||
return [
|
||||
self::JOIN,
|
||||
self::ENTERBEFOREHOST,
|
||||
self::RECORDINGS,
|
||||
self::CONTENT,
|
||||
self::EDITRECORDING,
|
||||
self::ADOBEEDITOR,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* All defined Moodle roles (shortnames), sorted like the role UI.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function role_shortnames(): array {
|
||||
$out = [];
|
||||
foreach (self::role_records() as $role) {
|
||||
if ($role->shortname !== '') {
|
||||
$out[] = $role->shortname;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,stdClass> id => role row (id, shortname, name, archetype, sortorder)
|
||||
*/
|
||||
public static function role_records(): array {
|
||||
global $DB;
|
||||
if (self::$rolerecords === null) {
|
||||
self::$rolerecords = $DB->get_records(
|
||||
'role',
|
||||
null,
|
||||
'sortorder ASC, id ASC',
|
||||
'id, shortname, name, archetype, sortorder'
|
||||
);
|
||||
if (!is_array(self::$rolerecords)) {
|
||||
self::$rolerecords = [];
|
||||
}
|
||||
}
|
||||
return self::$rolerecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cached role list (tests / after role changes in same request).
|
||||
*/
|
||||
public static function reset_role_cache(): void {
|
||||
self::$rolerecords = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile of features enabled for a given access level.
|
||||
*
|
||||
* @return string[] feature keys
|
||||
*/
|
||||
public static function features_for_profile(string $profile): array {
|
||||
switch ($profile) {
|
||||
case 'manager':
|
||||
case 'editingteacher':
|
||||
return self::features();
|
||||
case 'teacher':
|
||||
return [
|
||||
self::JOIN, self::ENTERBEFOREHOST,
|
||||
self::RECORDINGS, self::CONTENT,
|
||||
];
|
||||
case 'student':
|
||||
return [self::JOIN, self::RECORDINGS, self::CONTENT];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Moodle role to a sensible MeetLet access profile.
|
||||
*/
|
||||
public static function profile_for_role(stdClass $role): string {
|
||||
$arch = strtolower(trim((string)($role->archetype ?? '')));
|
||||
$short = strtolower(trim((string)($role->shortname ?? '')));
|
||||
$name = strtolower(trim((string)($role->name ?? '')));
|
||||
$hay = $short . ' ' . $name;
|
||||
|
||||
if (in_array($arch, ['manager', 'coursecreator'], true)
|
||||
|| in_array($short, ['manager', 'coursecreator'], true)) {
|
||||
return 'manager';
|
||||
}
|
||||
if ($arch === 'editingteacher' || $short === 'editingteacher') {
|
||||
return 'editingteacher';
|
||||
}
|
||||
if ($arch === 'teacher' || $short === 'teacher') {
|
||||
return 'teacher';
|
||||
}
|
||||
if ($arch === 'student' || $short === 'student') {
|
||||
return 'student';
|
||||
}
|
||||
if (in_array($arch, ['guest', 'user', 'frontpage'], true)
|
||||
|| in_array($short, ['guest', 'user', 'frontpage'], true)) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Custom / plugin roles — infer from shortname & name.
|
||||
if (preg_match('/\b(host|manager|admin|owner)\b/u', $hay)
|
||||
|| str_contains($hay, 'adobeconnecthost')) {
|
||||
return 'editingteacher';
|
||||
}
|
||||
if (preg_match('/\b(presenter|editingteacher|instructor|tutor|moderator)\b/u', $hay)
|
||||
|| str_contains($hay, 'adobeconnectpresenter')) {
|
||||
return 'teacher';
|
||||
}
|
||||
if (preg_match('/\b(participant|student|learner|attendee)\b/u', $hay)
|
||||
|| str_contains($hay, 'adobeconnectparticipant')) {
|
||||
return 'student';
|
||||
}
|
||||
if (preg_match('/\b(teacher|teacherrole|ostad)\b/u', $hay)) {
|
||||
return 'teacher';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in defaults by role archetype + name heuristics for custom roles.
|
||||
*
|
||||
* @return array<string,array<string,int>> role => feature => 0|1
|
||||
*/
|
||||
public static function builtin_defaults(): array {
|
||||
$alloff = array_fill_keys(self::features(), 0);
|
||||
$matrix = [];
|
||||
foreach (self::role_records() as $role) {
|
||||
$row = $alloff;
|
||||
foreach (self::features_for_profile(self::profile_for_role($role)) as $f) {
|
||||
$row[$f] = 1;
|
||||
}
|
||||
$matrix[$role->shortname] = $row;
|
||||
}
|
||||
return $matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep explicit non-empty stored rows; fill blank/missing roles from builtins.
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function normalize_fill_blank_roles($raw): array {
|
||||
$defaults = self::builtin_defaults();
|
||||
if (!is_array($raw)) {
|
||||
return $defaults;
|
||||
}
|
||||
$out = $defaults;
|
||||
foreach (self::role_shortnames() as $role) {
|
||||
if (empty($raw[$role]) || !is_array($raw[$role])) {
|
||||
continue;
|
||||
}
|
||||
$enabled = 0;
|
||||
foreach (self::features() as $feature) {
|
||||
if (!empty($raw[$role][$feature])) {
|
||||
$enabled++;
|
||||
}
|
||||
}
|
||||
if ($enabled === 0) {
|
||||
// Entirely empty row → keep sensible builtin defaults for that role.
|
||||
continue;
|
||||
}
|
||||
foreach (self::features() as $feature) {
|
||||
$out[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the activity form may show a per-activity access matrix.
|
||||
*/
|
||||
public static function activity_override_enabled(): bool {
|
||||
$raw = get_config('meetletacbmarketplace', 'activity_featureaccess');
|
||||
if ($raw === false || $raw === null || $raw === '') {
|
||||
return true;
|
||||
}
|
||||
return (bool)$raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stored matrix; missing keys fall back to builtin defaults.
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function normalize($raw): array {
|
||||
$base = self::builtin_defaults();
|
||||
if (!is_array($raw)) {
|
||||
return $base;
|
||||
}
|
||||
foreach (self::role_shortnames() as $role) {
|
||||
if (empty($raw[$role]) || !is_array($raw[$role])) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::features() as $feature) {
|
||||
if (array_key_exists($feature, $raw[$role])) {
|
||||
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build matrix from checkbox POST (missing checkbox = denied).
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function normalize_from_form($raw): array {
|
||||
$base = [];
|
||||
foreach (self::role_shortnames() as $role) {
|
||||
$base[$role] = array_fill_keys(self::features(), 0);
|
||||
if (empty($raw[$role]) || !is_array($raw[$role])) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::features() as $feature) {
|
||||
$base[$role][$feature] = !empty($raw[$role][$feature]) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide matrix from plugin config.
|
||||
*
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function site_matrix(): array {
|
||||
$raw = get_config('meetletacbmarketplace', 'featureaccess');
|
||||
if ($raw === false || $raw === null || $raw === '') {
|
||||
return self::builtin_defaults();
|
||||
}
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
return self::normalize($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective matrix for an activity (override or site).
|
||||
*
|
||||
* @param stdClass|null $activity
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function matrix_for_activity(?stdClass $activity): array {
|
||||
if ($activity && !empty($activity->featureaccess)) {
|
||||
$decoded = json_decode((string)$activity->featureaccess, true);
|
||||
if (is_array($decoded) && !empty($decoded)) {
|
||||
return self::normalize($decoded);
|
||||
}
|
||||
}
|
||||
return self::site_matrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether current/user may use a feature in this module context.
|
||||
* Decision is based solely on the role × feature matrix (no visibility switch).
|
||||
*/
|
||||
public static function user_can(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
|
||||
global $USER;
|
||||
$userid = $userid ?? (int)$USER->id;
|
||||
|
||||
// Must at least be able to view the activity page.
|
||||
$viewcap = self::view_capability_for_context($context);
|
||||
if ($viewcap && !has_capability($viewcap, $context, $userid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matrix = self::matrix_for_activity($activity);
|
||||
$roles = get_user_roles($context, $userid, true);
|
||||
foreach ($roles as $role) {
|
||||
$short = $role->shortname ?? '';
|
||||
if ($short === '' || !isset($matrix[$short])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($matrix[$short][$feature])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching matrix role grants the feature.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional site/activity visibility key for a matrix feature.
|
||||
* Null = matrix-only (no enable_* / show_* switch).
|
||||
*/
|
||||
public static function visibility_feature(string $feature): ?string {
|
||||
$map = [
|
||||
self::RECORDINGS => 'recordingstab',
|
||||
self::CONTENT => 'contenttab',
|
||||
];
|
||||
return $map[$feature] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point: matrix grant AND visibility switch (when mapped).
|
||||
*
|
||||
* Use this for tabs, buttons, links, and web-service actions.
|
||||
*/
|
||||
public static function allowed(string $feature, context $context, ?stdClass $activity = null, ?int $userid = null): bool {
|
||||
if (!\mod_meetletacbmarketplace\registration\manager::is_registered()) {
|
||||
return false;
|
||||
}
|
||||
$vis = self::visibility_feature($feature);
|
||||
if ($vis !== null && !config::show_feature($vis, $activity)) {
|
||||
return false;
|
||||
}
|
||||
return self::user_can($feature, $context, $activity, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require allowed() or throw.
|
||||
*/
|
||||
public static function require_feature(string $feature, context $context, ?stdClass $activity = null): void {
|
||||
\mod_meetletacbmarketplace\registration\manager::require_registered();
|
||||
if (self::allowed($feature, $context, $activity)) {
|
||||
return;
|
||||
}
|
||||
$vis = self::visibility_feature($feature);
|
||||
if ($vis !== null && !config::show_feature($vis, $activity)) {
|
||||
throw new \moodle_exception('featuredisabled', 'meetletacbmarketplace');
|
||||
}
|
||||
throw new \moodle_exception('nopermissions', 'error', '', self::feature_label($feature));
|
||||
}
|
||||
|
||||
public static function feature_label(string $feature): string {
|
||||
$key = 'accessfeature_' . $feature;
|
||||
if (get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
|
||||
return get_string($key, 'meetletacbmarketplace');
|
||||
}
|
||||
return $feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string> feature => label
|
||||
*/
|
||||
public static function feature_labels(): array {
|
||||
$out = [];
|
||||
foreach (self::features() as $f) {
|
||||
$out[$f] = self::feature_label($f);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string> shortname => local name
|
||||
*/
|
||||
public static function role_labels(): array {
|
||||
$out = [];
|
||||
foreach (self::role_records() as $role) {
|
||||
$out[$role->shortname] = role_get_name($role);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function view_capability_for_context(context $context): string {
|
||||
return 'mod/meetletacbmarketplace:view';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render HTML matrix table (admin or mod form).
|
||||
*
|
||||
* @param array $matrix
|
||||
* @param string $inputname e.g. featureaccess[student][join] or admin name
|
||||
* @param bool $disabled
|
||||
*/
|
||||
public static function render_matrix_html(array $matrix, string $inputname, bool $disabled = false): string {
|
||||
$matrix = self::normalize($matrix);
|
||||
$roles = self::role_labels();
|
||||
$features = self::feature_labels();
|
||||
$strtogglerow = get_string('accessmatrix_toggle_row', 'meetletacbmarketplace');
|
||||
$strtogglecol = get_string('accessmatrix_toggle_col', 'meetletacbmarketplace');
|
||||
$strall = get_string('accessmatrix_all', 'meetletacbmarketplace');
|
||||
|
||||
$html = \html_writer::start_div('meetlet-access-matrix', ['data-meetlet-access-matrix' => '1']);
|
||||
$html .= \html_writer::start_tag('table', ['class' => 'generaltable meetlet-access-table']);
|
||||
|
||||
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
|
||||
foreach ($features as $fkey => $flabel) {
|
||||
$toggle = \html_writer::tag('button', s($strall), [
|
||||
'type' => 'button',
|
||||
'class' => 'meetlet-access-toggle meetlet-access-toggle--col',
|
||||
'data-access-toggle' => 'col',
|
||||
'data-feature' => $fkey,
|
||||
'title' => $strtogglecol,
|
||||
'aria-label' => $strtogglecol . ': ' . $flabel,
|
||||
]);
|
||||
$head .= \html_writer::tag('th',
|
||||
\html_writer::div(s($flabel), 'meetlet-access-col__label') . $toggle,
|
||||
['class' => 'meetlet-access-col', 'title' => $flabel]
|
||||
);
|
||||
}
|
||||
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
|
||||
|
||||
$body = '';
|
||||
foreach ($roles as $rshort => $rlabel) {
|
||||
$rowtoggle = \html_writer::tag('button', s($strall), [
|
||||
'type' => 'button',
|
||||
'class' => 'meetlet-access-toggle meetlet-access-toggle--row',
|
||||
'data-access-toggle' => 'row',
|
||||
'data-role' => $rshort,
|
||||
'title' => $strtogglerow,
|
||||
'aria-label' => $strtogglerow . ': ' . $rlabel,
|
||||
]);
|
||||
$cells = \html_writer::tag('th',
|
||||
\html_writer::span(s($rlabel), 'meetlet-access-role') . $rowtoggle,
|
||||
['scope' => 'row', 'class' => 'meetlet-access-rowhead']
|
||||
);
|
||||
foreach (array_keys($features) as $fkey) {
|
||||
$checked = !empty($matrix[$rshort][$fkey]);
|
||||
$attrs = [
|
||||
'type' => 'checkbox',
|
||||
'name' => $inputname . '[' . $rshort . '][' . $fkey . ']',
|
||||
'value' => '1',
|
||||
'class' => 'meetlet-access-check',
|
||||
'data-role' => $rshort,
|
||||
'data-feature' => $fkey,
|
||||
];
|
||||
if ($checked) {
|
||||
$attrs['checked'] = 'checked';
|
||||
}
|
||||
if ($disabled) {
|
||||
$attrs['disabled'] = 'disabled';
|
||||
}
|
||||
$cells .= \html_writer::tag('td', \html_writer::empty_tag('input', $attrs), [
|
||||
'class' => 'meetlet-access-cell',
|
||||
]);
|
||||
}
|
||||
$body .= \html_writer::tag('tr', $cells, ['data-role-row' => $rshort]);
|
||||
}
|
||||
$html .= \html_writer::tag('tbody', $body);
|
||||
$html .= \html_writer::end_tag('table');
|
||||
$html .= \html_writer::end_div();
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy $from grants onto $to when $to is missing from a stored row.
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
public static function copy_missing_feature_from($raw, string $from, string $to): array {
|
||||
$stored = is_array($raw) ? $raw : [];
|
||||
$out = self::normalize($stored);
|
||||
foreach (self::role_shortnames() as $role) {
|
||||
if (empty($stored[$role]) || !is_array($stored[$role])) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($to, $stored[$role])) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($from, $stored[$role])) {
|
||||
$out[$role][$to] = !empty($stored[$role][$from]) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse POSTed matrix checkbox array into normalized JSON.
|
||||
*
|
||||
* @param mixed $posted
|
||||
*/
|
||||
public static function matrix_from_post($posted): string {
|
||||
return json_encode(self::normalize_from_form(is_array($posted) ? $posted : []), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Local inline SVG icons (air-gapped — no CDN).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Shared MeetLet icon SVG snippets.
|
||||
*/
|
||||
class icons {
|
||||
/**
|
||||
* Return inline SVG markup for a named icon.
|
||||
*/
|
||||
public static function svg(string $name): string {
|
||||
$svgs = [
|
||||
'play' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>',
|
||||
'zip' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm1 7V3.5L19.5 9H15zM9 4h2v2H9V4zm0 3h2v2H9V7zm0 3h2v2H9v-2zm1 3h1v4H8v-1h2v-3z"/></svg>',
|
||||
'offline' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7V3zm1 4v5l4 2-.8 1.6L11 12.2V7h2z"/></svg>',
|
||||
'mp4' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 4v9.2l3.2-3.2 1.4 1.4L12 16.4 7.4 11.8l1.4-1.4L11 13.2V4h1z"/><rect x="5" y="18" width="14" height="2" rx="1" fill="currentColor"/></svg>',
|
||||
'import' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
|
||||
'request' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6.25C4 4.45 5.45 3 7.25 3h6.5C15.55 3 17 4.45 17 6.25V8.1l4.15-2.3A1.25 1.25 0 0 1 23 6.9v10.2c0 .95-1.03 1.55-1.85 1.1L17 15.9v1.85C17 19.55 15.55 21 13.75 21h-6.5C5.45 21 4 19.55 4 17.75V6.25z"/></svg>',
|
||||
'edit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.34a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z"/></svg>',
|
||||
'adobeedit' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4h7v2H6v12h12v-5h2v7H4V4zm9 0h7v7h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H13V4z"/></svg>',
|
||||
'users' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>',
|
||||
'csv' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 13h2.5l1.25 3.5L13 13h2v6h-1.5v-3.4L12.2 19h-.9L10 15.6V19H8.5v-6H8zm7.5 0H18c.8 0 1.5.7 1.5 1.5v3c0 .8-.7 1.5-1.5 1.5h-2.5v-6zm1.5 4.5h1v-3h-1v3z"/></svg>',
|
||||
'refresh' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
|
||||
'update' => '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.6-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.6 6.36L3 16"/><path d="M3 21v-5h5"/></svg>',
|
||||
'download' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 4h2v8.17l2.59-2.58L17 11l-5 5-5-5 1.41-1.41L11 12.17V4z"/><path fill="currentColor" d="M5 18h14v2H5z"/></svg>',
|
||||
'details' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2 5 5h-4a1 1 0 0 1-1-1V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z"/></svg>',
|
||||
'attendance' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-8 14-4-4 1.41-1.41L11 14.17l6.59-6.59L19 9l-8 8z"/></svg>',
|
||||
'recordings' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>',
|
||||
'content' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>',
|
||||
'sessions' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V8h14v11zM7 10h5v5H7v-5z"/></svg>',
|
||||
];
|
||||
return $svgs[$name] ?? $svgs['play'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Activity instance helpers.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use cm_info;
|
||||
use context_module;
|
||||
use moodle_exception;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Load and resolve MeetLet instances.
|
||||
*/
|
||||
class instance {
|
||||
/** @var stdClass */
|
||||
public $record;
|
||||
/** @var stdClass */
|
||||
public $course;
|
||||
/** @var cm_info|stdClass */
|
||||
public $cm;
|
||||
/** @var context_module */
|
||||
public $context;
|
||||
/** @var int */
|
||||
public $groupid = 0;
|
||||
/** @var stdClass|null */
|
||||
public $meetingmap;
|
||||
|
||||
public static function get_from_cmid(int $cmid, int $groupid = 0): self {
|
||||
list($course, $cm) = get_course_and_cm_from_cmid($cmid, 'meetletacbmarketplace');
|
||||
return self::from_cm($cm, $course, $groupid);
|
||||
}
|
||||
|
||||
public static function get_from_instanceid(int $instanceid, int $groupid = 0): self {
|
||||
list($course, $cm) = get_course_and_cm_from_instance($instanceid, 'meetletacbmarketplace');
|
||||
return self::from_cm($cm, $course, $groupid);
|
||||
}
|
||||
|
||||
protected static function from_cm($cm, $course, int $groupid): self {
|
||||
global $DB;
|
||||
$inst = new self();
|
||||
$inst->cm = $cm;
|
||||
$inst->course = $course;
|
||||
$inst->context = context_module::instance($cm->id);
|
||||
$inst->record = $DB->get_record('meetletacbmarketplace', ['id' => $cm->instance], '*', MUST_EXIST);
|
||||
$inst->groupid = $groupid;
|
||||
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
|
||||
'instanceid' => $inst->record->id,
|
||||
'groupid' => $groupid,
|
||||
]);
|
||||
if (!$inst->meetingmap && $groupid !== 0) {
|
||||
// Fallback to no-group mapping.
|
||||
$inst->meetingmap = $DB->get_record('meetlet_marketplace_meetings', [
|
||||
'instanceid' => $inst->record->id,
|
||||
'groupid' => 0,
|
||||
]);
|
||||
}
|
||||
return $inst;
|
||||
}
|
||||
|
||||
public function meetingscoid(): int {
|
||||
return (int)($this->meetingmap->meetingscoid ?? 0);
|
||||
}
|
||||
|
||||
public function require_meeting(): int {
|
||||
$scoid = $this->meetingscoid();
|
||||
if (!$scoid) {
|
||||
throw new moodle_exception('nomeeting', 'meetletacbmarketplace');
|
||||
}
|
||||
return $scoid;
|
||||
}
|
||||
|
||||
public function auto_offline_enabled(): bool {
|
||||
return config::auto_offline_enabled($this->record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Meeting lifecycle status (schedule + live presence + wait-for-host).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Resolve whether the activity meeting can accept join requests.
|
||||
*/
|
||||
class meetingstatus {
|
||||
public const OPEN = 'open';
|
||||
public const LIVE = 'live';
|
||||
public const SCHEDULED = 'scheduled';
|
||||
public const ENDED = 'ended';
|
||||
public const WAITINGHOST = 'waitinghost';
|
||||
/** Local-only placeholder until Adobe is polled from the browser. */
|
||||
public const CHECKING = 'checking';
|
||||
|
||||
/**
|
||||
* Fast local status for first paint — no Adobe API calls.
|
||||
*
|
||||
* Join stays blocked until the client polls {@see resolve()} unless the
|
||||
* schedule alone already forbids join (and the user cannot bypass).
|
||||
*
|
||||
* @return array{
|
||||
* status:string,
|
||||
* statuslabel:string,
|
||||
* joinable:bool,
|
||||
* message:string,
|
||||
* errorcode:string,
|
||||
* starttime:int,
|
||||
* endtime:int,
|
||||
* waitforhost:bool
|
||||
* }
|
||||
*/
|
||||
public static function resolve_local(instance $inst, ?int $userid = null): array {
|
||||
global $USER;
|
||||
$userid = $userid ?? (int)$USER->id;
|
||||
$rec = $inst->record;
|
||||
$now = time();
|
||||
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
|
||||
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
|
||||
$waitforhost = config::wait_for_host($rec);
|
||||
$canbypass = featureaccess::allowed(
|
||||
featureaccess::ENTERBEFOREHOST,
|
||||
$inst->context,
|
||||
$rec,
|
||||
$userid
|
||||
);
|
||||
|
||||
$status = self::CHECKING;
|
||||
$errorcode = '';
|
||||
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
|
||||
// Never allow join before Adobe confirms room state.
|
||||
$joinable = false;
|
||||
|
||||
if ($endts > 0 && $endts < $now) {
|
||||
$status = self::ENDED;
|
||||
$message = get_string('meetingended', 'meetletacbmarketplace');
|
||||
$errorcode = 'meetingended';
|
||||
$joinable = $canbypass;
|
||||
} else if ($startts > $now) {
|
||||
$status = self::SCHEDULED;
|
||||
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
|
||||
$errorcode = 'meetingnotstarted';
|
||||
$joinable = $canbypass;
|
||||
} else if ($waitforhost && !$canbypass) {
|
||||
// Provisional: assume waiting until Adobe poll confirms a live/open room.
|
||||
$status = self::WAITINGHOST;
|
||||
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
|
||||
$errorcode = 'waitingforhost';
|
||||
$joinable = false;
|
||||
} else if (!$canbypass) {
|
||||
// Until Adobe confirms the room is live, keep Join disabled for participants.
|
||||
$status = self::CHECKING;
|
||||
$message = get_string('statuschecking_help', 'meetletacbmarketplace');
|
||||
$joinable = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'statuslabel' => self::label($status),
|
||||
'joinable' => $joinable,
|
||||
'message' => $message,
|
||||
'errorcode' => $errorcode,
|
||||
'starttime' => $startts,
|
||||
'endtime' => $endts,
|
||||
'waitforhost' => $waitforhost,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status including Adobe live presence and host-hold.
|
||||
*
|
||||
* Priority:
|
||||
* 1) Live room presence on Adobe Connect
|
||||
* 2) Moodle schedule (start / end)
|
||||
* 3) Wait-for-host (Moodle flag and/or Adobe ACL)
|
||||
*
|
||||
* @return array{
|
||||
* status:string,
|
||||
* statuslabel:string,
|
||||
* joinable:bool,
|
||||
* message:string,
|
||||
* errorcode:string,
|
||||
* starttime:int,
|
||||
* endtime:int,
|
||||
* waitforhost:bool
|
||||
* }
|
||||
*/
|
||||
public static function resolve(instance $inst, ?int $userid = null, bool $checkhost = true): array {
|
||||
global $USER;
|
||||
$userid = $userid ?? (int)$USER->id;
|
||||
$rec = $inst->record;
|
||||
$now = time();
|
||||
$startts = !empty($rec->starttime) ? (int)$rec->starttime : 0;
|
||||
$endts = !empty($rec->endtime) ? (int)$rec->endtime : 0;
|
||||
|
||||
$service = null;
|
||||
$scoid = 0;
|
||||
$islive = false;
|
||||
$adobehold = false;
|
||||
|
||||
if (config::is_configured()) {
|
||||
$scoid = $inst->meetingscoid();
|
||||
if ($scoid) {
|
||||
try {
|
||||
$service = new service();
|
||||
$service->login_admin();
|
||||
$info = $service->meeting_info($scoid);
|
||||
if ($info && !empty($info->starttime) && $startts <= 0) {
|
||||
$adobestart = strtotime((string)$info->starttime);
|
||||
if ($adobestart) {
|
||||
$startts = $adobestart;
|
||||
}
|
||||
}
|
||||
$islive = $service->meeting_is_live($scoid);
|
||||
if ($checkhost) {
|
||||
$adobehold = $service->meeting_requires_host_first($scoid);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$service = null;
|
||||
$islive = false;
|
||||
$adobehold = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$waitforhost = config::wait_for_host($rec) || $adobehold;
|
||||
$status = self::OPEN;
|
||||
$errorcode = '';
|
||||
$message = '';
|
||||
$joinable = true;
|
||||
$canbypass = featureaccess::allowed(
|
||||
featureaccess::ENTERBEFOREHOST,
|
||||
$inst->context,
|
||||
$rec,
|
||||
$userid
|
||||
);
|
||||
|
||||
if ($islive) {
|
||||
$status = self::LIVE;
|
||||
$joinable = true;
|
||||
} else if ($endts > 0 && $endts < $now) {
|
||||
$status = self::ENDED;
|
||||
if (!$canbypass) {
|
||||
$joinable = false;
|
||||
$errorcode = 'meetingended';
|
||||
$message = get_string('meetingended', 'meetletacbmarketplace');
|
||||
}
|
||||
} else if ($startts > $now) {
|
||||
$status = self::SCHEDULED;
|
||||
if (!$canbypass) {
|
||||
$joinable = false;
|
||||
$errorcode = 'meetingnotstarted';
|
||||
$message = get_string('meetingnotstarted', 'meetletacbmarketplace');
|
||||
}
|
||||
} else if (!$islive && $checkhost && !$canbypass) {
|
||||
// Nobody is in the Adobe room yet. Participants must not get a join URL —
|
||||
// Connect would only show "Meeting Host Expected". Moderators with
|
||||
// enterbeforehost may still join to start the session.
|
||||
$status = self::WAITINGHOST;
|
||||
$joinable = false;
|
||||
$errorcode = 'waitingforhost';
|
||||
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
|
||||
} else if (!$islive && $canbypass && ($waitforhost || $adobehold)) {
|
||||
// Host/moderator may enter; still show waiting-for-host for clarity.
|
||||
$status = self::WAITINGHOST;
|
||||
$joinable = true;
|
||||
$message = get_string('joinblocked_waitinghost', 'meetletacbmarketplace');
|
||||
}
|
||||
|
||||
if ($service instanceof service) {
|
||||
try {
|
||||
$service->logout();
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
|
||||
if ($inst->meetingmap) {
|
||||
try {
|
||||
if (roomsession::note_presence($inst, $islive)) {
|
||||
recordings::sync($inst);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
debugging('MeetLet room session observe failed: ' . $e->getMessage(), DEBUG_DEVELOPER);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'statuslabel' => self::label($status),
|
||||
'joinable' => $joinable,
|
||||
'message' => $message !== '' ? $message : get_string('statushelp', 'meetletacbmarketplace'),
|
||||
'errorcode' => $errorcode,
|
||||
'starttime' => $startts,
|
||||
'endtime' => $endts,
|
||||
'waitforhost' => $waitforhost,
|
||||
];
|
||||
}
|
||||
|
||||
public static function label(string $status): string {
|
||||
switch ($status) {
|
||||
case self::LIVE:
|
||||
return get_string('statuslive', 'meetletacbmarketplace');
|
||||
case self::ENDED:
|
||||
return get_string('statusended', 'meetletacbmarketplace');
|
||||
case self::SCHEDULED:
|
||||
return get_string('statusscheduled', 'meetletacbmarketplace');
|
||||
case self::WAITINGHOST:
|
||||
return get_string('statuswaitinghost', 'meetletacbmarketplace');
|
||||
case self::CHECKING:
|
||||
return get_string('statuschecking', 'meetletacbmarketplace');
|
||||
default:
|
||||
return get_string('statusopen', 'meetletacbmarketplace');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Admin HTML for the in-plugin privacy policy tab (Marketplace edition).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Renders the Privacy admin settings tab.
|
||||
*/
|
||||
class privacy_policy {
|
||||
|
||||
/**
|
||||
* Build privacy policy HTML (safe for admin settings heading).
|
||||
*/
|
||||
public static function render(): string {
|
||||
$plugin = 'meetletacbmarketplace';
|
||||
|
||||
$sections = [
|
||||
self::section('scope', 'privacy_tab_scope_title', 'privacy_tab_scope_body'),
|
||||
self::section('registration', 'privacy_tab_registration_title', 'privacy_tab_registration_body'),
|
||||
self::section('purpose', 'privacy_tab_purpose_title', 'privacy_tab_purpose_body'),
|
||||
self::section('service', 'privacy_tab_service_title', 'privacy_tab_service_body'),
|
||||
self::section('adobe', 'privacy_tab_adobe_title', 'privacy_tab_adobe_body'),
|
||||
self::section('local', 'privacy_tab_local_title', 'privacy_tab_local_body'),
|
||||
self::section('retention', 'privacy_tab_retention_title', 'privacy_tab_retention_body'),
|
||||
self::section('sharing', 'privacy_tab_sharing_title', 'privacy_tab_sharing_body'),
|
||||
self::section('control', 'privacy_tab_control_title', 'privacy_tab_control_body'),
|
||||
self::section('contact', 'privacy_tab_contact_title', 'privacy_tab_contact_body'),
|
||||
];
|
||||
|
||||
return \html_writer::div(
|
||||
\html_writer::tag('h3', get_string('privacy_tab_heading', $plugin), ['class' => 'meetlet-guide__heading']) .
|
||||
\html_writer::tag('p', get_string('privacy_tab_intro', $plugin), ['class' => 'meetlet-guide__intro']) .
|
||||
\html_writer::tag('p', get_string('privacy_tab_effective', $plugin), [
|
||||
'class' => 'meetlet-guide__intro text-muted',
|
||||
]) .
|
||||
implode('', $sections),
|
||||
'meetlet-guide meetlet-settings-card meetlet-privacy'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id Section id for anchoring.
|
||||
* @param string $titlekey Lang string key for section title.
|
||||
* @param string $bodykey Lang string key or pre-formatted body HTML.
|
||||
*/
|
||||
protected static function section(string $id, string $titlekey, string $bodykey): string {
|
||||
$plugin = 'meetletacbmarketplace';
|
||||
$title = get_string($titlekey, $plugin);
|
||||
$body = strpos($bodykey, '<') !== false
|
||||
? $bodykey
|
||||
: get_string($bodykey, $plugin);
|
||||
|
||||
return \html_writer::div(
|
||||
\html_writer::tag('h4', $title) .
|
||||
\html_writer::div($body, 'meetlet-guide__body'),
|
||||
'meetlet-guide__section',
|
||||
['id' => 'meetlet-privacy-' . $id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Recording list sync (community edition — no offline MP4 / agent).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet <info@meetlet.net>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
class recordings {
|
||||
public static function for_tab(instance $instance): array {
|
||||
self::sync($instance);
|
||||
return self::load_local($instance);
|
||||
}
|
||||
|
||||
public static function sync(instance $instance, ?service $service = null): array {
|
||||
global $DB;
|
||||
|
||||
$ownsclient = false;
|
||||
if ($service === null) {
|
||||
$service = new service();
|
||||
$service->login_admin();
|
||||
$ownsclient = true;
|
||||
}
|
||||
|
||||
$scoid = $instance->meetingscoid();
|
||||
if (!$scoid) {
|
||||
if ($ownsclient) {
|
||||
$service->logout();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
$remote = $service->list_recordings($scoid);
|
||||
$now = time();
|
||||
$out = [];
|
||||
|
||||
foreach ($remote as $rec) {
|
||||
$record = $DB->get_record('meetlet_marketplace_recordings', [
|
||||
'instanceid' => $instance->record->id,
|
||||
'recordingscoid' => $rec->scoid,
|
||||
]);
|
||||
|
||||
$zipurl = service::zip_url($rec->urlpath);
|
||||
|
||||
if (!$record) {
|
||||
$record = new stdClass();
|
||||
$record->instanceid = $instance->record->id;
|
||||
$record->meetingscoid = $scoid;
|
||||
$record->recordingscoid = $rec->scoid;
|
||||
$record->name = $rec->name;
|
||||
$record->urlpath = $rec->urlpath;
|
||||
$record->zipurl = $zipurl;
|
||||
$record->duration = $rec->duration;
|
||||
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
|
||||
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
|
||||
$record->timemodified = $now;
|
||||
$record->id = $DB->insert_record('meetlet_marketplace_recordings', $record);
|
||||
} else {
|
||||
$record->name = $rec->name;
|
||||
$record->urlpath = $rec->urlpath;
|
||||
$record->zipurl = $zipurl;
|
||||
$record->duration = $rec->duration;
|
||||
$record->timestart = $rec->datebegin ? strtotime($rec->datebegin) : 0;
|
||||
$record->timeend = $rec->dateend ? strtotime($rec->dateend) : 0;
|
||||
$record->timemodified = $now;
|
||||
$DB->update_record('meetlet_marketplace_recordings', $record);
|
||||
}
|
||||
|
||||
$record->playurl = config::public_url($rec->urlpath);
|
||||
$record->editurl = config::recording_editor_url($rec->urlpath);
|
||||
$out[] = $record;
|
||||
}
|
||||
|
||||
if ($ownsclient) {
|
||||
$service->logout();
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function load_local(instance $instance): array {
|
||||
global $DB;
|
||||
|
||||
$rows = $DB->get_records(
|
||||
'meetlet_marketplace_recordings',
|
||||
['instanceid' => $instance->record->id],
|
||||
'timestart DESC, id DESC'
|
||||
);
|
||||
foreach ($rows as $record) {
|
||||
$record->playurl = config::public_url((string)$record->urlpath);
|
||||
$record->editurl = config::recording_editor_url((string)$record->urlpath);
|
||||
$record->mp4ready = false;
|
||||
$record->mp4url = '';
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function jobstatus_label(string $status, bool $mp4ready = false): string {
|
||||
return $status !== '' ? $status : '—';
|
||||
}
|
||||
|
||||
public static function job_is_busy(string $status): bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* BBB-style participant role mapping for Adobe Connect.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use context;
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Participant list helpers (BBB pattern: all/role/user → Adobe role).
|
||||
*/
|
||||
class roles {
|
||||
public const ROLE_HOST = 'host';
|
||||
public const ROLE_PRESENTER = 'presenter';
|
||||
public const ROLE_PARTICIPANT = 'participant';
|
||||
public const MODE_INHERIT = 0;
|
||||
public const MODE_OVERRIDE = 1;
|
||||
|
||||
/**
|
||||
* Map form role id to Adobe permission-id.
|
||||
*/
|
||||
public static function to_adobe_permission(string $role): string {
|
||||
switch ($role) {
|
||||
case self::ROLE_HOST:
|
||||
return service::ROLE_HOST;
|
||||
case self::ROLE_PRESENTER:
|
||||
return service::ROLE_PRESENTER;
|
||||
default:
|
||||
return service::ROLE_PARTICIPANT;
|
||||
}
|
||||
}
|
||||
|
||||
public static function role_rank(string $role): int {
|
||||
switch ($role) {
|
||||
case self::ROLE_HOST:
|
||||
return 3;
|
||||
case self::ROLE_PRESENTER:
|
||||
return 2;
|
||||
case self::ROLE_PARTICIPANT:
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in site defaults (no owner user rule).
|
||||
*/
|
||||
public static function builtin_site_defaults(): array {
|
||||
global $DB;
|
||||
$list = [[
|
||||
'selectiontype' => 'all',
|
||||
'selectionid' => 'all',
|
||||
'role' => self::ROLE_PARTICIPANT,
|
||||
]];
|
||||
|
||||
foreach (['manager', 'coursecreator', 'editingteacher'] as $shortname) {
|
||||
if ($role = $DB->get_record('role', ['shortname' => $shortname], 'id')) {
|
||||
$list[] = [
|
||||
'selectiontype' => 'role',
|
||||
'selectionid' => (string)$role->id,
|
||||
'role' => self::ROLE_HOST,
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($role = $DB->get_record('role', ['shortname' => 'teacher'], 'id')) {
|
||||
$list[] = [
|
||||
'selectiontype' => 'role',
|
||||
'selectionid' => (string)$role->id,
|
||||
'role' => self::ROLE_PRESENTER,
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide default participant rules from plugin config (or built-in).
|
||||
*/
|
||||
public static function get_site_default_rules(): array {
|
||||
$raw = get_config('meetletacbmarketplace', 'default_participants');
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded) && !empty($decoded)) {
|
||||
return self::normalize_rules($decoded);
|
||||
}
|
||||
}
|
||||
return self::builtin_site_defaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and sanitize a participant rule list.
|
||||
*/
|
||||
public static function normalize_rules(array $rules): array {
|
||||
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
|
||||
$out = [];
|
||||
$hasall = false;
|
||||
foreach ($rules as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
$type = (string)($rule['selectiontype'] ?? '');
|
||||
$id = (string)($rule['selectionid'] ?? '');
|
||||
$role = (string)($rule['role'] ?? self::ROLE_PARTICIPANT);
|
||||
if (!in_array($role, $allowed, true)) {
|
||||
$role = self::ROLE_PARTICIPANT;
|
||||
}
|
||||
if ($type === 'all') {
|
||||
$hasall = true;
|
||||
$out[] = ['selectiontype' => 'all', 'selectionid' => 'all', 'role' => $role];
|
||||
continue;
|
||||
}
|
||||
if ($type === 'role' && ctype_digit($id) && (int)$id > 0) {
|
||||
$out[] = ['selectiontype' => 'role', 'selectionid' => $id, 'role' => $role];
|
||||
continue;
|
||||
}
|
||||
if ($type === 'user' && ctype_digit($id) && (int)$id > 0) {
|
||||
$out[] = ['selectiontype' => 'user', 'selectionid' => $id, 'role' => $role];
|
||||
}
|
||||
}
|
||||
if (!$hasall) {
|
||||
array_unshift($out, [
|
||||
'selectiontype' => 'all',
|
||||
'selectionid' => 'all',
|
||||
'role' => self::ROLE_PARTICIPANT,
|
||||
]);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default participant rules for a new / empty activity.
|
||||
*/
|
||||
public static function get_participant_list_default(context $context, ?int $ownerid = null): array {
|
||||
$list = self::get_site_default_rules();
|
||||
// Activity owner as host when enrolled (activity-only convenience rule).
|
||||
if (!empty($ownerid) && is_enrolled($context, $ownerid)) {
|
||||
$list[] = [
|
||||
'selectiontype' => 'user',
|
||||
'selectionid' => (string)$ownerid,
|
||||
'role' => self::ROLE_HOST,
|
||||
];
|
||||
}
|
||||
return self::normalize_rules($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one activity should inherit the site role mapping.
|
||||
*/
|
||||
public static function activity_inherits_defaults(?stdClass $instance): bool {
|
||||
if ($instance === null) {
|
||||
return true;
|
||||
}
|
||||
if (!property_exists($instance, 'participantsmode')) {
|
||||
return false;
|
||||
}
|
||||
return (int)$instance->participantsmode !== self::MODE_OVERRIDE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rule list → admin matrix keyed by _all / role shortname.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function matrix_from_rules(array $rules): array {
|
||||
$matrix = ['_all' => self::ROLE_PARTICIPANT];
|
||||
foreach (featureaccess::role_records() as $role) {
|
||||
if ($role->shortname !== '') {
|
||||
$matrix[$role->shortname] = self::ROLE_PARTICIPANT;
|
||||
}
|
||||
}
|
||||
$byid = featureaccess::role_records();
|
||||
foreach (self::normalize_rules($rules) as $rule) {
|
||||
$adobe = $rule['role'] ?? self::ROLE_PARTICIPANT;
|
||||
if (($rule['selectiontype'] ?? '') === 'all') {
|
||||
$matrix['_all'] = $adobe;
|
||||
} else if (($rule['selectiontype'] ?? '') === 'role') {
|
||||
$rid = (int)($rule['selectionid'] ?? 0);
|
||||
if (isset($byid[$rid]) && $byid[$rid]->shortname !== '') {
|
||||
$matrix[$byid[$rid]->shortname] = $adobe;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert posted admin matrix → JSON rule list for config storage.
|
||||
*
|
||||
* @param array $data role shortname / _all => adobe role
|
||||
*/
|
||||
public static function rules_json_from_matrix_post(array $data): string {
|
||||
$allowed = [self::ROLE_HOST, self::ROLE_PRESENTER, self::ROLE_PARTICIPANT];
|
||||
$allrole = (string)($data['_all'] ?? self::ROLE_PARTICIPANT);
|
||||
if (!in_array($allrole, $allowed, true)) {
|
||||
$allrole = self::ROLE_PARTICIPANT;
|
||||
}
|
||||
$list = [[
|
||||
'selectiontype' => 'all',
|
||||
'selectionid' => 'all',
|
||||
'role' => $allrole,
|
||||
]];
|
||||
foreach (featureaccess::role_records() as $role) {
|
||||
if ($role->shortname === '') {
|
||||
continue;
|
||||
}
|
||||
$adobe = (string)($data[$role->shortname] ?? self::ROLE_PARTICIPANT);
|
||||
if (!in_array($adobe, $allowed, true)) {
|
||||
$adobe = self::ROLE_PARTICIPANT;
|
||||
}
|
||||
// Skip redundant participant rows that match the “everyone” default.
|
||||
if ($adobe === $allrole && $adobe === self::ROLE_PARTICIPANT) {
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
'selectiontype' => 'role',
|
||||
'selectionid' => (string)$role->id,
|
||||
'role' => $adobe,
|
||||
];
|
||||
}
|
||||
return json_encode(self::normalize_rules($list), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adobe Connect role options for selects/radios.
|
||||
*
|
||||
* @return array<string,string> value => label
|
||||
*/
|
||||
public static function adobe_role_labels(): array {
|
||||
return [
|
||||
self::ROLE_HOST => get_string('role_host', 'meetletacbmarketplace'),
|
||||
self::ROLE_PRESENTER => get_string('role_presenter', 'meetletacbmarketplace'),
|
||||
self::ROLE_PARTICIPANT => get_string('role_participant', 'meetletacbmarketplace'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render site admin Moodle-role → Adobe-role mapping table.
|
||||
*/
|
||||
public static function render_site_mapping_html(array $rules, string $inputname): string {
|
||||
$matrix = self::matrix_from_rules($rules);
|
||||
$adobelabels = self::adobe_role_labels();
|
||||
$rolelabels = featureaccess::role_labels();
|
||||
|
||||
$html = \html_writer::start_div('meetlet-rolemapping', ['data-meetlet-rolemapping' => '1']);
|
||||
$html .= \html_writer::start_tag('table', [
|
||||
'class' => 'generaltable meetlet-access-table meetlet-rolemapping-table',
|
||||
]);
|
||||
|
||||
$head = \html_writer::tag('th', get_string('accessrole', 'meetletacbmarketplace'));
|
||||
$head .= \html_writer::tag('th', get_string('adoberole', 'meetletacbmarketplace'));
|
||||
$html .= \html_writer::tag('thead', \html_writer::tag('tr', $head));
|
||||
|
||||
$body = '';
|
||||
// Everyone row first.
|
||||
$body .= self::mapping_row_html(
|
||||
get_string('participant_type_all', 'meetletacbmarketplace'),
|
||||
$inputname . '[_all]',
|
||||
$matrix['_all'] ?? self::ROLE_PARTICIPANT,
|
||||
$adobelabels,
|
||||
'_all'
|
||||
);
|
||||
foreach ($rolelabels as $shortname => $label) {
|
||||
$body .= self::mapping_row_html(
|
||||
$label,
|
||||
$inputname . '[' . $shortname . ']',
|
||||
$matrix[$shortname] ?? self::ROLE_PARTICIPANT,
|
||||
$adobelabels,
|
||||
$shortname
|
||||
);
|
||||
}
|
||||
$html .= \html_writer::tag('tbody', $body);
|
||||
$html .= \html_writer::end_tag('table');
|
||||
$html .= \html_writer::end_div();
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mapping table row with Adobe role radios.
|
||||
*
|
||||
* @param array<string,string> $adobelabels
|
||||
*/
|
||||
protected static function mapping_row_html(
|
||||
string $moodlelabel,
|
||||
string $inputname,
|
||||
string $selected,
|
||||
array $adobelabels,
|
||||
string $rowkey
|
||||
): string {
|
||||
$cells = \html_writer::tag('th', s($moodlelabel), [
|
||||
'scope' => 'row',
|
||||
'class' => 'meetlet-access-rowhead',
|
||||
]);
|
||||
$chips = '';
|
||||
foreach ($adobelabels as $value => $label) {
|
||||
$id = 'meetlet-map-' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $rowkey . '-' . $value);
|
||||
$attrs = [
|
||||
'type' => 'radio',
|
||||
'name' => $inputname,
|
||||
'id' => $id,
|
||||
'value' => $value,
|
||||
'class' => 'meetlet-rolechip__input',
|
||||
];
|
||||
if ($selected === $value) {
|
||||
$attrs['checked'] = 'checked';
|
||||
}
|
||||
$chips .= \html_writer::tag('label',
|
||||
\html_writer::empty_tag('input', $attrs) .
|
||||
\html_writer::span(s($label), 'meetlet-rolechip__text'),
|
||||
[
|
||||
'class' => 'meetlet-rolechip',
|
||||
'for' => $id,
|
||||
'data-role-value' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
$group = \html_writer::div($chips, 'meetlet-rolechip-group', [
|
||||
'role' => 'radiogroup',
|
||||
'aria-label' => $moodlelabel,
|
||||
]);
|
||||
$cells .= \html_writer::tag('td', $group, ['class' => 'meetlet-rolemapping-cell']);
|
||||
return \html_writer::tag('tr', $cells, ['data-role-row' => $rowkey]);
|
||||
}
|
||||
|
||||
public static function get_participant_list(?stdClass $instance, context $context): array {
|
||||
global $USER;
|
||||
if ($instance === null || self::activity_inherits_defaults($instance)
|
||||
|| empty($instance->participants) || $instance->participants === '[]') {
|
||||
return self::get_participant_list_default($context, $USER->id);
|
||||
}
|
||||
$rules = json_decode($instance->participants, true);
|
||||
if (!is_array($rules) || empty($rules)) {
|
||||
// Legacy format support.
|
||||
if (isset($rules['hosts']) || (is_array($rules) && isset($rules[0]) === false && isset($rules['participants']))) {
|
||||
return self::legacy_to_rules($rules);
|
||||
}
|
||||
return self::get_participant_list_default($context, $USER->id);
|
||||
}
|
||||
// Detect legacy associative map.
|
||||
if (isset($rules['hosts']) || isset($rules['presenters'])) {
|
||||
return self::legacy_to_rules($rules);
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected static function legacy_to_rules(array $map): array {
|
||||
$list = [[
|
||||
'selectiontype' => 'all',
|
||||
'selectionid' => 'all',
|
||||
'role' => self::ROLE_PARTICIPANT,
|
||||
]];
|
||||
foreach ($map['hosts'] ?? [] as $id) {
|
||||
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_HOST];
|
||||
}
|
||||
foreach ($map['presenters'] ?? [] as $id) {
|
||||
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PRESENTER];
|
||||
}
|
||||
foreach ($map['participants'] ?? [] as $id) {
|
||||
$list[] = ['selectiontype' => 'role', 'selectionid' => (string)$id, 'role' => self::ROLE_PARTICIPANT];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function get_participant_data(context $context, ?stdClass $instance = null): array {
|
||||
$data = [
|
||||
'all' => [
|
||||
'name' => get_string('participant_type_all', 'meetletacbmarketplace'),
|
||||
'children' => [],
|
||||
],
|
||||
'role' => [
|
||||
'name' => get_string('participant_type_role', 'meetletacbmarketplace'),
|
||||
'children' => self::get_roles_select($context),
|
||||
],
|
||||
'user' => [
|
||||
'name' => get_string('participant_type_user', 'meetletacbmarketplace'),
|
||||
'children' => [],
|
||||
],
|
||||
];
|
||||
if ($instance) {
|
||||
$data['user']['children'] = self::get_users_array($context);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected static function get_roles_select(context $context): array {
|
||||
$roles = get_viewable_roles($context);
|
||||
$out = [];
|
||||
foreach ($roles as $id => $name) {
|
||||
$out[$id] = ['id' => $id, 'name' => $name];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected static function get_users_array(context $context): array {
|
||||
$users = get_enrolled_users($context, '', 0, 'u.id, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic, u.middlename, u.alternatename');
|
||||
$out = [];
|
||||
foreach ($users as $u) {
|
||||
$out[$u->id] = ['id' => $u->id, 'name' => fullname($u)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function get_participant_selection_data(): array {
|
||||
return [
|
||||
'type_options' => [
|
||||
'all' => get_string('participant_type_all', 'meetletacbmarketplace'),
|
||||
'role' => get_string('participant_type_role', 'meetletacbmarketplace'),
|
||||
'user' => get_string('participant_type_user', 'meetletacbmarketplace'),
|
||||
],
|
||||
'type_selected' => 'role',
|
||||
'options' => ['all' => '---------------'],
|
||||
'selected' => 'all',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Adobe permission for user from participant rules.
|
||||
*
|
||||
* @return string|null Adobe permission-id or null if denied
|
||||
*/
|
||||
public static function resolve_adobe_role(stdClass $instance, context $context, int $userid): ?string {
|
||||
if (has_capability('mod/meetletacbmarketplace:host', $context, $userid)) {
|
||||
return service::ROLE_HOST;
|
||||
}
|
||||
if (has_capability('mod/meetletacbmarketplace:presenter', $context, $userid)) {
|
||||
return service::ROLE_PRESENTER;
|
||||
}
|
||||
|
||||
$rules = self::get_participant_list($instance, $context);
|
||||
$userroles = get_user_roles($context, $userid);
|
||||
$roleids = [];
|
||||
foreach ($userroles as $r) {
|
||||
$roleids[] = (int)$r->roleid;
|
||||
}
|
||||
|
||||
$best = null;
|
||||
$bestrank = 0;
|
||||
foreach ($rules as $rule) {
|
||||
$match = false;
|
||||
if (($rule['selectiontype'] ?? '') === 'all') {
|
||||
$match = true;
|
||||
} else if (($rule['selectiontype'] ?? '') === 'role') {
|
||||
$match = in_array((int)$rule['selectionid'], $roleids, true);
|
||||
} else if (($rule['selectiontype'] ?? '') === 'user') {
|
||||
$match = ((int)$rule['selectionid'] === (int)$userid);
|
||||
}
|
||||
if (!$match) {
|
||||
continue;
|
||||
}
|
||||
$role = $rule['role'] ?? self::ROLE_PARTICIPANT;
|
||||
$rank = self::role_rank($role);
|
||||
if ($rank > $bestrank) {
|
||||
$bestrank = $rank;
|
||||
$best = $role;
|
||||
}
|
||||
}
|
||||
|
||||
if ($best === null) {
|
||||
return !empty($instance->meetingpublic) ? service::ROLE_PARTICIPANT : null;
|
||||
}
|
||||
return self::to_adobe_permission($best);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Track Adobe Connect room occupancy for auto MP4 after the class actually ends.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Persist live/closed room state on meetlet_marketplace_meetings and decide when to sweep recordings.
|
||||
*/
|
||||
class roomsession {
|
||||
/** Seconds to keep looking for new recordings after the room empties. */
|
||||
public const SWEEP_GRACE = 20 * MINSECS;
|
||||
|
||||
/**
|
||||
* Record occupancy. Returns true for an opportunistic queue (just closed / grace window).
|
||||
*
|
||||
* Open = at least one person in the Adobe room. Closed = it was open, then empty.
|
||||
* Moodle schedule endtime is intentionally ignored here.
|
||||
* Cron retries leftover recordings forever while the room is empty; this return
|
||||
* only gates page-view / join-status opportunistic queues during the grace window.
|
||||
*/
|
||||
public static function note_presence(instance $instance, bool $islive): bool {
|
||||
global $DB;
|
||||
|
||||
$map = $instance->meetingmap;
|
||||
if (!$map || empty($map->id)) {
|
||||
return false;
|
||||
}
|
||||
$row = $DB->get_record('meetlet_marketplace_meetings', ['id' => (int)$map->id]);
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$waslive = !empty($row->islive);
|
||||
|
||||
if ($islive) {
|
||||
$row->islive = 1;
|
||||
$row->liveat = $now;
|
||||
$DB->update_record('meetlet_marketplace_meetings', $row);
|
||||
$instance->meetingmap = $row;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($waslive) {
|
||||
$row->islive = 0;
|
||||
$row->closedat = $now;
|
||||
$row->sweepuntil = $now + self::sweep_window();
|
||||
}
|
||||
|
||||
$insweep = !empty($row->sweepuntil) && $now <= (int)$row->sweepuntil;
|
||||
$due = $insweep && $instance->auto_offline_enabled()
|
||||
&& ($now - (int)($row->sweptat ?? 0) >= 45);
|
||||
|
||||
if ($due) {
|
||||
$row->sweptat = $now;
|
||||
}
|
||||
if ($waslive || $due) {
|
||||
$DB->update_record('meetlet_marketplace_meetings', $row);
|
||||
}
|
||||
$instance->meetingmap = $row;
|
||||
return $due;
|
||||
}
|
||||
|
||||
protected static function sweep_window(): int {
|
||||
$mins = config::cron_interval_minutes();
|
||||
return max(self::SWEEP_GRACE, $mins * MINSECS * 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?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';
|
||||
|
||||
$match = \html_writer::tag('h4', get_string('serverguide_match_title', $plugin)) .
|
||||
\html_writer::tag('p', get_string('serverguide_match_intro', $plugin)) .
|
||||
self::table([
|
||||
[get_string('admin_httpauth', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_httpauth', $plugin)))],
|
||||
[get_string('host', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_apiurl', $plugin)))],
|
||||
[get_string('meethost', $plugin), \html_writer::tag('code', s(get_string('serverguide_placeholder_meethost', $plugin)))],
|
||||
]);
|
||||
|
||||
$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)), [
|
||||
'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($match, 'meetlet-guide__section meetlet-guide__section--match') .
|
||||
$customini .
|
||||
$webxml .
|
||||
$serverxml .
|
||||
$upgrade .
|
||||
$codes,
|
||||
'meetlet-guide meetlet-settings-card'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Help-code table so an admin can match a student-reported ML- code.
|
||||
*/
|
||||
protected static function errorcodes_section(): string {
|
||||
$plugin = 'meetletacbmarketplace';
|
||||
$head = \html_writer::tag('tr',
|
||||
\html_writer::tag('th', get_string('serverguide_errorcodes_col_code', $plugin)) .
|
||||
\html_writer::tag('th', get_string('serverguide_errorcodes_col_meaning', $plugin)) .
|
||||
\html_writer::tag('th', get_string('serverguide_errorcodes_col_fix', $plugin))
|
||||
);
|
||||
$body = '';
|
||||
foreach (usererror::catalog() as $item) {
|
||||
$body .= \html_writer::tag('tr',
|
||||
\html_writer::tag('td', \html_writer::tag('code', s($item['label'])), ['class' => 'meetlet-guide__code-cell']) .
|
||||
\html_writer::tag('td', get_string($item['titlekey'], $plugin)) .
|
||||
\html_writer::tag('td', get_string($item['fixkey'], $plugin))
|
||||
);
|
||||
}
|
||||
$table = \html_writer::tag(
|
||||
'table',
|
||||
\html_writer::tag('thead', $head) . \html_writer::tag('tbody', $body),
|
||||
['class' => 'meetlet-guide__table generaltable']
|
||||
);
|
||||
return self::section(
|
||||
'errorcodes',
|
||||
get_string('serverguide_errorcodes_title', $plugin),
|
||||
\html_writer::tag('p', get_string('serverguide_errorcodes_intro', $plugin)) . $table
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id Section id for anchoring.
|
||||
* @param string $title Section title.
|
||||
* @param string $bodyhtml Trusted HTML from lang strings (admin-only).
|
||||
*/
|
||||
protected static function section(string $id, string $title, string $bodyhtml): string {
|
||||
return \html_writer::div(
|
||||
\html_writer::tag('h4', $title) .
|
||||
\html_writer::div($bodyhtml, 'meetlet-guide__body'),
|
||||
'meetlet-guide__section',
|
||||
['id' => 'meetlet-guide-' . $id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{0:string,1:string}> $rows Label / value (value may contain HTML).
|
||||
*/
|
||||
protected static function table(array $rows): string {
|
||||
$trs = '';
|
||||
foreach ($rows as $row) {
|
||||
$trs .= \html_writer::tag(
|
||||
'tr',
|
||||
\html_writer::tag('th', $row[0], ['scope' => 'row']) .
|
||||
\html_writer::tag('td', $row[1])
|
||||
);
|
||||
}
|
||||
return \html_writer::tag(
|
||||
'table',
|
||||
\html_writer::tag('tbody', $trs),
|
||||
['class' => 'meetlet-guide__table generaltable']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Adobe Connect SSO helpers for the current Moodle user.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet <info@meetlet.net>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use mod_meetletacbmarketplace\api\client;
|
||||
use mod_meetletacbmarketplace\api\service;
|
||||
use moodle_exception;
|
||||
use stdClass;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Build Connect logins and BREEZESESSION for Moodle users.
|
||||
*/
|
||||
class sso {
|
||||
/**
|
||||
* Ordered Connect login candidates for a Moodle user.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function login_candidates(stdClass $user, ?stdClass $adobeuser = null): array {
|
||||
$reserved = [
|
||||
'administrators', 'authors', 'everyone', 'live-admins', 'admins',
|
||||
'course-admins', 'event-administrators', 'event-managers', 'learners',
|
||||
'seminar-administrators',
|
||||
// Generic Moodle accounts must never be sent as Connect SSO identities.
|
||||
'admin', 'guest', 'root', 'administrator', 'moodle',
|
||||
];
|
||||
$candidates = [];
|
||||
// Prefer ext-login (refresh_principal_login) then stable mlu{id}, then legacy logins.
|
||||
$seed = [];
|
||||
if (!empty($adobeuser->login)) {
|
||||
$seed[] = (string)$adobeuser->login;
|
||||
}
|
||||
if (!empty($adobeuser->internallogin)) {
|
||||
$seed[] = (string)$adobeuser->internallogin;
|
||||
}
|
||||
$seed[] = config::connect_login($user);
|
||||
foreach (array_merge($seed, config::legacy_connect_logins($user)) as $candidate) {
|
||||
$candidate = trim((string)$candidate);
|
||||
if ($candidate === '' || in_array(strtolower($candidate), $reserved, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($candidate, $candidates, true)) {
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a BREEZESESSION cookie via HTTP header SSO for the given logins.
|
||||
*
|
||||
* @param string[] $logincandidates
|
||||
*/
|
||||
public static function breeze_session(array $logincandidates): string {
|
||||
\mod_meetletacbmarketplace\registration\manager::require_registered();
|
||||
|
||||
$cookie = '';
|
||||
$lasterror = null;
|
||||
$primary = $logincandidates[0] ?? '';
|
||||
|
||||
foreach ($logincandidates as $logintry) {
|
||||
try {
|
||||
$sso = new client();
|
||||
$cookie = $sso->login_external($logintry);
|
||||
if ($cookie !== '') {
|
||||
return $cookie;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$lasterror = $e;
|
||||
}
|
||||
}
|
||||
$parsed = $lasterror ? usererror::from_throwable($lasterror) : ['helpid' => 'sso_01', 'facts' => [], 'xml' => ''];
|
||||
$facts = $parsed['facts'];
|
||||
if ($primary !== '' && empty($facts['login'])) {
|
||||
$facts['login'] = $primary;
|
||||
}
|
||||
$ip = self::guess_egress_ip();
|
||||
if ($ip !== '') {
|
||||
$facts['egress_ip'] = $ip;
|
||||
}
|
||||
$helpid = $parsed['helpid'];
|
||||
if ($lasterror instanceof moodle_exception && $lasterror->errorcode === 'agentrequestfailed') {
|
||||
$helpid = 'sso_02';
|
||||
$facts['agent'] = is_scalar($lasterror->a) ? (string)$lasterror->a : $lasterror->getMessage();
|
||||
}
|
||||
if ($lasterror instanceof usererror && $lasterror->helpid === 'sso_01') {
|
||||
$helpid = 'sso_01';
|
||||
}
|
||||
throw new usererror($helpid, 'errorhttpauth', $facts, $parsed['xml']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort public IP Adobe will see for Moodle API calls.
|
||||
*/
|
||||
public static function guess_egress_ip(): string {
|
||||
try {
|
||||
$curl = new \curl(['ignoresecurity' => true]);
|
||||
$curl->setopt(['CURLOPT_TIMEOUT' => 5]);
|
||||
$raw = $curl->get('https://api.ipify.org');
|
||||
$ip = trim((string)$raw);
|
||||
if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) {
|
||||
return $ip;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore.
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Connect user exists, grant permission on a SCO (and parent meeting for
|
||||
* recordings), then SSO login.
|
||||
*
|
||||
* @return array{cookie:string,adobeuser:stdClass}
|
||||
*/
|
||||
public static function provision_and_login(stdClass $user, int $scoid, string $permission): array {
|
||||
\mod_meetletacbmarketplace\registration\manager::require_registered();
|
||||
|
||||
$service = new service();
|
||||
$service->login_admin();
|
||||
$adobeuser = $service->ensure_user($user);
|
||||
$adobeuser = $service->refresh_principal_login($adobeuser);
|
||||
if (trim((string)($adobeuser->login ?? '')) === '') {
|
||||
throw new usererror('usr_01', 'errorcreateuser', ['login' => config::connect_login($user)]);
|
||||
}
|
||||
|
||||
$targets = [$scoid];
|
||||
try {
|
||||
$info = $service->get_client()->request([
|
||||
'action' => 'sco-info',
|
||||
'sco-id' => $scoid,
|
||||
]);
|
||||
$folder = (int)($info->sco['folder-id'] ?? 0);
|
||||
// Parent meeting folder ACL is required for recordings and uploaded content.
|
||||
if ($folder > 0) {
|
||||
$targets[] = $folder;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Best-effort enrichment only.
|
||||
}
|
||||
foreach (array_unique($targets) as $aclid) {
|
||||
if ($aclid > 0) {
|
||||
$service->assign_permission((int)$adobeuser->principalid, (int)$aclid, $permission);
|
||||
}
|
||||
}
|
||||
$service->logout();
|
||||
|
||||
$cookie = self::breeze_session(self::login_candidates($user, $adobeuser));
|
||||
return ['cookie' => $cookie, 'adobeuser' => $adobeuser];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Audience-aware MeetLet errors (student vs teacher/admin) with help codes.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\local;
|
||||
|
||||
use moodle_exception;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Structured plugin error with a public help code (ML-…) and staff-only detail.
|
||||
*/
|
||||
class usererror extends moodle_exception {
|
||||
/** @var string Internal catalog id, e.g. sso_01 */
|
||||
public string $helpid = 'err_01';
|
||||
|
||||
/** @var array<string, string> Staff fact rows (login, adobe_code, …) */
|
||||
public array $facts = [];
|
||||
|
||||
/** @var string Optional Adobe XML / raw snippet */
|
||||
public string $xml = '';
|
||||
|
||||
/**
|
||||
* @param string $helpid Catalog id (sso_01, api_01, …)
|
||||
* @param string $errorcode Moodle lang string id
|
||||
* @param array<string, string> $facts
|
||||
* @param string $xml
|
||||
*/
|
||||
public function __construct(string $helpid, string $errorcode, array $facts = [], string $xml = '') {
|
||||
$this->helpid = $helpid !== '' ? $helpid : 'err_01';
|
||||
$this->facts = $facts;
|
||||
$this->xml = trim($xml);
|
||||
$debug = $this->facts;
|
||||
if ($this->xml !== '') {
|
||||
$debug['xml'] = $this->xml;
|
||||
}
|
||||
parent::__construct($errorcode, 'meetletacbmarketplace', '', null, json_encode($debug, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Teachers, managers, and site admins see technical detail; students do not.
|
||||
*/
|
||||
public static function is_staff(\context $context, ?int $userid = null): bool {
|
||||
return has_any_capability([
|
||||
'moodle/site:config',
|
||||
'moodle/course:update',
|
||||
'moodle/course:manageactivities',
|
||||
'mod/meetletacbmarketplace:addinstance',
|
||||
'mod/meetletacbmarketplace:host',
|
||||
'mod/meetletacbmarketplace:presenter',
|
||||
], $context, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Help-code catalog shown in the server-setup guide.
|
||||
*
|
||||
* @return array<string, array{label:string,titlekey:string,fixkey:string}>
|
||||
*/
|
||||
public static function catalog(): array {
|
||||
return [
|
||||
'sso_01' => [
|
||||
'label' => 'ML-SSO-01',
|
||||
'titlekey' => 'helpcode_sso_01_title',
|
||||
'fixkey' => 'helpcode_sso_01_fix',
|
||||
],
|
||||
'api_01' => [
|
||||
'label' => 'ML-API-01',
|
||||
'titlekey' => 'helpcode_api_01_title',
|
||||
'fixkey' => 'helpcode_api_01_fix',
|
||||
],
|
||||
'api_02' => [
|
||||
'label' => 'ML-API-02',
|
||||
'titlekey' => 'helpcode_api_02_title',
|
||||
'fixkey' => 'helpcode_api_02_fix',
|
||||
],
|
||||
'usr_01' => [
|
||||
'label' => 'ML-USR-01',
|
||||
'titlekey' => 'helpcode_usr_01_title',
|
||||
'fixkey' => 'helpcode_usr_01_fix',
|
||||
],
|
||||
'mtg_01' => [
|
||||
'label' => 'ML-MTG-01',
|
||||
'titlekey' => 'helpcode_mtg_01_title',
|
||||
'fixkey' => 'helpcode_mtg_01_fix',
|
||||
],
|
||||
'cfg_01' => [
|
||||
'label' => 'ML-CFG-01',
|
||||
'titlekey' => 'helpcode_cfg_01_title',
|
||||
'fixkey' => 'helpcode_cfg_01_fix',
|
||||
],
|
||||
'err_01' => [
|
||||
'label' => 'ML-ERR-01',
|
||||
'titlekey' => 'helpcode_err_01_title',
|
||||
'fixkey' => 'helpcode_err_01_fix',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public label for a catalog id (ML-SSO-01).
|
||||
*/
|
||||
public static function label(string $helpid): string {
|
||||
$cat = self::catalog();
|
||||
return $cat[$helpid]['label'] ?? 'ML-ERR-01';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajax payload for join / recording launch failures.
|
||||
*
|
||||
* @return array{code:string,message:string,detailhtml:string,staff:bool}
|
||||
*/
|
||||
public static function payload(\Throwable $e, \context $context): array {
|
||||
if (self::is_gate_exception($e)) {
|
||||
return [
|
||||
'code' => '',
|
||||
'message' => $e->getMessage(),
|
||||
'detailhtml' => '',
|
||||
'staff' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$parsed = self::from_throwable($e);
|
||||
$label = self::label($parsed['helpid']);
|
||||
$staff = self::is_staff($context);
|
||||
|
||||
if (!$staff) {
|
||||
return [
|
||||
'code' => $label,
|
||||
'message' => get_string('error_user_unavailable', 'meetletacbmarketplace', $label),
|
||||
'detailhtml' => '',
|
||||
'staff' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $label,
|
||||
'message' => self::staff_title($parsed['helpid'], $e),
|
||||
'detailhtml' => self::render_staff_html($parsed),
|
||||
'staff' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* WS success envelope.
|
||||
*
|
||||
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
|
||||
*/
|
||||
public static function ws_ok(string $url): array {
|
||||
return [
|
||||
'url' => $url,
|
||||
'ok' => true,
|
||||
'helpcode' => '',
|
||||
'message' => '',
|
||||
'detailhtml' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* WS failure envelope (HTTP 200 so the UI can format HTML).
|
||||
*
|
||||
* @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string}
|
||||
*/
|
||||
public static function ws_fail(\Throwable $e, \context $context): array {
|
||||
$p = self::payload($e, $context);
|
||||
return [
|
||||
'url' => '',
|
||||
'ok' => false,
|
||||
'helpcode' => $p['code'],
|
||||
'message' => $p['message'],
|
||||
'detailhtml' => $p['detailhtml'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{url: \core_external\external_value, ok: \core_external\external_value, helpcode: \core_external\external_value, message: \core_external\external_value, detailhtml: \core_external\external_value}
|
||||
*/
|
||||
public static function ws_url_returns(): array {
|
||||
return [
|
||||
'url' => new \core_external\external_value(PARAM_RAW, 'URL with session when ok'),
|
||||
'ok' => new \core_external\external_value(PARAM_BOOL, 'False when the session could not be opened'),
|
||||
'helpcode' => new \core_external\external_value(PARAM_TEXT, 'Public help code, e.g. ML-SSO-01'),
|
||||
'message' => new \core_external\external_value(PARAM_RAW, 'Audience-appropriate short message'),
|
||||
'detailhtml' => new \core_external\external_value(PARAM_RAW, 'Staff-only HTML; empty for students'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print an Adobe XML snippet for a code box.
|
||||
*/
|
||||
public static function format_xml(string $raw): string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
$start = strpos($raw, '<?xml');
|
||||
if ($start === false) {
|
||||
$start = strpos($raw, '<results');
|
||||
}
|
||||
$chunk = $start === false ? $raw : substr($raw, $start);
|
||||
if (preg_match('/<results\b[\s\S]*?<\/results>/i', $chunk, $m)) {
|
||||
$chunk = $m[0];
|
||||
} else {
|
||||
$chunk = substr($chunk, 0, 1200);
|
||||
}
|
||||
$dom = new \DOMDocument();
|
||||
$dom->preserveWhiteSpace = false;
|
||||
$dom->formatOutput = true;
|
||||
if (@$dom->loadXML($chunk)) {
|
||||
$out = $dom->saveXML($dom->documentElement);
|
||||
return $out !== false ? trim($out) : trim($chunk);
|
||||
}
|
||||
return trim($chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{helpid:string,facts:array<string,string>,xml:string}
|
||||
*/
|
||||
public static function from_throwable(\Throwable $e): array {
|
||||
if ($e instanceof self) {
|
||||
return [
|
||||
'helpid' => $e->helpid,
|
||||
'facts' => $e->facts,
|
||||
'xml' => $e->xml,
|
||||
];
|
||||
}
|
||||
|
||||
$code = $e instanceof moodle_exception ? (string)$e->errorcode : '';
|
||||
$helpid = self::helpid_from_moodle($code);
|
||||
$facts = [];
|
||||
$xml = '';
|
||||
|
||||
if ($e instanceof moodle_exception && is_scalar($e->a) && (string)$e->a !== '') {
|
||||
$legacy = self::parse_legacy_detail((string)$e->a);
|
||||
$facts = $legacy['facts'];
|
||||
$xml = $legacy['xml'];
|
||||
}
|
||||
|
||||
return ['helpid' => $helpid, 'facts' => $facts, 'xml' => $xml];
|
||||
}
|
||||
|
||||
protected static function is_gate_exception(\Throwable $e): bool {
|
||||
if (!$e instanceof moodle_exception) {
|
||||
return false;
|
||||
}
|
||||
return in_array($e->errorcode, [
|
||||
'meetingended',
|
||||
'meetingnotstarted',
|
||||
'waitingforhost',
|
||||
'notparticipant',
|
||||
'usergrouprequired',
|
||||
'joinblocked_waitinghost',
|
||||
], true);
|
||||
}
|
||||
|
||||
protected static function helpid_from_moodle(string $errorcode): string {
|
||||
$map = [
|
||||
'errorhttpauth' => 'sso_01',
|
||||
'erroremptyxml' => 'api_01',
|
||||
'errorlogin' => 'api_02',
|
||||
'errorcreateuser' => 'usr_01',
|
||||
'errorcreatemeeting' => 'mtg_01',
|
||||
'nomeeting' => 'mtg_01',
|
||||
'errorexistingsco' => 'mtg_01',
|
||||
'notconfigured' => 'cfg_01',
|
||||
];
|
||||
return $map[$errorcode] ?? 'err_01';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{facts:array<string,string>,xml:string}
|
||||
*/
|
||||
protected static function parse_legacy_detail(string $detail): array {
|
||||
$facts = [];
|
||||
$xml = '';
|
||||
if (preg_match('/login=([^\s·]+)/u', $detail, $m)) {
|
||||
$facts['login'] = $m[1];
|
||||
}
|
||||
if (preg_match('/\bcode=([^\s·]+)/u', $detail, $m)) {
|
||||
$facts['adobe_code'] = $m[1];
|
||||
}
|
||||
if (preg_match('/subcode=([^\s·]+)/u', $detail, $m)) {
|
||||
$facts['adobe_subcode'] = $m[1];
|
||||
}
|
||||
$bodypos = stripos($detail, 'body=');
|
||||
if ($bodypos !== false) {
|
||||
$xml = self::format_xml(substr($detail, $bodypos + 5));
|
||||
}
|
||||
return ['facts' => $facts, 'xml' => $xml];
|
||||
}
|
||||
|
||||
protected static function staff_title(string $helpid, \Throwable $e): string {
|
||||
$cat = self::catalog();
|
||||
$key = $cat[$helpid]['titlekey'] ?? '';
|
||||
if ($key !== '' && get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
|
||||
return get_string($key, 'meetletacbmarketplace');
|
||||
}
|
||||
if ($e instanceof moodle_exception) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
return get_string('error', 'meetletacbmarketplace');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{helpid:string,facts:array<string,string>,xml:string} $parsed
|
||||
*/
|
||||
protected static function render_staff_html(array $parsed): string {
|
||||
$helpid = $parsed['helpid'];
|
||||
$label = self::label($helpid);
|
||||
$cat = self::catalog();
|
||||
$titlekey = $cat[$helpid]['titlekey'] ?? '';
|
||||
$fixkey = $cat[$helpid]['fixkey'] ?? '';
|
||||
$title = ($titlekey !== '' && get_string_manager()->string_exists($titlekey, 'meetletacbmarketplace'))
|
||||
? get_string($titlekey, 'meetletacbmarketplace')
|
||||
: get_string('error', 'meetletacbmarketplace');
|
||||
|
||||
$codebox = \html_writer::div(
|
||||
\html_writer::span(get_string('error_helpcode_label', 'meetletacbmarketplace'), 'meetlet-err__codelabel') .
|
||||
\html_writer::tag('code', s($label), ['data-copy-code' => $label]),
|
||||
'meetlet-err__codebox'
|
||||
);
|
||||
|
||||
$rows = '';
|
||||
$factorder = ['login', 'adobe_code', 'adobe_subcode', 'egress_ip'];
|
||||
$facts = $parsed['facts'];
|
||||
foreach ($factorder as $key) {
|
||||
if (empty($facts[$key])) {
|
||||
continue;
|
||||
}
|
||||
$dtkey = 'errorfact_' . $key;
|
||||
$dt = get_string_manager()->string_exists($dtkey, 'meetletacbmarketplace')
|
||||
? get_string($dtkey, 'meetletacbmarketplace')
|
||||
: $key;
|
||||
$rows .= \html_writer::tag(
|
||||
'div',
|
||||
\html_writer::tag('dt', s($dt)) .
|
||||
\html_writer::tag('dd', \html_writer::tag('code', s((string)$facts[$key]))),
|
||||
['class' => 'meetlet-err__fact']
|
||||
);
|
||||
}
|
||||
foreach ($facts as $key => $val) {
|
||||
if (in_array($key, $factorder, true) || (string)$val === '') {
|
||||
continue;
|
||||
}
|
||||
$rows .= \html_writer::tag(
|
||||
'div',
|
||||
\html_writer::tag('dt', s((string)$key)) .
|
||||
\html_writer::tag('dd', \html_writer::tag('code', s((string)$val))),
|
||||
['class' => 'meetlet-err__fact']
|
||||
);
|
||||
}
|
||||
$factshtml = $rows !== '' ? \html_writer::tag('dl', $rows, ['class' => 'meetlet-err__facts']) : '';
|
||||
|
||||
$fix = '';
|
||||
if ($fixkey !== '' && get_string_manager()->string_exists($fixkey, 'meetletacbmarketplace')) {
|
||||
$fix = \html_writer::div(get_string($fixkey, 'meetletacbmarketplace'), 'meetlet-err__fix');
|
||||
}
|
||||
|
||||
$xmlhtml = '';
|
||||
if ($parsed['xml'] !== '') {
|
||||
$xmlhtml = \html_writer::tag('p', get_string('error_adobe_response', 'meetletacbmarketplace'), ['class' => 'meetlet-err__prelabel']) .
|
||||
\html_writer::tag('pre', s($parsed['xml']), ['class' => 'meetlet-err__xml']);
|
||||
}
|
||||
|
||||
$guide = \html_writer::tag('p', get_string('error_see_serverguide', 'meetletacbmarketplace'), ['class' => 'meetlet-err__guide']);
|
||||
|
||||
return \html_writer::div(
|
||||
\html_writer::tag('p', s($title), ['class' => 'meetlet-err__lead']) .
|
||||
$codebox .
|
||||
$factshtml .
|
||||
$fix .
|
||||
$xmlhtml .
|
||||
$guide,
|
||||
'meetlet-err meetlet-err--staff'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user