Commit 3dd89357 authored by David Monllaó's avatar David Monllaó
Browse files

Merge branch 'MDL-53772-master' of git://github.com/damyon/moodle

Conflicts:
	lib/upgrade.txt
parents e92a39cf 787ec240
......@@ -61,6 +61,7 @@ class cohortidnumber extends \core\output\inplace_editable {
global $DB;
$cohort = $DB->get_record('cohort', array('id' => $cohortid), '*', MUST_EXIST);
$cohortcontext = \context::instance_by_id($cohort->contextid);
\external_api::validate_context($cohortcontext);
require_capability('moodle/cohort:manage', $cohortcontext);
$record = (object)array('id' => $cohort->id, 'idnumber' => $newvalue, 'contextid' => $cohort->contextid);
cohort_update_cohort($record);
......
......@@ -61,6 +61,7 @@ class cohortname extends \core\output\inplace_editable {
global $DB;
$cohort = $DB->get_record('cohort', array('id' => $cohortid), '*', MUST_EXIST);
$cohortcontext = \context::instance_by_id($cohort->contextid);
\external_api::validate_context($cohortcontext);
require_capability('moodle/cohort:manage', $cohortcontext);
$newvalue = clean_param($newvalue, PARAM_TEXT);
if (strval($newvalue) !== '') {
......
......@@ -86,15 +86,15 @@ class course_module_name extends \core\output\inplace_editable {
* @return static
*/
public static function update($itemid, $newvalue) {
list($course, $cm) = get_course_and_cm_from_cmid($itemid);
$context = context_module::instance($cm->id);
global $PAGE;
$context = context_module::instance($itemid);
// Check access.
require_login($course, false, $cm, true, true);
\external_api::validate_context($context);
require_capability('moodle/course:manageactivities', $context);
// Update value.
set_coursemodule_name($cm->id, $newvalue);
set_coursemodule_name($PAGE->cm->id, $newvalue);
// Return instance.
$cm = get_fast_modinfo($course)->get_cm($cm->id);
$cm = get_fast_modinfo($PAGE->course)->get_cm($PAGE->cm->id);
return new static($cm, true);
}
}
......@@ -1089,8 +1089,8 @@ abstract class format_base {
*/
public function inplace_editable_update_section_name($section, $itemtype, $newvalue) {
if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
require_login($section->course, false, null, true, true);
$context = context_course::instance($section->course);
external_api::validate_context($context);
require_capability('moodle/course:update', $context);
$newtitle = clean_param($newvalue, PARAM_TEXT);
......
......@@ -41,66 +41,15 @@ if ($requests === null) {
}
$responses = array();
foreach ($requests as $request) {
$response = array();
$methodname = clean_param($request['methodname'], PARAM_ALPHANUMEXT);
$index = clean_param($request['index'], PARAM_INT);
$args = $request['args'];
try {
$externalfunctioninfo = external_function_info($methodname);
if (!$externalfunctioninfo->allowed_from_ajax) {
error_log('This external function is not available to ajax. Failed to call "' . $methodname . '"');
throw new moodle_exception('servicenotavailable', 'webservice');
}
// Do not allow access to write or delete webservices as a public user.
if ($externalfunctioninfo->loginrequired) {
if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES) {
error_log('Set "loginrequired" to false in db/service.php when calling entry point service-nologin.php. ' .
'Failed to call "' . $methodname . '"');
throw new moodle_exception('servicenotavailable', 'webservice');
}
if (!isloggedin()) {
error_log('This external function is not available to public users. Failed to call "' . $methodname . '"');
throw new moodle_exception('servicenotavailable', 'webservice');
} else {
require_sesskey();
}
}
// Validate params, this also sorts the params properly, we need the correct order in the next part.
$callable = array($externalfunctioninfo->classname, 'validate_parameters');
$params = call_user_func($callable,
$externalfunctioninfo->parameters_desc,
$args);
// Execute - gulp!
$callable = array($externalfunctioninfo->classname, $externalfunctioninfo->methodname);
$result = call_user_func_array($callable,
array_values($params));
// Validate the return parameters.
if ($externalfunctioninfo->returns_desc !== null) {
$callable = array($externalfunctioninfo->classname, 'clean_returnvalue');
$result = call_user_func($callable, $externalfunctioninfo->returns_desc, $result);
}
$response['error'] = false;
$response['data'] = $result;
$responses[$index] = $response;
} catch (Exception $e) {
$jsonexception = get_exception_info($e);
unset($jsonexception->a);
if (!debugging('', DEBUG_DEVELOPER)) {
unset($jsonexception->debuginfo);
unset($jsonexception->backtrace);
}
$response['error'] = true;
$response['exception'] = $jsonexception;
$responses[$index] = $response;
$response = external_api::call_external_function($methodname, $args, true);
$responses[$index] = $response;
if ($response['error']) {
// Do not process the remaining requests.
break;
}
......
......@@ -386,7 +386,6 @@ class core_external extends external_api {
if (!$tmpl || !($tmpl instanceof \core\output\inplace_editable)) {
throw new \moodle_exception('inplaceeditableerror');
}
$PAGE->set_context(null); // To prevent warning if context was not set in the callback.
return $tmpl->export_for_template($PAGE->get_renderer('core'));
}
......
......@@ -35,98 +35,7 @@ defined('MOODLE_INTERNAL') || die();
* @since Moodle 2.0
*/
function external_function_info($function, $strictness=MUST_EXIST) {
global $DB, $CFG;
if (!is_object($function)) {
if (!$function = $DB->get_record('external_functions', array('name'=>$function), '*', $strictness)) {
return false;
}
}
// First try class autoloading.
if (!class_exists($function->classname)) {
// Fallback to explicit include of externallib.php.
$function->classpath = empty($function->classpath) ? core_component::get_component_directory($function->component).'/externallib.php' : $CFG->dirroot.'/'.$function->classpath;
if (!file_exists($function->classpath)) {
throw new coding_exception('Cannot find file with external function implementation: ' . $function->classname);
}
require_once($function->classpath);
if (!class_exists($function->classname)) {
throw new coding_exception('Cannot find external class');
}
}
$function->ajax_method = $function->methodname.'_is_allowed_from_ajax';
$function->parameters_method = $function->methodname.'_parameters';
$function->returns_method = $function->methodname.'_returns';
$function->deprecated_method = $function->methodname.'_is_deprecated';
// make sure the implementaion class is ok
if (!method_exists($function->classname, $function->methodname)) {
throw new coding_exception('Missing implementation method of '.$function->classname.'::'.$function->methodname);
}
if (!method_exists($function->classname, $function->parameters_method)) {
throw new coding_exception('Missing parameters description');
}
if (!method_exists($function->classname, $function->returns_method)) {
throw new coding_exception('Missing returned values description');
}
if (method_exists($function->classname, $function->deprecated_method)) {
if (call_user_func(array($function->classname, $function->deprecated_method)) === true) {
$function->deprecated = true;
}
}
$function->allowed_from_ajax = false;
// fetch the parameters description
$function->parameters_desc = call_user_func(array($function->classname, $function->parameters_method));
if (!($function->parameters_desc instanceof external_function_parameters)) {
throw new coding_exception('Invalid parameters description');
}
// fetch the return values description
$function->returns_desc = call_user_func(array($function->classname, $function->returns_method));
// null means void result or result is ignored
if (!is_null($function->returns_desc) and !($function->returns_desc instanceof external_description)) {
throw new coding_exception('Invalid return description');
}
//now get the function description
//TODO MDL-31115 use localised lang pack descriptions, it would be nice to have
// easy to understand descriptions in admin UI,
// on the other hand this is still a bit in a flux and we need to find some new naming
// conventions for these descriptions in lang packs
$function->description = null;
$servicesfile = core_component::get_component_directory($function->component).'/db/services.php';
if (file_exists($servicesfile)) {
$functions = null;
include($servicesfile);
if (isset($functions[$function->name]['description'])) {
$function->description = $functions[$function->name]['description'];
}
if (isset($functions[$function->name]['testclientpath'])) {
$function->testclientpath = $functions[$function->name]['testclientpath'];
}
if (isset($functions[$function->name]['type'])) {
$function->type = $functions[$function->name]['type'];
}
if (isset($functions[$function->name]['ajax'])) {
$function->allowed_from_ajax = $functions[$function->name]['ajax'];
} else if (method_exists($function->classname, $function->ajax_method)) {
if (call_user_func(array($function->classname, $function->ajax_method)) === true) {
debugging('External function ' . $function->ajax_method . '() function is deprecated.' .
'Set ajax=>true in db/service.php instead.', DEBUG_DEVELOPER);
$function->allowed_from_ajax = true;
}
}
if (isset($functions[$function->name]['loginrequired'])) {
$function->loginrequired = $functions[$function->name]['loginrequired'];
} else {
$function->loginrequired = true;
}
}
return $function;
return external_api::external_function_info($function, $strictness);
}
/**
......@@ -161,6 +70,195 @@ class external_api {
/** @var stdClass context where the function calls will be restricted */
private static $contextrestriction;
/**
* Returns detailed function information
*
* @param string|object $function name of external function or record from external_function
* @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
* MUST_EXIST means throw exception if no record or multiple records found
* @return stdClass description or false if not found or exception thrown
* @since Moodle 2.0
*/
public static function external_function_info($function, $strictness=MUST_EXIST) {
global $DB, $CFG;
if (!is_object($function)) {
if (!$function = $DB->get_record('external_functions', array('name' => $function), '*', $strictness)) {
return false;
}
}
// First try class autoloading.
if (!class_exists($function->classname)) {
// Fallback to explicit include of externallib.php.
if (empty($function->classpath)) {
$function->classpath = core_component::get_component_directory($function->component).'/externallib.php';
} else {
$function->classpath = $CFG->dirroot.'/'.$function->classpath;
}
if (!file_exists($function->classpath)) {
throw new coding_exception('Cannot find file with external function implementation');
}
require_once($function->classpath);
if (!class_exists($function->classname)) {
throw new coding_exception('Cannot find external class');
}
}
$function->ajax_method = $function->methodname.'_is_allowed_from_ajax';
$function->parameters_method = $function->methodname.'_parameters';
$function->returns_method = $function->methodname.'_returns';
$function->deprecated_method = $function->methodname.'_is_deprecated';
// Make sure the implementaion class is ok.
if (!method_exists($function->classname, $function->methodname)) {
throw new coding_exception('Missing implementation method of '.$function->classname.'::'.$function->methodname);
}
if (!method_exists($function->classname, $function->parameters_method)) {
throw new coding_exception('Missing parameters description');
}
if (!method_exists($function->classname, $function->returns_method)) {
throw new coding_exception('Missing returned values description');
}
if (method_exists($function->classname, $function->deprecated_method)) {
if (call_user_func(array($function->classname, $function->deprecated_method)) === true) {
$function->deprecated = true;
}
}
$function->allowed_from_ajax = false;
// Fetch the parameters description.
$function->parameters_desc = call_user_func(array($function->classname, $function->parameters_method));
if (!($function->parameters_desc instanceof external_function_parameters)) {
throw new coding_exception('Invalid parameters description');
}
// Fetch the return values description.
$function->returns_desc = call_user_func(array($function->classname, $function->returns_method));
// Null means void result or result is ignored.
if (!is_null($function->returns_desc) and !($function->returns_desc instanceof external_description)) {
throw new coding_exception('Invalid return description');
}
// Now get the function description.
// TODO MDL-31115 use localised lang pack descriptions, it would be nice to have
// easy to understand descriptions in admin UI,
// on the other hand this is still a bit in a flux and we need to find some new naming
// conventions for these descriptions in lang packs.
$function->description = null;
$servicesfile = core_component::get_component_directory($function->component).'/db/services.php';
if (file_exists($servicesfile)) {
$functions = null;
include($servicesfile);
if (isset($functions[$function->name]['description'])) {
$function->description = $functions[$function->name]['description'];
}
if (isset($functions[$function->name]['testclientpath'])) {
$function->testclientpath = $functions[$function->name]['testclientpath'];
}
if (isset($functions[$function->name]['type'])) {
$function->type = $functions[$function->name]['type'];
}
if (isset($functions[$function->name]['ajax'])) {
$function->allowed_from_ajax = $functions[$function->name]['ajax'];
} else if (method_exists($function->classname, $function->ajax_method)) {
if (call_user_func(array($function->classname, $function->ajax_method)) === true) {
debugging('External function ' . $function->ajax_method . '() function is deprecated.' .
'Set ajax=>true in db/service.php instead.', DEBUG_DEVELOPER);
$function->allowed_from_ajax = true;
}
}
if (isset($functions[$function->name]['loginrequired'])) {
$function->loginrequired = $functions[$function->name]['loginrequired'];
} else {
$function->loginrequired = true;
}
}
return $function;
}
/**
* Call an external function validating all params/returns correctly.
*
* Note that an external function may modify the state of the current page, so this wrapper
* saves and restores tha PAGE and COURSE global variables before/after calling the external function.
*
* @param string $function A webservice function name.
* @param array $args Params array (named params)
* @param boolean $ajaxonly If true, an extra check will be peformed to see if ajax is required.
* @return array containing keys for error (bool), exception and data.
*/
public static function call_external_function($function, $args, $ajaxonly=false) {
global $PAGE, $COURSE, $CFG, $SITE;
require_once($CFG->libdir . "/pagelib.php");
$externalfunctioninfo = self::external_function_info($function);
$currentpage = $PAGE;
$currentcourse = $COURSE;
$response = array();
try {
$PAGE = new moodle_page();
$COURSE = clone($SITE);
if ($ajaxonly && !$externalfunctioninfo->allowed_from_ajax) {
throw new moodle_exception('servicenotavailable', 'webservice');
}
// Do not allow access to write or delete webservices as a public user.
if ($externalfunctioninfo->loginrequired) {
if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
throw new moodle_exception('servicenotavailable', 'webservice');
}
if (!isloggedin()) {
throw new moodle_exception('servicenotavailable', 'webservice');
} else {
require_sesskey();
}
}
// Validate params, this also sorts the params properly, we need the correct order in the next part.
$callable = array($externalfunctioninfo->classname, 'validate_parameters');
$params = call_user_func($callable,
$externalfunctioninfo->parameters_desc,
$args);
// Execute - gulp!
$callable = array($externalfunctioninfo->classname, $externalfunctioninfo->methodname);
$result = call_user_func_array($callable,
array_values($params));
// Validate the return parameters.
if ($externalfunctioninfo->returns_desc !== null) {
$callable = array($externalfunctioninfo->classname, 'clean_returnvalue');
$result = call_user_func($callable, $externalfunctioninfo->returns_desc, $result);
}
$response['error'] = false;
$response['data'] = $result;
} catch (Exception $e) {
$exception = get_exception_info($e);
unset($exception->a);
if (!debugging('', DEBUG_DEVELOPER)) {
unset($exception->debuginfo);
unset($exception->backtrace);
}
$response['error'] = true;
$response['exception'] = $exception;
// Do not process the remaining requests.
}
$PAGE = $currentpage;
$COURSE = $currentcourse;
return $response;
}
/**
* Set context restriction for all following subsequent function calls.
*
......@@ -359,7 +457,7 @@ class external_api {
* @since Moodle 2.0
*/
public static function validate_context($context) {
global $CFG;
global $CFG, $PAGE;
if (empty($context)) {
throw new invalid_parameter_exception('Context does not exist');
......@@ -382,10 +480,10 @@ class external_api {
}
}
if ($context->contextlevel >= CONTEXT_COURSE) {
list($context, $course, $cm) = get_context_info_array($context->id);
require_login($course, false, $cm, false, true);
}
$PAGE->reset_theme_and_output();
list($unused, $course, $cm) = get_context_info_array($context->id);
require_login($course, false, $cm, false, true);
$PAGE->set_context($context);
}
/**
......
......@@ -981,7 +981,6 @@ class moodle_page {
}
return;
}
// Ideally we should set context only once.
if (isset($this->_context) && $context->id !== $this->_context->id) {
$current = $this->_context->contextlevel;
......@@ -993,11 +992,7 @@ class moodle_page {
} else {
// We do not want devs to do weird switching of context levels on the fly because we might have used
// the context already such as in text filter in page title.
// This is explicitly allowed for webservices though which may
// call "external_api::validate_context on many contexts in a single request.
if (!WS_SERVER) {
debugging("Coding problem: unsupported modification of PAGE->context from {$current} to {$context->contextlevel}");
}
debugging("Coding problem: unsupported modification of PAGE->context from {$current} to {$context->contextlevel}");
}
}
......@@ -1560,6 +1555,22 @@ class moodle_page {
$this->_wherethemewasinitialised = debug_backtrace();
}
/**
* Reset the theme and output for a new context. This only makes sense from
* external::validate_context(). Do not cheat.
*
* @return string the name of the theme that should be used on this page.
*/
public function reset_theme_and_output() {
global $COURSE, $SITE;
$COURSE = clone($SITE);
$this->_theme = null;
$this->_wherethemewasinitialised = null;
$this->_course = null;
$this->_context = null;
}
/**
* Work out the theme this page should use.
*
......
......@@ -388,6 +388,46 @@ class core_externallib_testcase extends advanced_testcase {
// The extra course passed is not returned.
$this->assertArrayNotHasKey($c4->id, $courses);
}
public function test_call_external_function() {
global $PAGE, $COURSE;
$this->resetAfterTest(true);
// Call some webservice functions and verify they are correctly handling $PAGE and $COURSE.
// First test a function that calls validate_context outside a course.
$this->setAdminUser();
$category = $this->getDataGenerator()->create_category();
$params = array(
'contextid' => context_coursecat::instance($category->id)->id,
'name' => 'aaagrrryyy',
'idnumber' => '',
'description' => ''
);
$cohort1 = $this->getDataGenerator()->create_cohort($params);
$cohort2 = $this->getDataGenerator()->create_cohort();
$beforepage = $PAGE;
$beforecourse = $COURSE;
$params = array('cohortids' => array($cohort1->id, $cohort2->id));
$result = external_api::call_external_function('core_cohort_get_cohorts', $params);
$this->assertSame($beforepage, $PAGE);
$this->assertSame($beforecourse, $COURSE);
// Now test a function that calls validate_context inside a course.
$course = $this->getDataGenerator()->create_course();
$beforepage = $PAGE;
$beforecourse = $COURSE;
$params = array('courseid' => $course->id, 'options' => array());
$result = external_api::call_external_function('core_enrol_get_enrolled_users', $params);
$this->assertSame($beforepage, $PAGE);
$this->assertSame($beforecourse, $COURSE);
}
}
/*
......
......@@ -5,6 +5,8 @@ information provided here is intended especially for developers.
* Webservice function core_course_search_courses accepts a new parameter 'limittoenrolled' to filter the results
only to courses the user is enrolled in, and are visible to them.
* External functions that are not calling external_api::validate_context are buggy and will now generate
exceptions. Previously they were only generating warnings in the webserver error log.
* The moodle/blog:associatecourse and moodle/blog:associatemodule capabilities has been removed.
* The following functions has been finally deprecated and can not be used any more:
- profile_display_badges()
......
......@@ -346,10 +346,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
$discussions = mod_forum_external::get_forum_discussions(array($forum1->id, $forum2->id));
$discussions = external_api::clean_returnvalue(mod_forum_external::get_forum_discussions_returns(), $discussions);
$this->assertEquals($expecteddiscussions, $discussions);
// Some debugging is going to be produced, this is because we switch PAGE contexts in the get_forum_discussions function,
// the switch happens when the validate_context function is called inside a foreach loop.
// See MDL-41746 for more information.
$this->assertDebuggingCalled();
// Remove the users post from the qanda forum and ensure they can still see the discussion.
$DB->delete_records('forum_posts', array('id' => $discussion2reply1->id));
......@@ -365,7 +361,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
} catch (moodle_exception $e) {
$this->assertEquals('nopermissions', $e->errorcode);
}
$this->assertDebuggingCalled();
// Unenrol user from second course.
$enrol->unenrol_user($instance2, $user1->id);
......@@ -857,8 +852,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
mod_forum_external::add_discussion_post($discussion->firstpost, 'some subject', 'some text here...');
$this->fail('Exception expected due to invalid permissions for posting.');
} catch (moodle_exception $e) {
// Expect debugging since we are switching context, and this is something WS_SERVER mode don't like.
$this->assertDebuggingCalled();
$this->assertEquals('nopostforum', $e->errorcode);
}
......
......@@ -302,7 +302,6 @@ class core_tag_external extends external_api {
$context = $params['ctx'] ? context::instance_by_id($params['ctx']) : context_system::instance();
require_login(null, false, null, false, true);
self::validate_context($context);
$PAGE->set_context(null);
$tag = core_tag_tag::get_by_name($params['tc'], $params['tag'], '*', MUST_EXIST);
$tagareas = core_tag_collection::get_areas($params['tc']);
......
......@@ -50,6 +50,7 @@ function tag_page_type_list($pagetype, $parentcontext, $currentcontext) {
* @return \core\output\inplace_editable
*/
function core_tag_inplace_editable($itemtype, $itemid, $newvalue) {
\external_api::validate_context(context_system::instance());
if ($itemtype === 'tagname') {
return \core_tag\output\tagname::update($itemid, $newvalue);
} else if ($itemtype === 'tagareaenable') {
......
......@@ -157,6 +157,7 @@ class core_tag_external_testcase extends externallib_advanced_testcase {
$this->resetAfterTest(true);
$tag = $this->getDataGenerator()->create_tag();
$this->setUser($this->getDataGenerator()->create_user());
// Call service for core_tag component without necessary permissions.
try {
......