Files
meetletacbfree/classes/local/featureaccess.php
T

545 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}