91334a46b5
Co-authored-by: Cursor <cursoragent@cursor.com>
545 lines
19 KiB
PHP
545 lines
19 KiB
PHP
<?php
|
||
// This file is part of Moodle - http://moodle.org/
|
||
//
|
||
// Moodle is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// Moodle is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
/**
|
||
* Role-based feature access matrix (site + activity).
|
||
*
|
||
* @package mod_meetletacbfree
|
||
* @copyright 2026 MeetLet
|
||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||
*/
|
||
|
||
namespace mod_meetletacbfree\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/meetletacbfree: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('meetletacbfree', '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('meetletacbfree', '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_meetletacbfree\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_meetletacbfree\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', 'meetletacbfree');
|
||
}
|
||
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, 'meetletacbfree')) {
|
||
return get_string($key, 'meetletacbfree');
|
||
}
|
||
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/meetletacbfree: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', 'meetletacbfree');
|
||
$strtogglecol = get_string('accessmatrix_toggle_col', 'meetletacbfree');
|
||
$strall = get_string('accessmatrix_all', 'meetletacbfree');
|
||
|
||
$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', 'meetletacbfree'));
|
||
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);
|
||
}
|
||
}
|