Update #86c46bd0u - Add support for predefined IP addresses/ranges

This commit is contained in:
Vincent Cornelis
2025-08-04 17:38:30 +02:00
parent 26669e73f1
commit fe3a5b2302
16 changed files with 1435 additions and 52 deletions

View File

@@ -38,25 +38,41 @@ use core_availability\info;
class condition extends \core_availability\condition {
/**
* Manual provided IP addresses.
*
* @var string
*/
protected string $ipaddresses = '';
/**
* Predefined IP address ranges.
*
* @var array
*/
protected array $predefinedranges = [];
/**
* condition constructor.
*
* @param \stdClass $structure
*/
public function __construct($structure) {
public function __construct(\stdClass $structure) {
if (isset($structure->ipaddresses)) {
$this->ipaddresses = $structure->ipaddresses;
}
if (isset($structure->predefined_ranges)) {
$this->predefinedranges = $structure->predefined_ranges;
}
}
/**
* Determines whether a particular item is currently available
* according to this availability condition.
*
* Note: Cannot add type declarations for $not, $grabthelot, and $userid parameters
* as the parent core_availability\condition::is_available() method doesn't have them,
* and PHP requires compatibility with parent method signatures when overriding.
*
* If implementations require a course or modinfo, they should use
* the get methods in $info.
*
@@ -78,14 +94,43 @@ class condition extends \core_availability\condition {
* @return bool True if available
*/
public function is_available($not, info $info, $grabthelot, $userid): bool {
global $DB;
if (empty($this->ipaddresses)) {
// Collect all IP addresses to check.
$allipaddresses = [];
// Add custom IP addresses.
if (!empty($this->ipaddresses)) {
$allipaddresses[] = trim($this->ipaddresses);
}
// Add predefined ranges.
if (!empty($this->predefinedranges)) {
$ranges = $DB->get_records_list(
'availability_ipaddress_pre',
'id',
$this->predefinedranges,
'',
'ipaddresses'
);
foreach ($ranges as $range) {
if (!empty($range->ipaddresses)) {
$allipaddresses[] = trim($range->ipaddresses);
}
}
}
// If no IP addresses are configured, the condition passes.
if (empty($allipaddresses)) {
return !$not;
}
// Check if ip-address matches.
if (address_in_subnet(getremoteaddr(), trim($this->ipaddresses))) {
return !$not;
// Check if user's IP matches any of the allowed addresses.
$userip = getremoteaddr();
foreach ($allipaddresses as $iplist) {
if (address_in_subnet($userip, $iplist)) {
return !$not;
}
}
return $not;
@@ -97,6 +142,9 @@ class condition extends \core_availability\condition {
* students if the activity is not available to them, and for staff to see
* what conditions are.
*
* Note: Cannot add type declarations for $full and $not parameters as the parent
* core_availability\condition::get_description() method doesn't have them.
*
* The $full parameter can be used to distinguish between 'staff' cases
* (when displaying all information about the activity) and 'student' cases
* (when displaying only conditions they don't meet).
@@ -151,10 +199,16 @@ class condition extends \core_availability\condition {
* @return \stdClass Structure object (ready to be made into JSON format)
*/
public function save(): \stdClass {
return (object) [
$result = (object) [
'type' => 'ipaddress',
'ipaddresses' => $this->ipaddresses,
];
if (!empty($this->predefinedranges)) {
$result->predefined_ranges = $this->predefinedranges;
}
return $result;
}
}

151
classes/form/range_form.php Normal file
View File

@@ -0,0 +1,151 @@
<?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/>.
/**
* Form for managing predefined IP ranges.
*
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_ipaddress\form;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/formslib.php');
/**
* Form for managing predefined IP ranges.
*
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class range_form extends \moodleform {
/**
* Define the form.
*
* @return void
*/
protected function definition(): void {
$mform = $this->_form;
$id = $this->_customdata['id'] ?? 0;
// Hidden id field.
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
$mform->setDefault('id', $id);
// Name field.
$mform->addElement('text', 'name', get_string('range_name', 'availability_ipaddress'));
$mform->setType('name', PARAM_TEXT);
$mform->addRule('name', null, 'required', null, 'client');
$mform->addHelpButton('name', 'range_name', 'availability_ipaddress');
// IP addresses field.
$mform->addElement(
'text',
'ipaddresses',
get_string('ipaddresses', 'availability_ipaddress'),
['size' => 100]
);
$mform->setType('ipaddresses', PARAM_TEXT);
$mform->addRule('ipaddresses', null, 'required', null, 'client');
$mform->addHelpButton('ipaddresses', 'ipaddresses_help', 'availability_ipaddress');
// Description field.
$mform->addElement('textarea', 'description', get_string('description'),
['rows' => 3, 'cols' => 60]);
$mform->setType('description', PARAM_TEXT);
// Enabled field.
$mform->addElement('advcheckbox', 'enabled', get_string('enabled', 'availability_ipaddress'));
$mform->setDefault('enabled', 1);
// Action buttons.
$this->add_action_buttons();
}
/**
* Validate the form data.
*
* Note: Parameter type declarations cannot be added here as the parent
* moodleform::validation() method doesn't have them, and PHP requires
* compatibility with parent method signatures when overriding.
*
* @param array $data
* @param array $files
*
* @return array
*/
public function validation($data, $files) {
$errors = parent::validation($data, $files);
// Validate IP addresses.
if (!empty($data['ipaddresses'])) {
$ipaddresses = explode(',', $data['ipaddresses']);
foreach ($ipaddresses as $ip) {
$ip = trim($ip);
if (!$this->validate_ip_format($ip)) {
$errors['ipaddresses'] = get_string('error_ipaddress', 'availability_ipaddress');
break;
}
}
}
return $errors;
}
/**
* Validate IP address format.
*
* @param string $ip
*
* @return bool
*/
private function validate_ip_format(string $ip): bool {
// Use the same validation logic as the main plugin.
// This is a simplified version - you might want to use the same regex as in JS.
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return true;
}
// Check for CIDR notation.
if (strpos($ip, '/') !== false) {
[$addr, $mask] = explode('/', $ip);
if (filter_var($addr, FILTER_VALIDATE_IP) && is_numeric($mask)) {
return true;
}
}
// Check for IP range.
if (strpos($ip, '-') !== false) {
[$start, $end] = explode('-', $ip);
if (filter_var($start, FILTER_VALIDATE_IP)) {
return true;
}
}
return false;
}
}

View File

@@ -44,7 +44,42 @@ class frontend extends \core_availability\frontend {
return [
'js:ipaddress',
'error_ipaddress',
'predefined_ranges',
'custom_ipaddress',
'use_predefined',
];
}
/**
* Get additional parameters for the JavaScript module.
*
* Note: Cannot add type declaration for $course parameter as the parent
* core_availability\frontend::get_javascript_init_params() method doesn't
* have it, and PHP requires compatibility with parent method signatures.
*
* @param \stdClass $course Course object
* @param \cm_info|null $cm Course module
* @param \section_info|null $section Section
*
* @return array
*/
protected function get_javascript_init_params($course, ?\cm_info $cm = null, ?\section_info $section = null): array {
global $DB;
// Get enabled predefined IP ranges.
$ranges = $DB->get_records('availability_ipaddress_pre', ['enabled' => 1], 'name', 'id, name, ipaddresses');
// Format for JavaScript.
$rangedata = [];
foreach ($ranges as $range) {
$rangedata[] = [
'id' => $range->id,
'name' => format_string($range->name),
'ipaddresses' => $range->ipaddresses,
];
}
return [$rangedata];
}
}

365
classes/helper.php Normal file
View File

@@ -0,0 +1,365 @@
<?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/>.
/**
* Helper functions for availability_ipaddress.
*
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_ipaddress;
/**
* Helper class for availability_ipaddress.
*
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class helper {
/**
* Check if a predefined range is in use.
*
* @param int $rangeid The ID of the range to check.
*
* @return array Array with 'inuse' boolean and 'count' of uses.
*/
public static function is_range_in_use(int $rangeid): array {
$activities = [];
// Check course modules.
$moduleactivities = self::check_range_in_modules($rangeid);
$activities = array_merge($activities, $moduleactivities);
// Check sections.
$sectionactivities = self::check_range_in_sections($rangeid);
$activities = array_merge($activities, $sectionactivities);
$count = count($activities);
return [
'inuse' => ($count > 0),
'count' => $count,
'activities' => $activities,
];
}
/**
* Check if a range is used in course modules.
*
* @param int $rangeid The ID of the range to check.
*
* @return array Array of activities using the range.
*/
private static function check_range_in_modules(int $rangeid): array {
global $DB;
$activities = [];
$sql = "SELECT cm.id, cm.course, cm.availability, cm.module, cm.instance,
c.fullname as coursename, m.name as modname
FROM {course_modules} cm
JOIN {course} c ON c.id = cm.course
JOIN {modules} m ON m.id = cm.module
WHERE cm.availability IS NOT NULL AND cm.availability != ''";
$modules = $DB->get_records_sql($sql);
foreach ($modules as $module) {
$availability = json_decode($module->availability);
if (!$availability || !self::check_availability_tree($availability, $rangeid)) {
continue;
}
$activityname = self::get_module_name($module);
$activities[] = [
'coursename' => $module->coursename,
'cmid' => $module->id,
'name' => $activityname,
];
}
return $activities;
}
/**
* Get the name of a module.
*
* @param \stdClass $module The module record.
*
* @return string The module name.
*/
private static function get_module_name(\stdClass $module): string {
global $DB;
try {
if ($DB->get_manager()->table_exists($module->modname)) {
$activity = $DB->get_record($module->modname, ['id' => $module->instance], 'name', IGNORE_MISSING);
if ($activity && !empty($activity->name)) {
return $activity->name;
}
}
} catch (\Exception $e) {
// Table doesn't exist or other database error - fall through to default.
debugging('Error getting module name: ' . $e->getMessage(), DEBUG_DEVELOPER);
}
return get_string('modulename', $module->modname);
}
/**
* Check if a range is used in course sections.
*
* @param int $rangeid The ID of the range to check.
*
* @return array Array of sections using the range.
*/
private static function check_range_in_sections(int $rangeid): array {
global $DB;
$activities = [];
$sql = "SELECT cs.id, cs.course, cs.availability, cs.name, cs.section, c.fullname as coursename
FROM {course_sections} cs
JOIN {course} c ON c.id = cs.course
WHERE cs.availability IS NOT NULL AND cs.availability != ''";
$sections = $DB->get_records_sql($sql);
foreach ($sections as $section) {
$availability = json_decode($section->availability);
if (!$availability || !self::check_availability_tree($availability, $rangeid)) {
continue;
}
$sectionname = $section->name ?: get_string('section') . ' ' . $section->section;
$activities[] = [
'coursename' => $section->coursename,
'cmid' => 0,
'name' => $sectionname,
];
}
return $activities;
}
/**
* Recursively check availability tree for range usage.
*
* @param \stdClass $availability The availability tree.
* @param int $rangeid The range ID to look for.
*
* @return bool True if range is found in tree.
*/
private static function check_availability_tree(\stdClass $availability, int $rangeid): bool {
// Check if this is an IP address condition.
if (isset($availability->type) && $availability->type === 'ipaddress') {
if (isset($availability->predefined_ranges) && is_array($availability->predefined_ranges)) {
return in_array($rangeid, $availability->predefined_ranges);
}
}
// Check nested conditions (for groups).
if (isset($availability->c) && is_array($availability->c)) {
foreach ($availability->c as $condition) {
if (self::check_availability_tree($condition, $rangeid)) {
return true;
}
}
}
return false;
}
/**
* Get usage details for a range as HTML.
*
* @param int $rangeid The range ID.
*
* @return string HTML string with usage details.
*/
public static function get_range_usage_html(int $rangeid): string {
$usage = self::is_range_in_use($rangeid);
if (!$usage['inuse']) {
return '';
}
$html = \html_writer::tag('p', get_string('range_in_use_count', 'availability_ipaddress', $usage['count']));
if (!empty($usage['activities'])) {
$items = [];
foreach (array_slice($usage['activities'], 0, 5) as $activity) {
$items[] = $activity['coursename'] . ': ' . $activity['name'];
}
$html .= \html_writer::alist($items);
if (count($usage['activities']) > 5) {
$more = count($usage['activities']) - 5;
$html .= \html_writer::tag('p', get_string('and_x_more', 'availability_ipaddress', $more));
}
}
return $html;
}
/**
* Remove a predefined range from all availability restrictions.
*
* @param int $rangeid The ID of the range to remove.
*
* @return int Number of restrictions updated.
*/
public static function remove_range_from_restrictions(int $rangeid): int {
global $DB;
$updatecount = 0;
// Update course modules.
$sql = "SELECT cm.id, cm.availability
FROM {course_modules} cm
WHERE cm.availability IS NOT NULL AND cm.availability != ''";
$modules = $DB->get_records_sql($sql);
foreach ($modules as $module) {
$availability = json_decode($module->availability);
if ($availability && self::remove_range_from_tree($availability, $rangeid)) {
$module->availability = json_encode($availability);
$DB->update_record('course_modules', $module);
$updatecount++;
// Rebuild course cache.
$course = $DB->get_record('course', ['id' => $DB->get_field('course_modules', 'course', ['id' => $module->id])]);
if ($course) {
rebuild_course_cache($course->id, true);
}
}
}
// Update course sections.
$sql = "SELECT cs.id, cs.course, cs.availability
FROM {course_sections} cs
WHERE cs.availability IS NOT NULL AND cs.availability != ''";
$sections = $DB->get_records_sql($sql);
foreach ($sections as $section) {
$availability = json_decode($section->availability);
if ($availability && self::remove_range_from_tree($availability, $rangeid)) {
$section->availability = json_encode($availability);
$DB->update_record('course_sections', $section);
$updatecount++;
// Rebuild course cache.
rebuild_course_cache($section->course, true);
}
}
return $updatecount;
}
/**
* Recursively remove range from availability tree.
*
* @param \stdClass $availability The availability tree.
* @param int $rangeid The range ID to remove.
*
* @return bool True if tree was modified.
*/
private static function remove_range_from_tree(\stdClass $availability, int $rangeid): bool {
$modified = false;
// Process IP address conditions.
if (self::is_ipaddress_condition($availability)) {
$modified = self::remove_range_from_condition($availability, $rangeid);
}
// Process nested conditions.
return self::process_nested_conditions($availability, $rangeid) || $modified;
}
/**
* Check if the availability item is an IP address condition.
*
* @param \stdClass $availability The availability item.
*
* @return bool True if it's an IP address condition.
*/
private static function is_ipaddress_condition(\stdClass $availability): bool {
return isset($availability->type) && $availability->type === 'ipaddress';
}
/**
* Remove range from an IP address condition.
*
* @param \stdClass $availability The availability condition.
* @param int $rangeid The range ID to remove.
*
* @return bool True if the condition was modified.
*/
private static function remove_range_from_condition(\stdClass $availability, int $rangeid): bool {
if (!isset($availability->predefined_ranges) || !is_array($availability->predefined_ranges)) {
return false;
}
$key = array_search($rangeid, $availability->predefined_ranges);
if ($key === false) {
return false;
}
// Remove the range from the array.
array_splice($availability->predefined_ranges, $key, 1);
// If no ranges left, remove the predefined_ranges property.
if (empty($availability->predefined_ranges)) {
unset($availability->predefined_ranges);
}
return true;
}
/**
* Process nested conditions in availability tree.
*
* @param \stdClass $availability The availability item.
* @param int $rangeid The range ID to remove.
*
* @return bool True if any nested condition was modified.
*/
private static function process_nested_conditions(\stdClass $availability, int $rangeid): bool {
if (!isset($availability->c) || !is_array($availability->c)) {
return false;
}
$modified = false;
foreach ($availability->c as $condition) {
if (self::remove_range_from_tree($condition, $rangeid)) {
$modified = true;
}
}
return $modified;
}
}

View File

@@ -0,0 +1,238 @@
<?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/>.
/**
* Table class for displaying IP address ranges.
*
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_ipaddress\table;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/tablelib.php');
use table_sql;
use html_writer;
use moodle_url;
use pix_icon;
use confirm_action;
/**
* Table class for IP address ranges.
*
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @package availability_ipaddress
* @copyright 04/08/2025 LdesignMedia.nl - Luuk Verhoeven
* @author Vincent Cornelis
*/
class ipranges_table extends table_sql {
/**
* @var moodle_url The base URL for the page.
*/
public $baseurl;
/**
* Constructor.
*
* @param string $uniqueid Unique ID for the table.
* @param moodle_url $baseurl The base URL for the page.
*/
public function __construct(string $uniqueid, moodle_url $baseurl) {
parent::__construct($uniqueid);
$this->baseurl = $baseurl;
// Define columns and headers.
$columns = ['name', 'description', 'ipaddresses', 'enabled', 'actions'];
$headers = [
get_string('name'),
get_string('description'),
get_string('ipaddresses', 'availability_ipaddress'),
get_string('enabled', 'availability_ipaddress'),
get_string('actions'),
];
$this->define_columns($columns);
$this->define_headers($headers);
// Set attributes.
$this->set_attribute('class', 'generaltable');
$this->sortable(true, 'name', SORT_ASC);
$this->no_sorting('description');
$this->no_sorting('actions');
$this->collapsible(false);
$this->pageable(true);
$this->is_downloadable(false);
$this->define_baseurl($baseurl);
}
/**
* Set up the SQL query.
*
* @param int $pagesize Number of records per page.
* @param bool $useinitialsbar Whether to use the initials bar.
*
* @return void
*/
public function set_sql_data(int $pagesize = 30, bool $useinitialsbar = false): void {
$fields = 'id, name, description, ipaddresses, enabled, timecreated, timemodified';
$from = '{availability_ipaddress_pre}';
$where = '1=1';
$params = [];
$this->set_sql($fields, $from, $where, $params);
$this->set_count_sql("SELECT COUNT(*) FROM {availability_ipaddress_pre}");
}
/**
* Column for name.
*
* @param \stdClass $range The range record.
*
* @return string
*/
public function col_name(\stdClass $range): string {
return format_string($range->name);
}
/**
* Column for description.
*
* @param \stdClass $range The range record.
*
* @return string
*/
public function col_description(\stdClass $range): string {
if (empty($range->description)) {
return '-';
}
$description = format_string($range->description);
// Truncate if too long.
if (strlen($description) > 100) {
$truncated = substr($description, 0, 97) . '...';
return html_writer::tag('span', $truncated, ['title' => $description]);
}
return $description;
}
/**
* Column for IP addresses.
*
* @param \stdClass $range The range record.
*
* @return string
*/
public function col_ipaddresses(\stdClass $range): string {
$ips = s($range->ipaddresses);
// Truncate if too long and add tooltip.
if (strlen($ips) > 50) {
$truncated = substr($ips, 0, 47) . '...';
return html_writer::tag(
'span',
html_writer::tag('code', $truncated),
['title' => $ips]
);
}
return html_writer::tag('code', $ips);
}
/**
* Column for enabled status.
*
* @param \stdClass $range The range record.
*
* @return string
*/
public function col_enabled(\stdClass $range): string {
return $range->enabled ? get_string('yes') : get_string('no');
}
/**
* Column for actions.
*
* @param \stdClass $range The range record.
*
* @return string
*/
public function col_actions(\stdClass $range): string {
global $OUTPUT;
$actions = [];
// Edit action.
$editurl = new moodle_url($this->baseurl, ['action' => 'edit', 'id' => $range->id]);
$actions[] = $OUTPUT->action_icon($editurl,
new pix_icon('t/edit', get_string('edit')));
// Toggle action.
$toggleurl = new moodle_url($this->baseurl, ['action' => 'toggle', 'id' => $range->id, 'sesskey' => sesskey()]);
$toggleicon = $range->enabled ? 't/hide' : 't/show';
$togglestring = $range->enabled ? get_string('disable') : get_string('enable');
// Check if range is in use and add confirmation if disabling.
if ($range->enabled) {
$usage = \availability_ipaddress\helper::is_range_in_use($range->id);
if ($usage['inuse']) {
// Create confirmation message with usage details.
$message = \availability_ipaddress\helper::get_range_usage_html($range->id);
$message .= \html_writer::tag('p', get_string('confirm_disable_range', 'availability_ipaddress'),
['class' => 'font-weight-bold']);
$actions[] = $OUTPUT->action_icon($toggleurl, new pix_icon($toggleicon, $togglestring),
new confirm_action($message));
} else {
$actions[] = $OUTPUT->action_icon($toggleurl, new pix_icon($toggleicon, $togglestring));
}
} else {
// Enabling doesn't need confirmation.
$actions[] = $OUTPUT->action_icon($toggleurl, new pix_icon($toggleicon, $togglestring));
}
// Delete action.
$deleteurl = new moodle_url($this->baseurl, [
'action' => 'delete',
'id' => $range->id,
'sesskey' => sesskey(),
]);
// Check if range is in use and add usage info to confirmation.
$usage = \availability_ipaddress\helper::is_range_in_use($range->id);
if ($usage['inuse']) {
$message = \availability_ipaddress\helper::get_range_usage_html($range->id);
$message .= \html_writer::tag('p', get_string('confirm_delete_range', 'availability_ipaddress'),
['class' => 'font-weight-bold']);
} else {
$message = get_string('confirm_delete_range', 'availability_ipaddress');
}
$actions[] = $OUTPUT->action_icon($deleteurl,
new pix_icon('t/delete', get_string('delete')),
new confirm_action($message));
return implode(' ', $actions);
}
}