Skip to content

Commit

Permalink
MDL-58010 user: allow to update only whitelisted user preferences
Browse files Browse the repository at this point in the history
  • Loading branch information
marinaglancy authored and danpoltawski committed Mar 10, 2017
1 parent 77f5918 commit 6e65554
Show file tree
Hide file tree
Showing 18 changed files with 560 additions and 96 deletions.
5 changes: 3 additions & 2 deletions badges/preferences.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

require_once(__DIR__ . '/../config.php');
require_once('preferences_form.php');
require_once($CFG->dirroot.'/user/editlib.php');

$url = new moodle_url('/badges/preferences.php');

Expand All @@ -42,8 +43,8 @@
$mform->set_data(array('badgeprivacysetting' => get_user_preferences('badgeprivacysetting')));

if (!$mform->is_cancelled() && $data = $mform->get_data()) {
$setting = $data->badgeprivacysetting;
set_user_preference('badgeprivacysetting', $setting);
useredit_update_user_preference(['id' => $USER->id,
'preference_badgeprivacysetting' => $data->badgeprivacysetting]);
}

if ($mform->is_cancelled()) {
Expand Down
2 changes: 1 addition & 1 deletion blocks/course_overview/locallib.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function block_course_overview_get_myorder() {
// If preference was not found, look in the old location and convert if found.
$order = array();
if ($value = get_user_preferences('course_overview_course_order')) {
$order = unserialize($value);
$order = unserialize_array($value);
block_course_overview_update_myorder($order);
unset_user_preference('course_overview_course_order');
}
Expand Down
4 changes: 3 additions & 1 deletion blog/preferences.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
require_once('../config.php');
require_once($CFG->dirroot.'/blog/lib.php');
require_once('preferences_form.php');
require_once($CFG->dirroot.'/user/editlib.php');

$courseid = optional_param('courseid', SITEID, PARAM_INT);
$modid = optional_param('modid', null, PARAM_INT);
Expand Down Expand Up @@ -81,7 +82,8 @@
if ($pagesize < 1) {
print_error('invalidpagesize');
}
set_user_preference('blogpagesize', $pagesize);
useredit_update_user_preference(['id' => $USER->id,
'preference_blogpagesize' => $pagesize]);
}

if ($mform->is_cancelled()) {
Expand Down
22 changes: 22 additions & 0 deletions calendar/lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -3449,3 +3449,25 @@ function calendar_get_calendar_context($subscription) {
}
return $context;
}

/**
* Implements callback user_preferences, whitelists preferences that users are allowed to update directly
*
* Used in {@see core_user::fill_preferences_cache()}, see also {@see useredit_update_user_preference()}
*
* @return array
*/
function core_calendar_user_preferences() {
$preferences = [];
$preferences['calendar_timeformat'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED, 'default' => '0',
'choices' => array('0', CALENDAR_TF_12, CALENDAR_TF_24)
);
$preferences['calendar_startwday'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0,
'choices' => array(0, 1, 2, 3, 4, 5, 6));
$preferences['calendar_maxevents'] = array('type' => PARAM_INT, 'choices' => range(1, 20));
$preferences['calendar_lookahead'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 365,
'choices' => array(365, 270, 180, 150, 120, 90, 60, 30, 21, 14, 7, 6, 5, 4, 3, 2, 1));
$preferences['calendar_persistflt'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0,
'choices' => array(0, 1));
return $preferences;
}
2 changes: 1 addition & 1 deletion grade/report/grader/lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -1774,7 +1774,7 @@ protected static function get_collapsed_preferences($courseid) {

// Try looking for old location of user setting that used to store all courses in one serialized user preference.
if (($oldcollapsedpref = get_user_preferences('grade_report_grader_collapsed_categories')) !== null) {
if ($collapsedall = @unserialize($oldcollapsedpref)) {
if ($collapsedall = unserialize_array($oldcollapsedpref)) {
// We found the old-style preference, filter out only categories that belong to this course and update the prefs.
$collapsed = static::filter_collapsed_categories($courseid, $collapsedall);
if (!empty($collapsed['aggregatesonly']) || !empty($collapsed['gradesonly'])) {
Expand Down
220 changes: 220 additions & 0 deletions lib/classes/user.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class core_user {
/** @var array store user fields properties cache. */
protected static $propertiescache = null;

/** @var array store user preferences cache. */
protected static $preferencescache = null;

/**
* Return user object from db or create noreply or support user,
* if userid matches corse_user::NOREPLY_USER or corse_user::SUPPORT_USER
Expand Down Expand Up @@ -644,4 +647,221 @@ public static function get_property_default($property) {

return self::$propertiescache[$property]['default'];
}

/**
* Definition of updateable user preferences and rules for data and access validation.
*
* array(
* 'preferencename' => array( // Either exact preference name or a regular expression.
* 'null' => NULL_ALLOWED, // Defaults to NULL_NOT_ALLOWED. Takes NULL_NOT_ALLOWED or NULL_ALLOWED.
* 'type' => PARAM_TYPE, // Expected parameter type of the user field - mandatory
* 'choices' => array(1, 2..) // An array of accepted values of the user field - optional
* 'default' => $CFG->setting // An default value for the field - optional
* 'isregex' => false/true // Whether the name of the preference is a regular expression (default false).
* 'permissioncallback' => callable // Function accepting arguments ($user, $preferencename) that checks if current user
* // is allowed to modify this preference for given user.
* // If not specified core_user::default_preference_permission_check() will be assumed.
* 'cleancallback' => callable // Custom callback for cleaning value if something more difficult than just type/choices is needed
* // accepts arguments ($value, $preferencename)
* )
* )
*
* @return void
*/
protected static function fill_preferences_cache() {
if (self::$preferencescache !== null) {
return;
}

// Array of user preferences and expected types/values.
// Every preference that can be updated directly by user should be added here.
$preferences = array();
$preferences['auth_forcepasswordchange'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'choices' => array(0, 1),
'permissioncallback' => function($user, $preferencename) {
global $USER;
$systemcontext = context_system::instance();
return ($USER->id != $user->id && (has_capability('moodle/user:update', $systemcontext) ||
($user->timecreated > time() - 10 && has_capability('moodle/user:create', $systemcontext))));
});
$preferences['usemodchooser'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
'choices' => array(0, 1));
$preferences['forum_markasreadonnotification'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
'choices' => array(0, 1));
$preferences['htmleditor'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED,
'cleancallback' => function($value, $preferencename) {
if (empty($value) || !array_key_exists($value, core_component::get_plugin_list('editor'))) {
return null;
}
return $value;
});
$preferences['badgeprivacysetting'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
'choices' => array(0, 1), 'permissioncallback' => function($user, $preferencename) {
global $CFG, $USER;
return !empty($CFG->enablebadges) && $user->id == $USER->id;
});
$preferences['blogpagesize'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 10,
'permissioncallback' => function($user, $preferencename) {
global $USER;
return $USER->id == $user->id && has_capability('moodle/blog:view', context_system::instance());
});

// Core components that may want to define their preferences.
// List of core components implementing callback is hardcoded here for performance reasons.
// TODO MDL-58184 cache list of core components implementing a function.
$corecomponents = ['core_message', 'core_calendar'];
foreach ($corecomponents as $component) {
if (($pluginpreferences = component_callback($component, 'user_preferences')) && is_array($pluginpreferences)) {
$preferences += $pluginpreferences;
}
}

// Plugins that may define their preferences.
if ($pluginsfunction = get_plugins_with_function('user_preferences')) {
foreach ($pluginsfunction as $plugintype => $plugins) {
foreach ($plugins as $function) {
if (($pluginpreferences = call_user_func($function)) && is_array($pluginpreferences)) {
$preferences += $pluginpreferences;
}
}
}
}

self::$preferencescache = $preferences;
}

/**
* Retrieves the preference definition
*
* @param string $preferencename
* @return array
*/
protected static function get_preference_definition($preferencename) {
self::fill_preferences_cache();

foreach (self::$preferencescache as $key => $preference) {
if (empty($preference['isregex'])) {
if ($key === $preferencename) {
return $preference;
}
} else {
if (preg_match($key, $preferencename)) {
return $preference;
}
}
}

throw new coding_exception('Invalid preference requested.');
}

/**
* Default callback used for checking if current user is allowed to change permission of user $user
*
* @param stdClass $user
* @param string $preferencename
* @return bool
*/
protected static function default_preference_permission_check($user, $preferencename) {
global $USER;
if (is_mnet_remote_user($user)) {
// Can't edit MNET user.
return false;
}

if ($user->id == $USER->id) {
// Editing own profile.
$systemcontext = context_system::instance();
return has_capability('moodle/user:editownprofile', $systemcontext);
} else {
// Teachers, parents, etc.
$personalcontext = context_user::instance($user->id);
if (!has_capability('moodle/user:editprofile', $personalcontext)) {
return false;
}
if (is_siteadmin($user->id) and !is_siteadmin($USER)) {
// Only admins may edit other admins.
return false;
}
return true;
}
}

/**
* Can current user edit preference of this/another user
*
* @param string $preferencename
* @param stdClass $user
* @return bool
*/
public static function can_edit_preference($preferencename, $user) {
if (!isloggedin() || isguestuser()) {
// Guests can not edit anything.
return false;
}

try {
$definition = self::get_preference_definition($preferencename);
} catch (coding_exception $e) {
return false;
}

if ($user->deleted || !context_user::instance($user->id, IGNORE_MISSING)) {
// User is deleted.
return false;
}

if (isset($definition['permissioncallback'])) {
$callback = $definition['permissioncallback'];
if (is_callable($callback)) {
return call_user_func_array($callback, [$user, $preferencename]);
} else {
throw new coding_exception('Permission callback for preference ' . s($preferencename) . ' is not callable');
return false;
}
} else {
return self::default_preference_permission_check($user, $preferencename);
}
}

/**
* Clean value of a user preference
*
* @param string $value the user preference value to be cleaned.
* @param string $preferencename the user preference name
* @return string the cleaned preference value
*/
public static function clean_preference($value, $preferencename) {

$definition = self::get_preference_definition($preferencename);

if (isset($definition['type']) && $value !== null) {
$value = clean_param($value, $definition['type']);
}

if (isset($definition['cleancallback'])) {
$callback = $definition['cleancallback'];
if (is_callable($callback)) {
return $callback($value, $preferencename);
} else {
throw new coding_exception('Clean callback for preference ' . s($preferencename) . ' is not callable');
}
} else if ($value === null && (!isset($definition['null']) || $definition['null'] == NULL_ALLOWED)) {
return null;
} else if (isset($definition['choices'])) {
if (!in_array($value, $definition['choices'])) {
if (isset($definition['default'])) {
return $definition['default'];
} else {
$first = reset($definition['choices']);
return $first;
}
} else {
return $value;
}
} else {
if ($value === null) {
return isset($definition['default']) ? $definition['default'] : '';
}
return $value;
}
}
}
54 changes: 54 additions & 0 deletions lib/moodlelib.php
Original file line number Diff line number Diff line change
Expand Up @@ -1864,6 +1864,8 @@ function mark_user_preferences_changed($userid) {
*
* If a $user object is submitted it's 'preference' property is used for the preferences cache.
*
* When additional validation/permission check is needed it is better to use {@see useredit_update_user_preference()}
*
* @package core
* @category preference
* @access public
Expand Down Expand Up @@ -9702,6 +9704,58 @@ function get_course_display_name_for_list($course) {
}
}

/**
* Safe analogue of unserialize() that can only parse arrays
*
* Arrays may contain only integers or strings as both keys and values. Nested arrays are allowed.
* Note: If any string (key or value) has semicolon (;) as part of the string parsing will fail.
* This is a simple method to substitute unnecessary unserialize() in code and not intended to cover all possible cases.
*
* @param string $expression
* @return array|bool either parsed array or false if parsing was impossible.
*/
function unserialize_array($expression) {
$subs = [];
// Find nested arrays, parse them and store in $subs , substitute with special string.
while (preg_match('/([\^;\}])(a:\d+:\{[^\{\}]*\})/', $expression, $matches) && strlen($matches[2]) < strlen($expression)) {
$key = '--SUB' . count($subs) . '--';
$subs[$key] = unserialize_array($matches[2]);
if ($subs[$key] === false) {
return false;
}
$expression = str_replace($matches[2], $key . ';', $expression);
}

// Check the expression is an array.
if (!preg_match('/^a:(\d+):\{([^\}]*)\}$/', $expression, $matches1)) {
return false;
}
// Get the size and elements of an array (key;value;key;value;....).
$parts = explode(';', $matches1[2]);
$size = intval($matches1[1]);
if (count($parts) < $size * 2 + 1) {
return false;
}
// Analyze each part and make sure it is an integer or string or a substitute.
$value = [];
for ($i = 0; $i < $size * 2; $i++) {
if (preg_match('/^i:(\d+)$/', $parts[$i], $matches2)) {
$parts[$i] = (int)$matches2[1];
} else if (preg_match('/^s:(\d+):"(.*)"$/', $parts[$i], $matches3) && strlen($matches3[2]) == (int)$matches3[1]) {
$parts[$i] = $matches3[2];
} else if (preg_match('/^--SUB\d+--$/', $parts[$i])) {
$parts[$i] = $subs[$parts[$i]];
} else {
return false;
}
}
// Combine keys and values.
for ($i = 0; $i < $size * 2; $i += 2) {
$value[$parts[$i]] = $parts[$i+1];
}
return $value;
}

/**
* The lang_string class
*
Expand Down
Loading

0 comments on commit 6e65554

Please sign in to comment.