. /** * 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|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 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> 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> */ 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> */ 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> */ 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> */ 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> */ 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 feature => label */ public static function feature_labels(): array { $out = []; foreach (self::features() as $f) { $out[$f] = self::feature_label($f); } return $out; } /** * @return array 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> */ 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); } }