diff --git a/lang/en/admin.php b/lang/en/admin.php
index 3b5f902b6ec..9559b7003d1 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -795,6 +795,8 @@ $string['notifyloginthreshold'] = 'Threshold for email notifications';
$string['notloggedinroleid'] = 'Role for visitors';
$string['numberofmissingstrings'] = 'Number of missing strings: {$a}';
$string['numberofstrings'] = 'Total number of strings: {$a->strings}
Missing: {$a->missing} ({$a->missingpercent} %)';
+$string['oauthrefreshtokenexpired'] = 'The refresh token for one of the OAuth services {$a->issuer} on your site {$a->siteurl} has expired. This will limit the functionality of any plugins that use this service. To fix this issue, visit the OAuth 2 Services configuration page and click on the "Connect system account" icon in the table row for this service. Be sure to login using the same service account for the OAuth system each time.';
+$string['oauthrefreshtokenexpiredshort'] = 'OAuth refresh token expired for {$a->issuer} on your site {$a->siteurl}.';
$string['onlynoreply'] = 'Only when from a no-reply address';
$string['opcacherecommended'] = 'PHP opcode caching improves performance and lowers memory requirements, OPcache extension is recommended and fully supported.';
$string['opensslrecommended'] = 'Installing the optional OpenSSL library is highly recommended -- it enables Moodle Networking functionality.';
@@ -1094,6 +1096,7 @@ $string['taskpasswordresetcleanup'] = 'Cleanup password reset attempts';
$string['taskplagiarismcron'] = 'Background processing for legacy cron in plagiarism plugins';
$string['taskportfoliocron'] = 'Background processing for portfolio plugins';
$string['taskquestioncron'] = 'Background processing for question engine';
+$string['taskrefreshoauthtokens'] = 'Refresh OAuth tokens for service accounts';
$string['taskregistrationcron'] = 'Site registration';
$string['tasksendfailedloginnotifications'] = 'Send failed login notifications';
$string['tasksendnewuserpasswords'] = 'Send new user passwords';
diff --git a/lang/en/calendar.php b/lang/en/calendar.php
index 9782884e5f2..a24c0f32cb6 100644
--- a/lang/en/calendar.php
+++ b/lang/en/calendar.php
@@ -117,6 +117,7 @@ $string['eventsubscriptioneditwarning'] = 'This calendar event is part of a subs
$string['expired'] = 'Expired';
$string['explain_site_timeformat'] = 'You can choose to see times in either 12 or 24 hour format for the whole site. If you choose "default", then the format will be automatically chosen according to the language you use in the site. This setting can be overridden by user preferences.';
$string['export'] = 'Export';
+$string['exporthelp'] = 'How do I subscribe to this calendar from a calendar application (Google/Outlook/Other)?';
$string['exportbutton'] = 'Export';
$string['exportcalendar'] = 'Export calendar';
$string['forcecalendartype'] = 'Force calendar';
diff --git a/lang/en/repository.php b/lang/en/repository.php
index d76784a5b7b..1a89457bfbb 100644
--- a/lang/en/repository.php
+++ b/lang/en/repository.php
@@ -160,6 +160,7 @@ $string['lostsource'] = 'Error. Source is missing. {$a}';
$string['makefileinternal'] = 'Make a copy of the file';
$string['makefilelink'] = 'Link to the file directly';
$string['makefilereference'] = 'Create an alias/shortcut to the file';
+$string['makefilecontrolledlink'] = 'Create an access controlled link to the file';
$string['manage'] = 'Manage repositories';
$string['manageinstances'] = 'Manage instances';
$string['manageurl'] = 'Manage';
@@ -232,6 +233,7 @@ $string['unknownoriginal'] = 'Unknown';
$string['upload'] = 'Upload this file';
$string['uploading'] = 'Uploading...';
$string['uploadsucc'] = 'The file has been uploaded successfully';
+$string['unknownsource'] = 'Unknown source';
$string['undisclosedsource'] = '(Undisclosed)';
$string['undisclosedreference'] = '(Undisclosed)';
$string['uselatestfile'] = 'Use latest file';
diff --git a/lib/authlib.php b/lib/authlib.php
index 09aa59d0584..0fea5319156 100644
--- a/lib/authlib.php
+++ b/lib/authlib.php
@@ -102,26 +102,7 @@ class auth_plugin_base {
* The fields we can lock and update from/to external authentication backends
* @var array
*/
- var $userfields = array(
- 'firstname',
- 'lastname',
- 'email',
- 'city',
- 'country',
- 'lang',
- 'description',
- 'url',
- 'idnumber',
- 'institution',
- 'department',
- 'phone1',
- 'phone2',
- 'address',
- 'firstnamephonetic',
- 'lastnamephonetic',
- 'middlename',
- 'alternatename'
- );
+ var $userfields = \core_user::AUTHSYNCFIELDS;
/**
* Moodle custom fields to sync with.
diff --git a/lib/classes/filetypes.php b/lib/classes/filetypes.php
index b2ac0620be8..97ff4568271 100644
--- a/lib/classes/filetypes.php
+++ b/lib/classes/filetypes.php
@@ -100,6 +100,12 @@ abstract class core_filetypes {
'gallery' => array('type' => 'application/x-smarttech-notebook', 'icon' => 'archive'),
'galleryitem' => array('type' => 'application/x-smarttech-notebook', 'icon' => 'archive'),
'gallerycollection' => array('type' => 'application/x-smarttech-notebook', 'icon' => 'archive'),
+ 'gdraw' => array('type' => 'application/vnd.google-apps.drawing', 'icon' => 'image', 'groups' => array('image')),
+ 'gdoc' => array('type' => 'application/vnd.google-apps.document', 'icon' => 'document', 'groups' => array('document')),
+ 'gsheet' => array('type' => 'application/vnd.google-apps.spreadsheet', 'icon' => 'spreadsheet',
+ 'groups' => array('spreadsheet')),
+ 'gslides' => array('type' => 'application/vnd.google-apps.presentation', 'icon' => 'powerpoint',
+ 'groups' => array('presentation')),
'gif' => array('type' => 'image/gif', 'icon' => 'gif', 'groups' => array('image', 'web_image'), 'string' => 'image'),
'gtar' => array('type' => 'application/x-gtar', 'icon' => 'archive',
'groups' => array('archive'), 'string' => 'archive'),
diff --git a/lib/classes/oauth2/api.php b/lib/classes/oauth2/api.php
new file mode 100644
index 00000000000..d7c0cad7d42
--- /dev/null
+++ b/lib/classes/oauth2/api.php
@@ -0,0 +1,765 @@
+.
+
+/**
+ * Class for loading/storing oauth2 endpoints from the DB.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+
+use context_system;
+use curl;
+use stdClass;
+use moodle_exception;
+use moodle_url;
+
+
+/**
+ * Static list of api methods for system oauth2 configuration.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api {
+
+ /**
+ * Create a google ready OAuth 2 service.
+ * @return core\oauth2\issuer
+ */
+ private static function create_google() {
+ $record = (object) [
+ 'name' => 'Google',
+ 'image' => 'https://accounts.google.com/favicon.ico',
+ 'baseurl' => 'http://accounts.google.com/',
+ 'loginparamsoffline' => 'access_type=offline&prompt=consent',
+ 'showonloginpage' => true
+ ];
+
+ $issuer = new issuer(0, $record);
+ $issuer->create();
+
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'name' => 'discovery_endpoint',
+ 'url' => 'https://accounts.google.com/.well-known/openid-configuration'
+ ];
+ $endpoint = new endpoint(0, $record);
+ $endpoint->create();
+ return $issuer;
+ }
+
+ /**
+ * Create a facebook ready OAuth 2 service.
+ * @return core\oauth2\issuer
+ */
+ private static function create_facebook() {
+ // Facebook is a custom setup.
+ $record = (object) [
+ 'name' => 'Facebook',
+ 'image' => 'https://facebookbrand.com/wp-content/themes/fb-branding/prj-fb-branding/assets/images/fb-art.png',
+ 'baseurl' => '',
+ 'loginscopes' => 'public_profile email',
+ 'loginscopesoffline' => 'public_profile email',
+ 'showonloginpage' => true
+ ];
+
+ $issuer = new issuer(0, $record);
+ $issuer->create();
+
+ $endpoints = [
+ 'authorization_endpoint' => 'https://www.facebook.com/v2.8/dialog/oauth',
+ 'token_endpoint' => 'https://graph.facebook.com/v2.8/oauth/access_token',
+ 'userinfo_endpoint' => 'https://graph.facebook.com/v2.8/me?fields=id,first_name,last_name,link,picture,name,email'
+ ];
+
+ foreach ($endpoints as $name => $url) {
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'name' => $name,
+ 'url' => $url
+ ];
+ $endpoint = new endpoint(0, $record);
+ $endpoint->create();
+ }
+
+ // Create the field mappings.
+ $mapping = [
+ 'name' => 'alternatename',
+ 'last_name' => 'lastname',
+ 'email' => 'email',
+ 'first_name' => 'firstname',
+ 'picture-data-url' => 'picture',
+ 'link' => 'url',
+ ];
+ foreach ($mapping as $external => $internal) {
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'externalfield' => $external,
+ 'internalfield' => $internal
+ ];
+ $userfieldmapping = new user_field_mapping(0, $record);
+ $userfieldmapping->create();
+ }
+ return $issuer;
+ }
+
+ /**
+ * Create a microsoft ready OAuth 2 service.
+ * @return core\oauth2\issuer
+ */
+ private static function create_microsoft() {
+ // Microsoft is a custom setup.
+ $record = (object) [
+ 'name' => 'Microsoft',
+ 'image' => 'https://www.microsoft.com/favicon.ico',
+ 'baseurl' => '',
+ 'loginscopes' => 'openid profile email user.read',
+ 'loginscopesoffline' => 'openid profile email user.read offline_access',
+ 'showonloginpage' => true
+ ];
+
+ $issuer = new issuer(0, $record);
+ $issuer->create();
+
+ $endpoints = [
+ 'authorization_endpoint' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+ 'token_endpoint' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
+ 'userinfo_endpoint' => 'https://graph.microsoft.com/v1.0/me/',
+ 'userpicture_endpoint' => 'https://graph.microsoft.com/v1.0/me/photo/$value',
+ ];
+
+ foreach ($endpoints as $name => $url) {
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'name' => $name,
+ 'url' => $url
+ ];
+ $endpoint = new endpoint(0, $record);
+ $endpoint->create();
+ }
+
+ // Create the field mappings.
+ $mapping = [
+ 'givenName' => 'firstname',
+ 'surname' => 'lastname',
+ 'userPrincipalName' => 'email',
+ 'displayName' => 'alternatename',
+ 'officeLocation' => 'address',
+ 'mobilePhone' => 'phone1',
+ 'preferredLanguage' => 'lang'
+ ];
+ foreach ($mapping as $external => $internal) {
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'externalfield' => $external,
+ 'internalfield' => $internal
+ ];
+ $userfieldmapping = new user_field_mapping(0, $record);
+ $userfieldmapping->create();
+ }
+ return $issuer;
+ }
+
+ /**
+ * Create one of the standard issuers.
+ * @param string $type One of google, facebook, microsoft
+ * @return \core\oauth2\issuer
+ */
+ public static function create_standard_issuer($type) {
+ require_capability('moodle/site:config', context_system::instance());
+ if ($type == 'google') {
+ return self::create_google();
+ } else if ($type == 'microsoft') {
+ return self::create_microsoft();
+ } else if ($type == 'facebook') {
+ return self::create_facebook();
+ } else {
+ throw new moodle_exception('OAuth 2 service type not recognised: ' . $type);
+ }
+ }
+
+ /**
+ * List all the issuers, ordered by the sortorder field
+ * @return core\oauth2\issuer[]
+ */
+ public static function get_all_issuers() {
+ return issuer::get_records([], 'sortorder');
+ }
+
+ /**
+ * Get a single issuer by id.
+ *
+ * @param int $id
+ * @return core\oauth2\issuer
+ */
+ public static function get_issuer($id) {
+ return new issuer($id);
+ }
+
+ /**
+ * Get a single endpoint by id.
+ *
+ * @param int $id
+ * @return core\oauth2\endpoint
+ */
+ public static function get_endpoint($id) {
+ return new endpoint($id);
+ }
+
+ /**
+ * Get a single user field mapping by id.
+ *
+ * @param int $id
+ * @return core\oauth2\user_field_mapping
+ */
+ public static function get_user_field_mapping($id) {
+ return new user_field_mapping($id);
+ }
+
+ /**
+ * Get the system account for an installed OAuth service.
+ * Never ever ever expose this to a webservice because it contains the refresh token which grants API access.
+ *
+ * @param \core\oauth2\issuer $issuer
+ * @return \core\oauth2\client
+ */
+ public static function get_system_account(issuer $issuer) {
+ return system_account::get_record(['issuerid' => $issuer->get('id')]);
+ }
+
+ /**
+ * Get the full list of system scopes required by an oauth issuer.
+ * This includes the list required for login as well as any scopes injected by the oauth2_system_scopes callback in plugins.
+ *
+ * @param \core\oauth2\issuer $issuer
+ * @return string
+ */
+ public static function get_system_scopes_for_issuer($issuer) {
+ $scopes = $issuer->get('loginscopesoffline');
+
+ $pluginsfunction = get_plugins_with_function('oauth2_system_scopes', 'lib.php');
+ foreach ($pluginsfunction as $plugintype => $plugins) {
+ foreach ($plugins as $pluginfunction) {
+ // Get additional scopes from the plugin.
+ $pluginscopes = $pluginfunction($issuer);
+ if (empty($pluginscopes)) {
+ continue;
+ }
+
+ // Merge the additional scopes with the existing ones.
+ $additionalscopes = explode(' ', $pluginscopes);
+
+ foreach ($additionalscopes as $scope) {
+ if (!empty($scope)) {
+ if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
+ $scopes .= ' ' . $scope;
+ }
+ }
+ }
+ }
+ }
+
+ return $scopes;
+ }
+
+ /**
+ * Get an authenticated oauth2 client using the system account.
+ * This call uses the refresh token to get an access token.
+ *
+ * @param core\oauth2\issuer $issuer
+ * @return core\oauth2\client
+ */
+ public static function get_system_oauth_client(issuer $issuer) {
+ $systemaccount = self::get_system_account($issuer);
+ if (empty($systemaccount)) {
+ return false;
+ }
+ // Get all the scopes!
+ $scopes = self::get_system_scopes_for_issuer($issuer);
+
+ $client = new \core\oauth2\client($issuer, null, $scopes, true);
+
+ if (!$client->is_logged_in()) {
+ if (!$client->upgrade_refresh_token($systemaccount)) {
+ return false;
+ }
+ }
+ return $client;
+ }
+
+ /**
+ * Get an authenticated oauth2 client using the current user account.
+ * This call does the redirect dance back to the current page after authentication.
+ *
+ * @param core\oauth2\issuer $issuer The desired OAuth issuer
+ * @param moodle_url $currenturl The url to the current page.
+ * @param string $additionalscopes The additional scopes required for authorization.
+ * @return core\oauth2\client
+ */
+ public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
+ $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
+
+ return $client;
+ }
+
+ /**
+ * Get the list of defined endpoints for this OAuth issuer
+ *
+ * @param core\oauth2\issuer $issuer The desired OAuth issuer
+ * @return core\oauth2\endpoint[]
+ */
+ public static function get_endpoints(issuer $issuer) {
+ return endpoint::get_records(['issuerid' => $issuer->get('id')]);
+ }
+
+ /**
+ * Get the list of defined mapping from OAuth user fields to moodle user fields.
+ *
+ * @param core\oauth2\issuer $issuer The desired OAuth issuer
+ * @return core\oauth2\user_field_mapping[]
+ */
+ public static function get_user_field_mappings(issuer $issuer) {
+ return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
+ }
+
+ /**
+ * Guess an image from the discovery URL.
+ *
+ * @param core\oauth2\issuer $issuer The desired OAuth issuer
+ */
+ protected static function guess_image($issuer) {
+ if (empty($issuer->get('image'))) {
+ $baseurl = parse_url($issuer->get('discoveryurl'));
+ $imageurl = $baseurl['scheme'] . '://' . $baseurl['host'] . '/favicon.ico';
+ $issuer->set('image', $imageurl);
+ $issuer->update();
+ }
+ }
+
+ /**
+ * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints.
+ *
+ * @param issuer $issuer
+ * @return int The number of discovered services.
+ */
+ protected static function discover_endpoints($issuer) {
+ $curl = new curl();
+
+ if (empty($issuer->get('baseurl'))) {
+ return 0;
+ }
+
+ $url = $issuer->get_endpoint_url('discovery');
+ if (!$url) {
+ $url = $issuer->get('baseurl') . '/.well-known/openid-configuration';
+ }
+
+ if (!$json = $curl->get($url)) {
+ $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
+ throw new moodle_exception($msg);
+ }
+
+ if ($msg = $curl->error) {
+ throw new moodle_exception('Could not discover service endpoints: ' . $msg);
+ }
+
+ $info = json_decode($json);
+ if (empty($info)) {
+ $msg = 'Could not discover end points for identity issuer' . $issuer->get('name');
+ throw new moodle_exception($msg);
+ }
+
+ foreach (endpoint::get_records(['issuerid' => $issuer->get('id')]) as $endpoint) {
+ if ($endpoint->get('name') != 'discovery_endpoint') {
+ $endpoint->delete();
+ }
+ }
+
+ foreach ($info as $key => $value) {
+ if (substr_compare($key, '_endpoint', - strlen('_endpoint')) === 0) {
+ $record = new stdClass();
+ $record->issuerid = $issuer->get('id');
+ $record->name = $key;
+ $record->url = $value;
+
+ $endpoint = new endpoint(0, $record);
+ $endpoint->create();
+ }
+
+ if ($key == 'scopes_supported') {
+ $issuer->set('scopessupported', implode(' ', $value));
+ $issuer->update();
+ }
+ }
+
+ // We got to here - must be a decent OpenID connect service. Add the default user field mapping list.
+ foreach (user_field_mapping::get_records(['issuerid' => $issuer->get('id')]) as $userfieldmapping) {
+ $userfieldmapping->delete();
+ }
+
+ // Create the field mappings.
+ $mapping = [
+ 'given_name' => 'firstname',
+ 'middle_name' => 'middlename',
+ 'family_name' => 'lastname',
+ 'email' => 'email',
+ 'website' => 'url',
+ 'nickname' => 'alternatename',
+ 'picture' => 'picture',
+ 'address' => 'address',
+ 'phone' => 'phone1',
+ 'locale' => 'lang'
+ ];
+ foreach ($mapping as $external => $internal) {
+ $record = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'externalfield' => $external,
+ 'internalfield' => $internal
+ ];
+ $userfieldmapping = new user_field_mapping(0, $record);
+ $userfieldmapping->create();
+ }
+
+ return endpoint::count_records(['issuerid' => $issuer->get('id')]);
+ }
+
+ /**
+ * Take the data from the mform and update the issuer.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\issuer
+ */
+ public static function update_issuer($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $issuer = new issuer(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $issuer->update();
+
+ // Perform service discovery.
+ self::discover_endpoints($issuer);
+ self::guess_image($issuer);
+ return $issuer;
+ }
+
+ /**
+ * Take the data from the mform and create the issuer.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\issuer
+ */
+ public static function create_issuer($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $issuer = new issuer(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $issuer->create();
+
+ // Perform service discovery.
+ self::discover_endpoints($issuer);
+ self::guess_image($issuer);
+ return $issuer;
+ }
+
+ /**
+ * Take the data from the mform and update the endpoint.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\endpoint
+ */
+ public static function update_endpoint($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $endpoint = new endpoint(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $endpoint->update();
+
+ return $endpoint;
+ }
+
+ /**
+ * Take the data from the mform and create the endpoint.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\endpoint
+ */
+ public static function create_endpoint($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $endpoint = new endpoint(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $endpoint->create();
+ return $endpoint;
+ }
+
+ /**
+ * Take the data from the mform and update the user field mapping.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\user_field_mapping
+ */
+ public static function update_user_field_mapping($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $userfieldmapping = new user_field_mapping(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $userfieldmapping->update();
+
+ return $userfieldmapping;
+ }
+
+ /**
+ * Take the data from the mform and create the user field mapping.
+ *
+ * @param stdClass $data
+ * @return core\oauth2\user_field_mapping
+ */
+ public static function create_user_field_mapping($data) {
+ require_capability('moodle/site:config', context_system::instance());
+ $userfieldmapping = new user_field_mapping(0, $data);
+
+ // Will throw exceptions on validation failures.
+ $userfieldmapping->create();
+ return $userfieldmapping;
+ }
+
+ /**
+ * Reorder this identity issuer.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the identity issuer to move.
+ * @return boolean
+ */
+ public static function move_up_issuer($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $current = new issuer($id);
+
+ $sortorder = $current->get('sortorder');
+ if ($sortorder == 0) {
+ return false;
+ }
+
+ $sortorder = $sortorder - 1;
+ $current->set('sortorder', $sortorder);
+
+ $filters = array('sortorder' => $sortorder);
+ $children = issuer::get_records($filters, 'id');
+ foreach ($children as $needtoswap) {
+ $needtoswap->set('sortorder', $sortorder + 1);
+ $needtoswap->update();
+ }
+
+ // OK - all set.
+ $result = $current->update();
+
+ return $result;
+ }
+
+ /**
+ * Reorder this identity issuer.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the identity issuer to move.
+ * @return boolean
+ */
+ public static function move_down_issuer($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $current = new issuer($id);
+
+ $max = issuer::count_records();
+ if ($max > 0) {
+ $max--;
+ }
+
+ $sortorder = $current->get('sortorder');
+ if ($sortorder >= $max) {
+ return false;
+ }
+ $sortorder = $sortorder + 1;
+ $current->set('sortorder', $sortorder);
+
+ $filters = array('sortorder' => $sortorder);
+ $children = issuer::get_records($filters);
+ foreach ($children as $needtoswap) {
+ $needtoswap->set('sortorder', $sortorder - 1);
+ $needtoswap->update();
+ }
+
+ // OK - all set.
+ $result = $current->update();
+
+ return $result;
+ }
+
+ /**
+ * Disable an identity issuer.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the identity issuer to disable.
+ * @return boolean
+ */
+ public static function disable_issuer($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $issuer = new issuer($id);
+
+ $issuer->set('enabled', 0);
+ return $issuer->update();
+ }
+
+
+ /**
+ * Enable an identity issuer.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the identity issuer to enable.
+ * @return boolean
+ */
+ public static function enable_issuer($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $issuer = new issuer($id);
+
+ $issuer->set('enabled', 1);
+ return $issuer->update();
+ }
+
+ /**
+ * Delete an identity issuer.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the identity issuer to delete.
+ * @return boolean
+ */
+ public static function delete_issuer($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $issuer = new issuer($id);
+
+ $systemaccount = self::get_system_account($issuer);
+ if ($systemaccount) {
+ $systemaccount->delete();
+ }
+ $endpoints = self::get_endpoints($issuer);
+ if ($endpoints) {
+ foreach ($endpoints as $endpoint) {
+ $endpoint->delete();
+ }
+ }
+
+ // Will throw exceptions on validation failures.
+ return $issuer->delete();
+ }
+
+ /**
+ * Delete an endpoint.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the endpoint to delete.
+ * @return boolean
+ */
+ public static function delete_endpoint($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $endpoint = new endpoint($id);
+
+ // Will throw exceptions on validation failures.
+ return $endpoint->delete();
+ }
+
+ /**
+ * Delete a user_field_mapping.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param int $id The id of the user_field_mapping to delete.
+ * @return boolean
+ */
+ public static function delete_user_field_mapping($id) {
+ require_capability('moodle/site:config', context_system::instance());
+ $userfieldmapping = new user_field_mapping($id);
+
+ // Will throw exceptions on validation failures.
+ return $userfieldmapping->delete();
+ }
+
+ /**
+ * Perform the OAuth dance and get a refresh token.
+ *
+ * Requires moodle/site:config capability at the system context.
+ *
+ * @param core\oauth2\issuer $issuer
+ * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
+ * @return boolean
+ */
+ public static function connect_system_account($issuer, $returnurl) {
+ require_capability('moodle/site:config', context_system::instance());
+
+ // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access.
+ $scopes = self::get_system_scopes_for_issuer($issuer);
+
+ // Allow callbacks to inject non-standard scopes to the auth request.
+
+ $client = new client($issuer, $returnurl, $scopes, true);
+
+ if (!optional_param('response', false, PARAM_BOOL)) {
+ $client->log_out();
+ }
+
+ if (optional_param('error', '', PARAM_RAW)) {
+ return false;
+ }
+
+ if (!$client->is_logged_in()) {
+ redirect($client->get_login_url());
+ }
+
+ $refreshtoken = $client->get_refresh_token();
+ if (!$refreshtoken) {
+ return false;
+ }
+
+ $systemaccount = self::get_system_account($issuer);
+ if ($systemaccount) {
+ $systemaccount->delete();
+ }
+
+ $userinfo = $client->get_userinfo();
+
+ $record = new stdClass();
+ $record->issuerid = $issuer->get('id');
+ $record->refreshtoken = $refreshtoken;
+ $record->grantedscopes = $scopes;
+ $record->email = $userinfo['email'];
+ $record->username = $userinfo['username'];
+
+ $systemaccount = new system_account(0, $record);
+
+ $systemaccount->create();
+
+ $client->log_out();
+ return true;
+ }
+}
diff --git a/lib/classes/oauth2/client.php b/lib/classes/oauth2/client.php
new file mode 100644
index 00000000000..5f2b8c2870a
--- /dev/null
+++ b/lib/classes/oauth2/client.php
@@ -0,0 +1,299 @@
+.
+
+/**
+ * Configurable oauth2 client class.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/oauthlib.php');
+require_once($CFG->libdir . '/filelib.php');
+
+use moodle_url;
+use curl;
+use stdClass;
+
+/**
+ * Configurable oauth2 client class where the urls come from DB.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class client extends \oauth2_client {
+
+ /** @var \core\oauth2\issuer $issuer */
+ private $issuer;
+
+ /** @var bool $system */
+ protected $system = false;
+
+ /**
+ * Constructor.
+ *
+ * @param issuer $issuer
+ * @param moodle_url|null $returnurl
+ * @param string $scopesrequired
+ * @param boolean $system
+ */
+ public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false) {
+ $this->issuer = $issuer;
+ $this->system = $system;
+ $scopes = $this->get_login_scopes();
+ $additionalscopes = explode(' ', $scopesrequired);
+
+ foreach ($additionalscopes as $scope) {
+ if (!empty($scope)) {
+ if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) {
+ $scopes .= ' ' . $scope;
+ }
+ }
+ }
+ if (empty($returnurl)) {
+ $returnurl = new moodle_url('/');
+ }
+ parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes);
+ }
+
+ /**
+ * Returns the auth url for OAuth 2.0 request
+ * @return string the auth url
+ */
+ protected function auth_url() {
+ return $this->issuer->get_endpoint_url('authorization');
+ }
+
+ /**
+ * Get the oauth2 issuer for this client.
+ *
+ * @return \core\oauth2\issuer Issuer
+ */
+ public function get_issuer() {
+ return $this->issuer;
+ }
+
+ /**
+ * Override to append additional params to a authentication request.
+ *
+ * @return array (name value pairs).
+ */
+ public function get_additional_login_parameters() {
+ $params = '';
+ if ($this->system) {
+ if (!empty($this->issuer->get('loginparamsoffline'))) {
+ $params = $this->issuer->get('loginparamsoffline');
+ }
+ } else {
+ if (!empty($this->issuer->get('loginparams'))) {
+ $params = $this->issuer->get('loginparams');
+ }
+ }
+ if (empty($params)) {
+ return [];
+ }
+ $result = [];
+ parse_str($params, $result);
+ return $result;
+ }
+
+ /**
+ * Override to change the scopes requested with an authentiction request.
+ *
+ * @return string
+ */
+ protected function get_login_scopes() {
+ if ($this->system) {
+ return $this->issuer->get('loginscopesoffline');
+ } else {
+ return $this->issuer->get('loginscopes');
+ }
+ }
+
+ /**
+ * Returns the token url for OAuth 2.0 request
+ *
+ * We are overriding the parent function so we get this from the configured endpoint.
+ *
+ * @return string the auth url
+ */
+ protected function token_url() {
+ return $this->issuer->get_endpoint_url('token');
+ }
+
+ /**
+ * We want a unique key for each issuer / and a different key for system vs user oauth.
+ *
+ * @return string The unique key for the session value.
+ */
+ protected function get_tokenname() {
+ $name = 'oauth2-state-' . $this->issuer->get('id');
+ if ($this->system) {
+ $name .= '-system';
+ }
+ return $name;
+ }
+
+ /**
+ * Get a list of the mapping user fields in an associative array.
+ *
+ * @return array
+ */
+ protected function get_userinfo_mapping() {
+ $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]);
+
+ $map = [];
+ foreach ($fields as $field) {
+ $map[$field->get('externalfield')] = $field->get('internalfield');
+ }
+ return $map;
+ }
+
+ /**
+ * Upgrade a refresh token from oauth 2.0 to an access token
+ *
+ * @param \core\oauth2\system_account $systemaccount
+ * @return boolean true if token is upgraded succesfully
+ */
+ public function upgrade_refresh_token(system_account $systemaccount) {
+ $refreshtoken = $systemaccount->get('refreshtoken');
+
+ $params = array('refresh_token' => $refreshtoken,
+ 'client_id' => $this->issuer->get('clientid'),
+ 'client_secret' => $this->issuer->get('clientsecret'),
+ 'grant_type' => 'refresh_token'
+ );
+
+ // Requests can either use http GET or POST.
+ if ($this->use_http_get()) {
+ $response = $this->get($this->token_url(), $params);
+ } else {
+ $response = $this->post($this->token_url(), $this->build_post_data($params));
+ }
+
+ if (!$this->info['http_code'] === 200) {
+ throw new moodle_exception('Could not upgrade oauth token');
+ }
+
+ $r = json_decode($response);
+
+ if (!empty($r->error)) {
+ throw new moodle_exception($r->error . ' ' . $r->error_description);
+ }
+
+ if (!isset($r->access_token)) {
+ return false;
+ }
+
+ if (isset($r->refresh_token)) {
+ $systemaccount->set('refreshtoken', $r->refresh_token);
+ $systemaccount->update();
+ $this->refreshtoken = $r->refresh_token;
+ }
+
+ // Store the token an expiry time.
+ $accesstoken = new stdClass;
+ $accesstoken->token = $r->access_token;
+ if (isset($r->expires_in)) {
+ // Expires 10 seconds before actual expiry.
+ $accesstoken->expires = (time() + ($r->expires_in - 10));
+ }
+ if (isset($r->scope)) {
+ $accesstoken->scope = $r->scope;
+ } else {
+ $accesstoken->scope = $this->scope;
+ }
+ // Also add the scopes.
+ $this->store_token($accesstoken);
+
+ return true;
+ }
+
+ /**
+ * Fetch the user info from the user info endpoint and map all
+ * the fields back into moodle fields.
+ *
+ * @return array (Moodle user fields for the logged in user).
+ */
+ public function get_userinfo() {
+ $url = $this->get_issuer()->get_endpoint_url('userinfo');
+ $response = $this->get($url);
+ if (!$response) {
+ return false;
+ }
+ $userinfo = new stdClass();
+ try {
+ $userinfo = json_decode($response);
+ } catch (Exception $e) {
+ return false;
+ }
+
+ $map = $this->get_userinfo_mapping();
+
+ $user = new stdClass();
+ foreach ($map as $openidproperty => $moodleproperty) {
+ // We support nested objects via a-b-c syntax.
+ $getfunc = function($obj, $prop) use (&$getfunc) {
+ $proplist = explode('-', $prop, 2);
+ if (empty($proplist[0]) || empty($obj->{$proplist[0]})) {
+ return false;
+ }
+ $obj = $obj->{$proplist[0]};
+
+ if (count($proplist) > 1) {
+ return $getfunc($obj, $proplist[1]);
+ }
+ return $obj;
+ };
+
+ $resolved = $getfunc($userinfo, $openidproperty);
+ if (!empty($resolved)) {
+ $user->$moodleproperty = $resolved;
+ }
+ }
+
+ if (empty($user->username) && !empty($user->email)) {
+ $user->username = $user->email;
+ }
+
+ if (!empty($user->picture)) {
+ $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false);
+ } else {
+ $pictureurl = $this->issuer->get_endpoint_url('userpicture');
+ if (!empty($pictureurl)) {
+ $user->picture = $this->get($pictureurl);
+ }
+ }
+
+ if (!empty($user->picture)) {
+ // If it doesn't look like a picture lets unset it.
+ if (function_exists('imagecreatefromstring')) {
+ $img = @imagecreatefromstring($user->picture);
+ if (empty($img)) {
+ unset($user->picture);
+ } else {
+ imagedestroy($img);
+ }
+ }
+ }
+
+ return (array)$user;
+ }
+}
diff --git a/lib/classes/oauth2/endpoint.php b/lib/classes/oauth2/endpoint.php
new file mode 100644
index 00000000000..21451966180
--- /dev/null
+++ b/lib/classes/oauth2/endpoint.php
@@ -0,0 +1,73 @@
+.
+
+/**
+ * Class for loading/storing oauth2 endpoints from the DB.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+use lang_string;
+
+/**
+ * Class for loading/storing oauth2 endpoints from the DB
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class endpoint extends persistent {
+
+ const TABLE = 'oauth2_endpoint';
+
+ /**
+ * Return the definition of the properties of this model.
+ *
+ * @return array
+ */
+ protected static function define_properties() {
+ return array(
+ 'issuerid' => array(
+ 'type' => PARAM_INT
+ ),
+ 'name' => array(
+ 'type' => PARAM_ALPHANUMEXT,
+ ),
+ 'url' => array(
+ 'type' => PARAM_URL,
+ )
+ );
+ }
+
+ /**
+ * Custom validator for end point URLs.
+ * Because we send Bearer tokens we must ensure SSL.
+ *
+ * @param string $value The value to check.
+ * @return lang_string|boolean
+ */
+ protected function validate_url($value) {
+ if (strpos($value, 'https://') !== 0) {
+ return new lang_string('sslonlyaccess', 'error');
+ }
+ return true;
+ }
+}
diff --git a/lib/classes/oauth2/issuer.php b/lib/classes/oauth2/issuer.php
new file mode 100644
index 00000000000..e58b07c1b89
--- /dev/null
+++ b/lib/classes/oauth2/issuer.php
@@ -0,0 +1,188 @@
+.
+
+/**
+ * Class for loading/storing issuers from the DB.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class for loading/storing issuer from the DB
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class issuer extends persistent {
+
+ const TABLE = 'oauth2_issuer';
+
+ /**
+ * Return the definition of the properties of this model.
+ *
+ * @return array
+ */
+ protected static function define_properties() {
+ return array(
+ 'name' => array(
+ 'type' => PARAM_TEXT
+ ),
+ 'image' => array(
+ 'type' => PARAM_URL,
+ 'null' => NULL_ALLOWED,
+ 'default' => null
+ ),
+ 'clientid' => array(
+ 'type' => PARAM_RAW_TRIMMED,
+ 'default' => ''
+ ),
+ 'clientsecret' => array(
+ 'type' => PARAM_RAW_TRIMMED,
+ 'default' => ''
+ ),
+ 'baseurl' => array(
+ 'type' => PARAM_URL,
+ 'default' => ''
+ ),
+ 'enabled' => array(
+ 'type' => PARAM_BOOL,
+ 'default' => true
+ ),
+ 'showonloginpage' => array(
+ 'type' => PARAM_BOOL,
+ 'default' => false
+ ),
+ 'scopessupported' => array(
+ 'type' => PARAM_RAW,
+ 'null' => NULL_ALLOWED,
+ 'default' => null
+ ),
+ 'loginscopes' => array(
+ 'type' => PARAM_RAW,
+ 'default' => 'openid profile email'
+ ),
+ 'loginscopesoffline' => array(
+ 'type' => PARAM_RAW,
+ 'default' => 'openid profile email'
+ ),
+ 'loginparams' => array(
+ 'type' => PARAM_RAW,
+ 'default' => ''
+ ),
+ 'loginparamsoffline' => array(
+ 'type' => PARAM_RAW,
+ 'default' => ''
+ ),
+ 'alloweddomains' => array(
+ 'type' => PARAM_RAW,
+ 'default' => ''
+ ),
+ 'sortorder' => array(
+ 'type' => PARAM_INT,
+ 'default' => 0,
+ )
+ );
+ }
+
+ /**
+ * Hook to execute before validate.
+ *
+ * @return void
+ */
+ protected function before_validate() {
+ if (($this->get('id') && $this->get('sortorder') === null) || !$this->get('id')) {
+ $this->set('sortorder', $this->count_records());
+ }
+ }
+
+ /**
+ * Helper the get a named service endpoint.
+ * @param string $type
+ * @return string|false
+ */
+ public function get_endpoint_url($type) {
+ $endpoint = endpoint::get_record([
+ 'issuerid' => $this->get('id'),
+ 'name' => $type . '_endpoint'
+ ]);
+
+ if ($endpoint) {
+ return $endpoint->get('url');
+ }
+ return false;
+ }
+
+ /**
+ * Perform matching against the list of allowed login domains for this issuer.
+ *
+ * @param string $email The email to check.
+ * @return boolean
+ */
+ public function is_valid_login_domain($email) {
+ if (empty($this->get('alloweddomains'))) {
+ return true;
+ }
+
+ $validdomains = explode(',', $this->get('alloweddomains'));
+
+ $parts = explode('@', $email, 2);
+ $emaildomain = '';
+ if (count($parts) > 1) {
+ $emaildomain = $parts[1];
+ }
+
+ return \core\ip_utils::is_domain_in_allowed_list($emaildomain, $validdomains);
+ }
+
+ /**
+ * Does this OAuth service support user authentication?
+ * @return boolean
+ */
+ public function is_authentication_supported() {
+ return (!empty($this->get_endpoint_url('userinfo')));
+ }
+
+ /**
+ * Return true if this issuer looks like it has been configured.
+ *
+ * @return boolean
+ */
+ public function is_configured() {
+ return (!empty($this->get('clientid')) && !empty($this->get('clientsecret')));
+ }
+
+ /**
+ * Do we have a refresh token for a system account?
+ * @return boolean
+ */
+ public function is_system_account_connected() {
+ if (!$this->is_configured()) {
+ return false;
+ }
+ $sys = system_account::get_record(['issuerid' => $this->get('id')]);
+ if (!empty($sys) and !empty($sys->get('refreshtoken'))) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/lib/classes/oauth2/refresh_system_tokens_task.php b/lib/classes/oauth2/refresh_system_tokens_task.php
new file mode 100644
index 00000000000..22cb45ce610
--- /dev/null
+++ b/lib/classes/oauth2/refresh_system_tokens_task.php
@@ -0,0 +1,94 @@
+.
+
+/**
+ * A scheduled task.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\oauth2;
+
+use \core\task\scheduled_task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Simple task to delete old messaging records.
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class refresh_system_tokens_task extends scheduled_task {
+
+ /**
+ * Get a descriptive name for this task (shown to admins).
+ *
+ * @return string
+ */
+ public function get_name() {
+ return get_string('taskrefreshsystemtokens', 'admin');
+ }
+
+ /**
+ * Notify admins when an OAuth refresh token expires. Should not happen if cron is running regularly.
+ * @param \core\oauth2\issuer $issuer
+ */
+ protected function notify_admins(\core\oauth2\issuer $issuer) {
+ $admins = get_admins();
+
+ if (empty($admins)) {
+ return;
+ }
+ foreach ($admins as $admin) {
+ $strparams = ['siteurl' => $CFG->wwwroot, 'issuer' => $issuer->get('name')];
+ $long = get_string('oauthrefreshtokenexpired', 'core_admin', $strparams);
+ $short = get_string('oauthrefreshtokenexpiredshort', 'core_admin', $strparams);
+ $message = new \core\message\message();
+ $message->courseid = SITEID;
+ $message->component = 'moodle';
+ $message->name = 'oauthrefreshtokenexpired';
+ $message->userfrom = core\user::get_noreply_user();
+ $message->userto = $admin;
+ $message->subject = $short;
+ $message->fullmessage = $long;
+ $message->fullmessageformat = FORMAT_PLAIN;
+ $message->fullmessagehtml = $long;
+ $message->smallmessage = $short;
+ $message->notification = 1;
+ message_send($message);
+ }
+ }
+
+
+ /**
+ * Do the job.
+ * Throw exceptions on errors (the job will be retried).
+ */
+ public function execute() {
+ $issuers = \core\oauth2\api::get_all_issuers();
+ foreach ($issuers as $issuer) {
+ if ($issuer->is_system_account_connected()) {
+ if (!\core\oauth2\api::get_system_oauth_client($issuer)) {
+ $this->notify_admins($issuer);
+ }
+ }
+ }
+ }
+
+}
diff --git a/lib/classes/oauth2/rest.php b/lib/classes/oauth2/rest.php
new file mode 100644
index 00000000000..b4a78732999
--- /dev/null
+++ b/lib/classes/oauth2/rest.php
@@ -0,0 +1,126 @@
+.
+
+/**
+ * Rest API base class mapping rest api methods to endpoints with http methods, args and post body.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+use curl;
+use coding_exception;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+
+/**
+ * Rest API base class mapping rest api methods to endpoints with http methods, args and post body.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class rest {
+
+ /** @var curl $curl */
+ private $curl;
+
+ /**
+ * Constructor.
+ *
+ * @param curl $curl
+ */
+ public function __construct(curl $curl) {
+ $this->curl = $curl;
+ }
+
+ /**
+ * Abstract function to define the functions of the rest API.
+ *
+ * @return array Example:
+ * [ 'listFiles' => [ 'method' => 'get', 'args' => [ 'folder' => PARAM_STRING ], 'response' => 'json' ] ]
+ */
+ public abstract function get_api_functions();
+
+ /**
+ * Call a function from the Api with a set of arguments and optional data.
+ *
+ * @param string $functionname
+ * @param array $functionargs
+ * @param string $rawpost Optional param to include in the body of a post.
+ * @return string|object
+ */
+ public function call($functionname, $functionargs, $rawpost = false) {
+ $functions = $this->get_api_functions();
+ $supportedmethods = [ 'get', 'put', 'post', 'patch', 'head', 'delete' ];
+ if (empty($functions[$functionname])) {
+ throw new coding_exception('unsupported api functionname: ' . $functionname);
+ }
+
+ $method = $functions[$functionname]['method'];
+ $endpoint = $functions[$functionname]['endpoint'];
+
+ $responsetype = $functions[$functionname]['response'];
+ if (!in_array($method, $supportedmethods)) {
+ throw new coding_exception('unsupported api method: ' . $method);
+ }
+
+ $args = $functions[$functionname]['args'];
+ $callargs = [];
+ foreach ($args as $argname => $argtype) {
+ if (isset($functionargs[$argname])) {
+ $callargs[$argname] = clean_param($functionargs[$argname], $argtype);
+ }
+ }
+
+ // Allow params in the URL path like /me/{parent}/children.
+ foreach ($callargs as $argname => $value) {
+ $newendpoint = str_replace('{' . $argname . '}', $value, $endpoint);
+ if ($newendpoint != $endpoint) {
+ $endpoint = $newendpoint;
+ unset($callargs[$argname]);
+ }
+ }
+
+ if ($rawpost !== false) {
+ $queryparams = $this->curl->build_post_data($callargs);
+ if (!empty($queryparams)) {
+ $endpoint .= '?' . $queryparams;
+ }
+ $callargs = $rawpost;
+ }
+
+ $this->curl->setHeader('Content-type: application/json');
+ $response = $this->curl->$method($endpoint, $callargs);
+
+ if ($this->curl->errno == 0) {
+ if ($responsetype == 'json') {
+ $json = json_decode($response);
+
+ if (!empty($json->error)) {
+ throw new rest_exception($json->error->code . ': ' . $json->error->message);
+ }
+ return $json;
+ }
+ return $response;
+ } else {
+ throw new rest_exception($this->curl->error, $this->curl->errno);
+ }
+ }
+}
diff --git a/lib/classes/oauth2/rest_exception.php b/lib/classes/oauth2/rest_exception.php
new file mode 100644
index 00000000000..41994745a77
--- /dev/null
+++ b/lib/classes/oauth2/rest_exception.php
@@ -0,0 +1,40 @@
+.
+
+/**
+ * Rest Exception class containing error code and message.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+use Exception;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+
+/**
+ * Rest Exception class containing error code and message.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class rest_exception extends Exception {
+
+}
diff --git a/lib/classes/oauth2/system_account.php b/lib/classes/oauth2/system_account.php
new file mode 100644
index 00000000000..74aa5c81611
--- /dev/null
+++ b/lib/classes/oauth2/system_account.php
@@ -0,0 +1,64 @@
+.
+
+/**
+ * When using OAuth sometimes it makes sense to authenticate as a system user, and not the current user.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class for loading/storing oauth2 refresh tokens from the DB.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class system_account extends persistent {
+
+ const TABLE = 'oauth2_system_account';
+
+ /**
+ * Return the definition of the properties of this model.
+ *
+ * @return array
+ */
+ protected static function define_properties() {
+ return array(
+ 'issuerid' => array(
+ 'type' => PARAM_INT
+ ),
+ 'refreshtoken' => array(
+ 'type' => PARAM_RAW,
+ ),
+ 'grantedscopes' => array(
+ 'type' => PARAM_RAW,
+ ),
+ 'email' => array(
+ 'type' => PARAM_RAW,
+ ),
+ 'username' => array(
+ 'type' => PARAM_RAW,
+ )
+ );
+ }
+}
diff --git a/lib/classes/oauth2/user_field_mapping.php b/lib/classes/oauth2/user_field_mapping.php
new file mode 100644
index 00000000000..33b203a5843
--- /dev/null
+++ b/lib/classes/oauth2/user_field_mapping.php
@@ -0,0 +1,77 @@
+.
+
+/**
+ * Class for loading/storing oauth2 endpoints from the DB.
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class for loading/storing oauth2 user field mappings from the DB
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_field_mapping extends persistent {
+
+ const TABLE = 'oauth2_user_field_mapping';
+
+ /**
+ * Return the list of valid internal user fields.
+ *
+ * @return array
+ */
+ private static function get_user_fields() {
+ return array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username']);
+ }
+
+ /**
+ * Return the definition of the properties of this model.
+ *
+ * @return array
+ */
+ protected static function define_properties() {
+ return array(
+ 'issuerid' => array(
+ 'type' => PARAM_INT
+ ),
+ 'externalfield' => array(
+ 'type' => PARAM_ALPHANUMEXT,
+ ),
+ 'internalfield' => array(
+ 'type' => PARAM_ALPHANUMEXT,
+ 'choices' => self::get_user_fields()
+ )
+ );
+ }
+
+ /**
+ * Return the list of internal fields
+ * in a format they can be used for choices in a select menu
+ * @return array
+ */
+ public function get_internalfield_list() {
+ return array_combine(self::get_user_fields(), self::get_user_fields());
+ }
+}
diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php
index 5f54b5621ce..88193ff83b4 100644
--- a/lib/classes/plugin_manager.php
+++ b/lib/classes/plugin_manager.php
@@ -1701,7 +1701,7 @@ class core_plugin_manager {
'auth' => array(
'cas', 'db', 'email', 'fc', 'imap', 'ldap', 'lti', 'manual', 'mnet',
- 'nntp', 'nologin', 'none', 'pam', 'pop3', 'shibboleth', 'webservice'
+ 'nntp', 'nologin', 'none', 'oauth2', 'pam', 'pop3', 'shibboleth', 'webservice'
),
'availability' => array(
@@ -1902,7 +1902,7 @@ class core_plugin_manager {
'assignmentupgrade', 'availabilityconditions', 'behat', 'capability', 'cohortroles', 'customlang',
'dbtransfer', 'filetypes', 'generator', 'health', 'innodb', 'installaddon',
'langimport', 'log', 'lp', 'lpimportcsv', 'lpmigrate', 'messageinbound', 'mobile', 'multilangupgrade', 'monitor',
- 'phpunit', 'profiling', 'recyclebin', 'replace', 'spamcleaner', 'task', 'templatelibrary',
+ 'oauth2', 'phpunit', 'profiling', 'recyclebin', 'replace', 'spamcleaner', 'task', 'templatelibrary',
'unittest', 'uploadcourse', 'uploaduser', 'unsuproles', 'usertours', 'xmldb'
),
diff --git a/lib/classes/user.php b/lib/classes/user.php
index 8ae89dcc87e..9c95cc2132c 100644
--- a/lib/classes/user.php
+++ b/lib/classes/user.php
@@ -58,6 +58,30 @@ class core_user {
*/
const MAILDISPLAY_COURSE_MEMBERS_ONLY = 2;
+ /**
+ * List of fields that can be synched/locked during authentication.
+ */
+ const AUTHSYNCFIELDS = [
+ 'firstname',
+ 'lastname',
+ 'email',
+ 'city',
+ 'country',
+ 'lang',
+ 'description',
+ 'url',
+ 'idnumber',
+ 'institution',
+ 'department',
+ 'phone1',
+ 'phone2',
+ 'address',
+ 'firstnamephonetic',
+ 'lastnamephonetic',
+ 'middlename',
+ 'alternatename'
+ ];
+
/** @var stdClass keep record of noreply user */
public static $noreplyuser = false;
@@ -100,6 +124,29 @@ class core_user {
}
}
+ /**
+ * Return user object from db based on their email.
+ *
+ * @param string $email The email of the user searched.
+ * @param string $fields A comma separated list of user fields to be returned, support and noreply user.
+ * @param int $mnethostid The id of the remote host.
+ * @param int $strictness IGNORE_MISSING means compatible mode, false returned if user not found, debug message if more found;
+ * IGNORE_MULTIPLE means return first user, ignore multiple user records found(not recommended);
+ * MUST_EXIST means throw an exception if no user record or multiple records found.
+ * @return stdClass|bool user record if found, else false.
+ * @throws dml_exception if user record not found and respective $strictness is set.
+ */
+ public static function get_user_by_email($email, $fields = '*', $mnethostid = null, $strictness = IGNORE_MISSING) {
+ global $DB, $CFG;
+
+ // Because we use the username as the search criteria, we must also restrict our search based on mnet host.
+ if (empty($mnethostid)) {
+ // If empty, we restrict to local users.
+ $mnethostid = $CFG->mnet_localhost_id;
+ }
+
+ return $DB->get_record('user', array('email' => $email, 'mnethostid' => $mnethostid), $fields, $strictness);
+ }
/**
* Return user object from db based on their username.
@@ -864,4 +911,5 @@ class core_user {
return $value;
}
}
+
}
diff --git a/lib/db/install.xml b/lib/db/install.xml
old mode 100644
new mode 100755
index 2e52477544f..63545257b2e
--- a/lib/db/install.xml
+++ b/lib/db/install.xml
@@ -1,5 +1,5 @@
-
@@ -3463,5 +3463,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/db/tasks.php b/lib/db/tasks.php
index c820348d6c2..bdb87087fd5 100644
--- a/lib/db/tasks.php
+++ b/lib/db/tasks.php
@@ -347,4 +347,13 @@ $tasks = array(
'dayofweek' => '*',
'month' => '*'
),
+ array(
+ 'classname' => 'core\oauth2\refresh_system_tokens_task',
+ 'blocking' => 0,
+ 'minute' => '30',
+ 'hour' => '*',
+ 'day' => '*',
+ 'dayofweek' => '*',
+ 'month' => '*'
+ ),
);
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php
index 69ef988fa08..99798247d77 100644
--- a/lib/db/upgrade.php
+++ b/lib/db/upgrade.php
@@ -2611,5 +2611,126 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2017031400.00);
}
+ if ($oldversion < 2017033100.01) {
+
+ // Define table oauth2_issuer to be created.
+ $table = new xmldb_table('oauth2_issuer');
+
+ // Adding fields to table oauth2_issuer.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('image', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('baseurl', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('clientid', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('clientsecret', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('loginscopes', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('loginscopesoffline', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('loginparams', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('loginparamsoffline', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('alloweddomains', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('scopessupported', XMLDB_TYPE_TEXT, null, null, null, null, null);
+ $table->add_field('showonloginpage', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '1');
+ $table->add_field('enabled', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '1');
+ $table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+ // Adding keys to table oauth2_issuer.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+
+ // Conditionally launch create table for oauth2_issuer.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2017033100.01);
+ }
+
+ if ($oldversion < 2017033100.02) {
+
+ // Define table oauth2_endpoint to be created.
+ $table = new xmldb_table('oauth2_endpoint');
+
+ // Adding fields to table oauth2_endpoint.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('url', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+ // Adding keys to table oauth2_endpoint.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+ $table->add_key('issuer_id_key', XMLDB_KEY_FOREIGN, array('issuerid'), 'oauth2_issuer', array('id'));
+
+ // Conditionally launch create table for oauth2_endpoint.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2017033100.02);
+ }
+
+ if ($oldversion < 2017033100.03) {
+
+ // Define table oauth2_system_account to be created.
+ $table = new xmldb_table('oauth2_system_account');
+
+ // Adding fields to table oauth2_system_account.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('refreshtoken', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('grantedscopes', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('username', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('email', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+
+ // Adding keys to table oauth2_system_account.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+ $table->add_key('issueridkey', XMLDB_KEY_FOREIGN_UNIQUE, array('issuerid'), 'oauth2_issuer', array('id'));
+
+ // Conditionally launch create table for oauth2_system_account.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2017033100.03);
+ }
+
+ if ($oldversion < 2017033100.04) {
+
+ // Define table oauth2_user_field_mapping to be created.
+ $table = new xmldb_table('oauth2_user_field_mapping');
+
+ // Adding fields to table oauth2_user_field_mapping.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('externalfield', XMLDB_TYPE_CHAR, '64', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('internalfield', XMLDB_TYPE_CHAR, '64', null, XMLDB_NOTNULL, null, null);
+
+ // Adding keys to table oauth2_user_field_mapping.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+ $table->add_key('issuerkey', XMLDB_KEY_FOREIGN, array('issuerid'), 'oauth2_issuer', array('id'));
+ $table->add_key('uniqinternal', XMLDB_KEY_UNIQUE, array('issuerid', 'internalfield'));
+
+ // Conditionally launch create table for oauth2_user_field_mapping.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2017033100.04);
+ }
+
return true;
}
diff --git a/lib/filelib.php b/lib/filelib.php
index 9cca2b9ad70..0c314f547ba 100644
--- a/lib/filelib.php
+++ b/lib/filelib.php
@@ -813,7 +813,7 @@ function file_save_draft_area_files($draftitemid, $contextid, $component, $filea
$options['areamaxbytes'] = FILE_AREA_MAX_BYTES_UNLIMITED; // Unlimited.
}
$allowreferences = true;
- if (isset($options['return_types']) && !($options['return_types'] & FILE_REFERENCE)) {
+ if (isset($options['return_types']) && !($options['return_types'] & (FILE_REFERENCE | FILE_CONTROLLED_LINK))) {
// we assume that if $options['return_types'] is NOT specified, we DO allow references.
// this is not exactly right. BUT there are many places in code where filemanager options
// are not passed to file_save_draft_area_files()
@@ -953,8 +953,15 @@ function file_save_draft_area_files($draftitemid, $contextid, $component, $filea
if ($file->is_external_file()) {
$repoid = $file->get_repository_id();
if (!empty($repoid)) {
+ $context = context::instance_by_id($contextid, MUST_EXIST);
+ $repo = repository::get_repository_by_id($repoid, $context);
+
$file_record['repositoryid'] = $repoid;
- $file_record['reference'] = $file->get_reference();
+ // This hook gives the repo a place to do some house cleaning, and update the $reference before it's saved
+ // to the file store. E.g. transfer ownership of the file to a system account etc.
+ $reference = $repo->reference_file_selected($file->get_reference(), $context, $component, $filearea, $itemid);
+
+ $file_record['reference'] = $reference;
}
}
@@ -2741,6 +2748,8 @@ class curl {
private $securityhelper;
/** @var bool ignoresecurity a flag which can be supplied to the constructor, allowing security to be bypassed. */
private $ignoresecurity;
+ /** @var array $mockresponses For unit testing only - return the head of this list instead of making the next request. */
+ private static $mockresponses = [];
/**
* Curl constructor.
@@ -2949,6 +2958,7 @@ class curl {
* Set HTTP Request Header
*
* @param array $header
+ * @param bool $replace If true, will remove any existing headers before appending the new one.
*/
public function setHeader($header) {
if (is_array($header)) {
@@ -3264,6 +3274,19 @@ class curl {
$this->responsefinished = false;
}
+ /**
+ * For use only in unit tests - we can pre-set the next curl response.
+ * This is useful for unit testing APIs that call external systems.
+ * @param string $response
+ */
+ public static function mock_response($response) {
+ if ((defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
+ array_push(self::$mockresponses, $response);
+ } else {
+ throw new coding_excpetion('mock_response function is only available for unit tests.');
+ }
+ }
+
/**
* Single HTTP Request
*
@@ -3275,6 +3298,13 @@ class curl {
// Reset here so that the data is valid when result returned from cache, or if we return due to a blacklist hit.
$this->reset_request_state_vars();
+ if ((defined('PHPUNIT_TEST') && PHPUNIT_TEST)) {
+ if ($mockresponse = array_pop(self::$mockresponses)) {
+ $this->info = [ 'http_code' => 200 ];
+ return $mockresponse;
+ }
+ }
+
// If curl security is enabled, check the URL against the blacklist before calling curl_exec.
// Note: This will only check the base url. In the case of redirects, the blacklist is also after the curl_exec.
if (!$this->ignoresecurity && $this->securityhelper->url_is_blocked($url)) {
@@ -3435,6 +3465,34 @@ class curl {
return $this->request($url, $options);
}
+ /**
+ * HTTP PATCH method
+ *
+ * @param string $url
+ * @param array|string $params
+ * @param array $options
+ * @return bool
+ */
+ public function patch($url, $params = '', $options = array()) {
+ $options['CURLOPT_CUSTOMREQUEST'] = 'PATCH';
+ if (is_array($params)) {
+ $this->_tmp_file_post_params = array();
+ foreach ($params as $key => $value) {
+ if ($value instanceof stored_file) {
+ $value->add_to_curl_request($this, $key);
+ } else {
+ $this->_tmp_file_post_params[$key] = $value;
+ }
+ }
+ $options['CURLOPT_POSTFIELDS'] = $this->_tmp_file_post_params;
+ unset($this->_tmp_file_post_params);
+ } else {
+ // The variable $params is the raw post data.
+ $options['CURLOPT_POSTFIELDS'] = $params;
+ }
+ return $this->request($url, $options);
+ }
+
/**
* HTTP POST method
*
@@ -3798,9 +3856,11 @@ class curl_cache {
* @param string $relativepath
* @param bool $forcedownload
* @param null|string $preview the preview mode, defaults to serving the original file
+ * @param boolean $offline If offline is requested - don't serve a redirect to an external file, return a file suitable for viewing
+ * offline (e.g. mobile app).
* @todo MDL-31088 file serving improments
*/
-function file_pluginfile($relativepath, $forcedownload, $preview = null) {
+function file_pluginfile($relativepath, $forcedownload, $preview = null, $offline = false) {
global $DB, $CFG, $USER;
// relative path must start with '/'
if (!$relativepath) {
@@ -3824,6 +3884,8 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
$fs = get_file_storage();
+ $sendfileoptions = ['preview' => $preview, 'offline' => $offline];
+
// ========================================================================================================================
if ($component === 'blog') {
// Blog file serving
@@ -3876,7 +3938,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
send_file_not_found();
}
- send_stored_file($file, 10*60, 0, true, array('preview' => $preview)); // download MUST be forced - security!
+ send_stored_file($file, 10*60, 0, true, $sendfileoptions); // download MUST be forced - security!
// ========================================================================================================================
} else if ($component === 'grade') {
@@ -3893,7 +3955,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'feedback' and $context->contextlevel == CONTEXT_COURSE) {
//TODO: nobody implemented this yet in grade edit form!!
@@ -3910,7 +3972,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
}
@@ -3931,7 +3993,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, true, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, true, $sendfileoptions);
} else {
send_file_not_found();
@@ -3953,14 +4015,14 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close();
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'userbadge' and $context->contextlevel == CONTEXT_USER) {
if (!$file = $fs->get_file($context->id, 'badges', 'userbadge', $badge->id, '/', $filename.'.png')) {
send_file_not_found();
}
\core\session\manager::write_close();
- send_stored_file($file, 60*60, 0, true, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, true, $sendfileoptions);
}
// ========================================================================================================================
} else if ($component === 'calendar') {
@@ -3987,7 +4049,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'event_description' and $context->contextlevel == CONTEXT_USER) {
@@ -4015,7 +4077,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, true, array('preview' => $preview));
+ send_stored_file($file, 0, 0, true, $sendfileoptions);
} else if ($filearea === 'event_description' and $context->contextlevel == CONTEXT_COURSE) {
@@ -4062,7 +4124,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
@@ -4116,7 +4178,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
send_file($imagefile, basename($imagefile), 60*60*24*14);
}
- $options = array('preview' => $preview);
+ $options = $sendfileoptions;
if (empty($CFG->forcelogin) && empty($CFG->forceloginforprofileimage)) {
// Profile images should be cache-able by both browsers and proxies according
// to $CFG->forcelogin and $CFG->forceloginforprofileimage.
@@ -4142,7 +4204,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, true, array('preview' => $preview)); // must force download - security!
+ send_stored_file($file, 0, 0, true, $sendfileoptions); // must force download - security!
} else if ($filearea === 'profile' and $context->contextlevel == CONTEXT_USER) {
@@ -4189,7 +4251,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, true, array('preview' => $preview)); // must force download - security!
+ send_stored_file($file, 0, 0, true, $sendfileoptions); // must force download - security!
} else if ($filearea === 'profile' and $context->contextlevel == CONTEXT_COURSE) {
$userid = (int)array_shift($args);
@@ -4227,7 +4289,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, true, array('preview' => $preview)); // must force download - security!
+ send_stored_file($file, 0, 0, true, $sendfileoptions); // must force download - security!
} else if ($filearea === 'backup' and $context->contextlevel == CONTEXT_USER) {
require_login();
@@ -4248,7 +4310,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, true, array('preview' => $preview)); // must force download - security!
+ send_stored_file($file, 0, 0, true, $sendfileoptions); // must force download - security!
} else {
send_file_not_found();
@@ -4281,7 +4343,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
}
@@ -4304,7 +4366,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'section') {
if ($CFG->forcelogin) {
@@ -4326,7 +4388,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
@@ -4355,7 +4417,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
if (($file = $fs->get_file($cohortcontext->id, 'cohort', 'description', $cohort->id, $filepath, $filename))
&& !$file->is_directory()) {
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60 * 60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60 * 60, 0, $forcedownload, $sendfileoptions);
}
}
@@ -4387,7 +4449,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'icon') {
$filename = array_pop($args);
@@ -4402,7 +4464,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, false, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, false, $sendfileoptions);
} else {
send_file_not_found();
@@ -4427,7 +4489,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
@@ -4446,7 +4508,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 0, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'section' and $context->contextlevel == CONTEXT_COURSE) {
require_login($course);
@@ -4461,7 +4523,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close();
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'activity' and $context->contextlevel == CONTEXT_MODULE) {
require_login($course, false, $cm);
@@ -4474,7 +4536,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close();
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
} else if ($filearea === 'automated' and $context->contextlevel == CONTEXT_COURSE) {
// Backup files that were generated by the automated backup systems.
@@ -4489,7 +4551,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 0, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 0, 0, $forcedownload, $sendfileoptions);
} else {
send_file_not_found();
@@ -4498,7 +4560,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
// ========================================================================================================================
} else if ($component === 'question') {
require_once($CFG->libdir . '/questionlib.php');
- question_pluginfile($course, $context, 'question', $filearea, $args, $forcedownload);
+ question_pluginfile($course, $context, 'question', $filearea, $args, $forcedownload, $sendfileoptions);
send_file_not_found();
// ========================================================================================================================
@@ -4535,7 +4597,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
\core\session\manager::write_close(); // Unlock session during file serving.
- send_stored_file($file, 60*60, 0, $forcedownload, array('preview' => $preview));
+ send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
}
// ========================================================================================================================
@@ -4567,17 +4629,17 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
}
// finally send the file
- send_stored_file($file, null, 0, false, array('preview' => $preview));
+ send_stored_file($file, null, 0, false, $sendfileoptions);
}
$filefunction = $component.'_pluginfile';
$filefunctionold = $modname.'_pluginfile';
if (function_exists($filefunction)) {
// if the function exists, it must send the file and terminate. Whatever it returns leads to "not found"
- $filefunction($course, $cm, $context, $filearea, $args, $forcedownload, array('preview' => $preview));
+ $filefunction($course, $cm, $context, $filearea, $args, $forcedownload, $sendfileoptions);
} else if (function_exists($filefunctionold)) {
// if the function exists, it must send the file and terminate. Whatever it returns leads to "not found"
- $filefunctionold($course, $cm, $context, $filearea, $args, $forcedownload, array('preview' => $preview));
+ $filefunctionold($course, $cm, $context, $filearea, $args, $forcedownload, $sendfileoptions);
}
send_file_not_found();
@@ -4618,7 +4680,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
$filefunction = $component.'_pluginfile';
if (function_exists($filefunction)) {
// if the function exists, it must send the file and terminate. Whatever it returns leads to "not found"
- $filefunction($course, $birecord, $context, $filearea, $args, $forcedownload, array('preview' => $preview));
+ $filefunction($course, $birecord, $context, $filearea, $args, $forcedownload, $sendfileoptions);
}
send_file_not_found();
@@ -4639,7 +4701,7 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null) {
$filefunction = $component.'_pluginfile';
if (function_exists($filefunction)) {
// if the function exists, it must send the file and terminate. Whatever it returns leads to "not found"
- $filefunction($course, $cm, $context, $filearea, $args, $forcedownload, array('preview' => $preview));
+ $filefunction($course, $cm, $context, $filearea, $args, $forcedownload, $sendfileoptions);
}
send_file_not_found();
diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php
index 1cf24fcd59e..1b26eba8860 100644
--- a/lib/filestorage/file_storage.php
+++ b/lib/filestorage/file_storage.php
@@ -1128,9 +1128,9 @@ class file_storage {
// creating a new file from an existing alias creates new alias implicitly.
// here we just check the database consistency.
if (!empty($newrecord->repositoryid)) {
- if ($newrecord->referencefileid != $this->get_referencefileid($newrecord->repositoryid, $newrecord->reference, MUST_EXIST)) {
- throw new file_reference_exception($newrecord->repositoryid, $newrecord->reference, $newrecord->referencefileid);
- }
+ // It is OK if the current reference does not exist. It may have been altered by a repository plugin when the files
+ // where saved from a draft area.
+ $newrecord->referencefileid = $this->get_or_create_referencefileid($newrecord->repositoryid, $newrecord->reference);
}
try {
@@ -2323,4 +2323,5 @@ class file_storage {
$data = array('id' => $referencefileid, 'lastsync' => $lastsync);
$DB->update_record('files_reference', (object)$data);
}
+
}
diff --git a/lib/filestorage/stored_file.php b/lib/filestorage/stored_file.php
index 192e7b8daf2..92478b61114 100644
--- a/lib/filestorage/stored_file.php
+++ b/lib/filestorage/stored_file.php
@@ -102,6 +102,15 @@ class stored_file {
return !empty($this->repository);
}
+ /**
+ * Whether or not this is a controlled link. Note that repositories cannot support FILE_REFERENCE and FILE_CONTROLLED_LINK.
+ *
+ * @return bool
+ */
+ public function is_controlled_link() {
+ return $this->is_external_file() && $this->repository->supported_returntypes() & FILE_CONTROLLED_LINK;
+ }
+
/**
* Update some file record fields
* NOTE: Must remain protected
diff --git a/lib/form/editor.php b/lib/form/editor.php
index 551e30645b5..69a78127219 100644
--- a/lib/form/editor.php
+++ b/lib/form/editor.php
@@ -58,8 +58,8 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab
/** @var array options provided to initalize filepicker */
protected $_options = array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 0, 'changeformat' => 0,
'areamaxbytes' => FILE_AREA_MAX_BYTES_UNLIMITED, 'context' => null, 'noclean' => 0, 'trusttext' => 0,
- 'return_types' => 7, 'enable_filemanagement' => true);
- // $_options['return_types'] = FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE
+ 'return_types' => 15, 'enable_filemanagement' => true);
+ // 15 is $_options['return_types'] = FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK.
/** @var array values for editor */
protected $_values = array('text'=>null, 'format'=>null, 'itemid'=>null);
diff --git a/lib/form/filemanager.php b/lib/form/filemanager.php
index 0cbf543ea7e..1c9126fe0c0 100644
--- a/lib/form/filemanager.php
+++ b/lib/form/filemanager.php
@@ -78,7 +78,7 @@ class MoodleQuickForm_filemanager extends HTML_QuickForm_element implements temp
$this->_options['maxbytes'] = get_user_max_upload_file_size($PAGE->context, $CFG->maxbytes, $options['maxbytes']);
}
if (empty($options['return_types'])) {
- $this->_options['return_types'] = (FILE_INTERNAL | FILE_REFERENCE);
+ $this->_options['return_types'] = (FILE_INTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK);
}
$this->_type = 'filemanager';
parent::__construct($elementName, $elementLabel, $attributes);
diff --git a/lib/oauthlib.php b/lib/oauthlib.php
index 7d83e82e68b..c684c9c911a 100644
--- a/lib/oauthlib.php
+++ b/lib/oauthlib.php
@@ -383,16 +383,22 @@ class oauth_helper {
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class oauth2_client extends curl {
- /** var string client identifier issued to the client */
+ /** @var string $clientid client identifier issued to the client */
private $clientid = '';
- /** var string The client secret. */
+ /** @var string $clientsecret The client secret. */
private $clientsecret = '';
- /** var moodle_url URL to return to after authenticating */
+ /** @var moodle_url $returnurl URL to return to after authenticating */
private $returnurl = null;
- /** var string scope of the authentication request */
- private $scope = '';
- /** var stdClass access token object */
+ /** @var string $scope of the authentication request */
+ protected $scope = '';
+ /** @var stdClass $accesstoken access token object */
private $accesstoken = null;
+ /** @var string $refreshtoken refresh token string */
+ private $refreshtoken = '';
+ /** @var string $mocknextresponse string */
+ private $mocknextresponse = '';
+ /** @var array $upgradedcodes list of upgraded codes in this request */
+ private static $upgradedcodes = [];
/**
* Returns the auth url for OAuth 2.0 request
@@ -439,13 +445,28 @@ abstract class oauth2_client extends curl {
// We have a token so we are logged in.
if (isset($this->accesstoken->token)) {
- return true;
+ // Check that the access token has all the requested scopes.
+ $scopemissing = false;
+ $scopecheck = ' ' . $this->accesstoken->scope . ' ';
+
+ $requiredscopes = explode(' ', $this->scope);
+ foreach ($requiredscopes as $requiredscope) {
+ if (strpos($scopecheck, ' ' . $requiredscope . ' ') === false) {
+ $scopemissing = true;
+ break;
+ }
+ }
+ if (!$scopemissing) {
+ return true;
+ }
}
// If we've been passed then authorization code generated by the
// authorization server try and upgrade the token to an access token.
$code = optional_param('oauth2code', null, PARAM_RAW);
- if ($code && $this->upgrade_token($code)) {
+ // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt
+ // to upgrade the same token twice.
+ if ($code && !in_array($code, self::$upgradedcodes) && $this->upgrade_token($code)) {
return true;
}
@@ -463,6 +484,15 @@ abstract class oauth2_client extends curl {
return new moodle_url('/admin/oauth2callback.php');
}
+ /**
+ * An additional array of url params to pass with a login request.
+ *
+ * @return array of name value pairs.
+ */
+ public function get_additional_login_parameters() {
+ return [];
+ }
+
/**
* Returns the login link for this oauth request
*
@@ -471,15 +501,32 @@ abstract class oauth2_client extends curl {
public function get_login_url() {
$callbackurl = self::callback_url();
- $url = new moodle_url($this->auth_url(),
- array('client_id' => $this->clientid,
- 'response_type' => 'code',
- 'redirect_uri' => $callbackurl->out(false),
- 'state' => $this->returnurl->out_as_local_url(false),
- 'scope' => $this->scope,
- ));
+ $params = array_merge(
+ [
+ 'client_id' => $this->clientid,
+ 'response_type' => 'code',
+ 'redirect_uri' => $callbackurl->out(false),
+ 'state' => $this->returnurl->out_as_local_url(false),
+ 'scope' => $this->scope,
+ ],
+ $this->get_additional_login_parameters()
+ );
- return $url;
+ return new moodle_url($this->auth_url(), $params);
+ }
+
+ /**
+ * Given an array of name value pairs - build a valid HTTP POST application/x-www-form-urlencoded string.
+ *
+ * @param array $params Name / value pairs.
+ * @return string POST data.
+ */
+ public function build_post_data($params) {
+ $result = [];
+ foreach ($params as $name => $value) {
+ $result[] = str_replace('&', '%26', $name) . '=' . str_replace('&', '%26', $value);
+ }
+ return implode('&', $result);
}
/**
@@ -490,10 +537,10 @@ abstract class oauth2_client extends curl {
*/
public function upgrade_token($code) {
$callbackurl = self::callback_url();
- $params = array('client_id' => $this->clientid,
+ $params = array('code' => $code,
+ 'client_id' => $this->clientid,
'client_secret' => $this->clientsecret,
'grant_type' => 'authorization_code',
- 'code' => $code,
'redirect_uri' => $callbackurl->out(false),
);
@@ -501,7 +548,7 @@ abstract class oauth2_client extends curl {
if ($this->use_http_get()) {
$response = $this->get($this->token_url(), $params);
} else {
- $response = $this->post($this->token_url(), $params);
+ $response = $this->post($this->token_url(), $this->build_post_data($params));
}
if (!$this->info['http_code'] === 200) {
@@ -510,10 +557,18 @@ abstract class oauth2_client extends curl {
$r = json_decode($response);
+ if (!empty($r->error)) {
+ throw new moodle_exception($r->error . ' ' . $r->error_description);
+ }
+
if (!isset($r->access_token)) {
return false;
}
+ if (isset($r->refresh_token)) {
+ $this->refreshtoken = $r->refresh_token;
+ }
+
// Store the token an expiry time.
$accesstoken = new stdClass;
$accesstoken->token = $r->access_token;
@@ -521,6 +576,9 @@ abstract class oauth2_client extends curl {
// Expires 10 seconds before actual expiry.
$accesstoken->expires = (time() + ($r->expires_in - 10));
}
+ $accesstoken->scope = $this->scope;
+ // Also add the scopes.
+ self::$upgradedcodes[] = $code;
$this->store_token($accesstoken);
return true;
@@ -552,7 +610,11 @@ abstract class oauth2_client extends curl {
}
}
- return parent::request($murl->out(false), $options);
+ $response = parent::request($murl->out(false), $options);
+
+ $this->resetHeader();
+
+ return $response;
}
/**
@@ -603,6 +665,15 @@ abstract class oauth2_client extends curl {
}
}
+ /**
+ * Get a refresh token!!!
+ *
+ * @return string
+ */
+ public function get_refresh_token() {
+ return $this->refreshtoken;
+ }
+
/**
* Retrieve a token stored.
*
diff --git a/lib/templates/login.mustache b/lib/templates/login.mustache
index e30c537624f..a0545c38573 100644
--- a/lib/templates/login.mustache
+++ b/lib/templates/login.mustache
@@ -148,9 +148,9 @@
{{#identityproviders}}
diff --git a/lib/tests/oauth2_test.php b/lib/tests/oauth2_test.php
new file mode 100644
index 00000000000..0f19b6fc0d6
--- /dev/null
+++ b/lib/tests/oauth2_test.php
@@ -0,0 +1,207 @@
+.
+
+/**
+ * Tests for oauth2 apis (\core\oauth2\*).
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests for oauth2 apis (\core\oauth2\*).
+ *
+ * @package core
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
+ */
+class core_oauth2_testcase extends advanced_testcase {
+
+ /**
+ * Tests the crud operations on oauth2 issuers.
+ */
+ public function test_create_and_delete_standard_issuers() {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ \core\oauth2\api::create_standard_issuer('google');
+ \core\oauth2\api::create_standard_issuer('facebook');
+ \core\oauth2\api::create_standard_issuer('microsoft');
+
+ $issuers = \core\oauth2\api::get_all_issuers();
+
+ $this->assertEquals($issuers[0]->get('name'), 'Google');
+ $this->assertEquals($issuers[1]->get('name'), 'Facebook');
+ $this->assertEquals($issuers[2]->get('name'), 'Microsoft');
+
+ \core\oauth2\api::move_down_issuer($issuers[0]->get('id'));
+
+ $issuers = \core\oauth2\api::get_all_issuers();
+
+ $this->assertEquals($issuers[0]->get('name'), 'Facebook');
+ $this->assertEquals($issuers[1]->get('name'), 'Google');
+ $this->assertEquals($issuers[2]->get('name'), 'Microsoft');
+
+ \core\oauth2\api::delete_issuer($issuers[1]->get('id'));
+
+ $issuers = \core\oauth2\api::get_all_issuers();
+
+ $this->assertEquals($issuers[0]->get('name'), 'Facebook');
+ $this->assertEquals($issuers[1]->get('name'), 'Microsoft');
+ }
+
+ /**
+ * Tests we can list and delete each of the persistents related to an issuer.
+ */
+ public function test_getters() {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $issuer = \core\oauth2\api::create_standard_issuer('microsoft');
+
+ $same = \core\oauth2\api::get_issuer($issuer->get('id'));
+
+ foreach ($same->properties_definition() as $name => $def) {
+ $this->assertTrue($issuer->get($name) == $same->get($name));
+ }
+
+ $endpoints = \core\oauth2\api::get_endpoints($issuer);
+ $same = \core\oauth2\api::get_endpoint($endpoints[0]->get('id'));
+ $this->assertEquals($endpoints[0]->get('id'), $same->get('id'));
+ $this->assertEquals($endpoints[0]->get('name'), $same->get('name'));
+
+ $todelete = $endpoints[0];
+ \core\oauth2\api::delete_endpoint($todelete->get('id'));
+ $endpoints = \core\oauth2\api::get_endpoints($issuer);
+ $this->assertNotEquals($endpoints[0]->get('id'), $todelete->get('id'));
+
+ $userfields = \core\oauth2\api::get_user_field_mappings($issuer);
+ $same = \core\oauth2\api::get_user_field_mapping($userfields[0]->get('id'));
+ $this->assertEquals($userfields[0]->get('id'), $same->get('id'));
+
+ $todelete = $userfields[0];
+ \core\oauth2\api::delete_user_field_mapping($todelete->get('id'));
+ $userfields = \core\oauth2\api::get_user_field_mappings($issuer);
+ $this->assertNotEquals($userfields[0]->get('id'), $todelete->get('id'));
+ }
+
+ /**
+ * Tests we can get a logged in oauth client for a system account.
+ */
+ public function test_get_system_oauth_client() {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $issuer = \core\oauth2\api::create_standard_issuer('microsoft');
+
+ $requiredscopes = \core\oauth2\api::get_system_scopes_for_issuer($issuer);
+ // Fake a system account.
+ $data = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'refreshtoken' => 'abc',
+ 'grantedscopes' => $requiredscopes,
+ 'email' => 'sys@example.com',
+ 'username' => 'sys'
+ ];
+ $sys = new \core\oauth2\system_account(0, $data);
+ $sys->create();
+
+ // Fake a response with an access token.
+ $response = json_encode(
+ (object) [
+ 'access_token' => 'fdas...',
+ 'token_type' => 'Bearer',
+ 'expires_in' => '3600',
+ 'id_token' => 'llfsd..',
+ ]
+ );
+ curl::mock_response($response);
+ $client = \core\oauth2\api::get_system_oauth_client($issuer);
+ $this->assertTrue($client->is_logged_in());
+ }
+
+ /**
+ * Tests we can enable and disable an issuer.
+ */
+ public function test_enable_disable_issuer() {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $issuer = \core\oauth2\api::create_standard_issuer('microsoft');
+
+ $issuerid = $issuer->get('id');
+
+ \core\oauth2\api::enable_issuer($issuerid);
+ $check = \core\oauth2\api::get_issuer($issuer->get('id'));
+ $this->assertTrue((boolean)$check->get('enabled'));
+
+ \core\oauth2\api::enable_issuer($issuerid);
+ $check = \core\oauth2\api::get_issuer($issuer->get('id'));
+ $this->assertTrue((boolean)$check->get('enabled'));
+
+ \core\oauth2\api::disable_issuer($issuerid);
+ $check = \core\oauth2\api::get_issuer($issuer->get('id'));
+ $this->assertFalse((boolean)$check->get('enabled'));
+
+ \core\oauth2\api::enable_issuer($issuerid);
+ $check = \core\oauth2\api::get_issuer($issuer->get('id'));
+ $this->assertTrue((boolean)$check->get('enabled'));
+ }
+
+ /**
+ * Test the alloweddomains for an issuer.
+ */
+ public function test_issuer_alloweddomains() {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $issuer = \core\oauth2\api::create_standard_issuer('microsoft');
+
+ $issuer->set('alloweddomains', '');
+
+ // Anything is allowed when domain is empty.
+ $this->assertTrue($issuer->is_valid_login_domain(''));
+ $this->assertTrue($issuer->is_valid_login_domain('a@b'));
+ $this->assertTrue($issuer->is_valid_login_domain('longer.example@example.com'));
+
+ $issuer->set('alloweddomains', 'example.com');
+
+ // One domain - must match exactly - no substrings etc.
+ $this->assertFalse($issuer->is_valid_login_domain(''));
+ $this->assertFalse($issuer->is_valid_login_domain('a@b'));
+ $this->assertFalse($issuer->is_valid_login_domain('longer.example@example'));
+ $this->assertTrue($issuer->is_valid_login_domain('longer.example@example.com'));
+
+ $issuer->set('alloweddomains', 'example.com,example.net');
+ // Multiple domains - must match any exactly - no substrings etc.
+ $this->assertFalse($issuer->is_valid_login_domain(''));
+ $this->assertFalse($issuer->is_valid_login_domain('a@b'));
+ $this->assertFalse($issuer->is_valid_login_domain('longer.example@example'));
+ $this->assertFalse($issuer->is_valid_login_domain('invalid@email@example.net'));
+ $this->assertTrue($issuer->is_valid_login_domain('longer.example@example.net'));
+ $this->assertTrue($issuer->is_valid_login_domain('longer.example@example.com'));
+
+ $issuer->set('alloweddomains', '*.example.com');
+ // Wildcard.
+ $this->assertFalse($issuer->is_valid_login_domain(''));
+ $this->assertFalse($issuer->is_valid_login_domain('a@b'));
+ $this->assertFalse($issuer->is_valid_login_domain('longer.example@example'));
+ $this->assertFalse($issuer->is_valid_login_domain('longer.example@example.com'));
+ $this->assertTrue($issuer->is_valid_login_domain('longer.example@sub.example.com'));
+ }
+
+}
diff --git a/lib/upgrade.txt b/lib/upgrade.txt
index 1f7edcfdb01..3b0f4188c8b 100644
--- a/lib/upgrade.txt
+++ b/lib/upgrade.txt
@@ -1,6 +1,12 @@
This files describes API changes in core libraries and APIs,
information provided here is intended especially for developers.
=== 3.3 ===
+* The information returned by the idp list has changed. This is usually only rendered by the login page and login block.
+ The icon attribute is removed and an iconurl attribute has been added.
+* Support added for a new type of external file: FILE_CONTROLLED_LINK. This is an external file that Moodle can control
+ the permissions. Moodle makes files read-only but can grant temporary write access.
+ When accessing a URL, the info from file_browser::get_file_info will be checked to determine if the user has write access,
+ if they do - the remote file will have access controls set to allow editing.
* The method moodleform::after_definition() has been added and can now be used to add some logic
to be performed after the form's definition was set. This is useful for intermediate subclasses.
* Moodle has support for font-awesome icons. Plugins should use the xxx_get_fontawesome_icon_map callback
diff --git a/mod/assign/assignmentplugin.php b/mod/assign/assignmentplugin.php
index 8861aece6aa..22c43f82cf2 100644
--- a/mod/assign/assignmentplugin.php
+++ b/mod/assign/assignmentplugin.php
@@ -580,17 +580,30 @@ abstract class assign_plugin {
public function get_file_info($browser, $filearea, $itemid, $filepath, $filename) {
global $CFG, $DB, $USER;
$urlbase = $CFG->wwwroot.'/pluginfile.php';
-
+ $writeaccess = false;
// Permission check on the itemid.
+ $assignment = $this->assignment;
if ($this->get_subtype() == 'assignsubmission') {
if ($itemid) {
- $record = $DB->get_record('assign_submission', array('id'=>$itemid), 'userid', IGNORE_MISSING);
+ $record = $DB->get_record('assign_submission', array('id' => $itemid), 'userid,groupid', IGNORE_MISSING);
if (!$record) {
return null;
}
- if (!$this->assignment->can_view_submission($record->userid)) {
- return null;
+ if (!empty($record->userid)) {
+ if (!$assignment->can_view_submission($record->userid)) {
+ return null;
+ }
+
+ // We only report write access for teachers.
+ $writeaccess = $assignment->can_grade() && $assignment->can_edit_submission($record->userid);
+ } else {
+ // Must be a team submission with a group.
+ if (!$assignment->can_view_group_submission($record->groupid)) {
+ return null;
+ }
+ // We only report write access for teachers.
+ $writeaccess = $assignment->can_grade() && $assignment->can_edit_group_submission($record->groupid);
}
}
} else {
@@ -601,7 +614,7 @@ abstract class assign_plugin {
$fs = get_file_storage();
$filepath = is_null($filepath) ? '/' : $filepath;
$filename = is_null($filename) ? '.' : $filename;
- if (!($storedfile = $fs->get_file($this->assignment->get_context()->id,
+ if (!($storedfile = $fs->get_file($assignment->get_context()->id,
$this->get_subtype() . '_' . $this->get_type(),
$filearea,
$itemid,
@@ -609,14 +622,15 @@ abstract class assign_plugin {
$filename))) {
return null;
}
+
return new file_info_stored($browser,
- $this->assignment->get_context(),
+ $assignment->get_context(),
$storedfile,
$urlbase,
$filearea,
$itemid,
true,
- true,
+ $writeaccess,
false);
}
diff --git a/mod/assign/lang/en/assign.php b/mod/assign/lang/en/assign.php
index 9a4e07c6c34..46eaeb92d8e 100644
--- a/mod/assign/lang/en/assign.php
+++ b/mod/assign/lang/en/assign.php
@@ -155,6 +155,7 @@ $string['submissionmodifiedgroup'] = 'The submission has been modified by somebo
$string['duedatereached'] = 'The due date for this assignment has now passed';
$string['duedatevalidation'] = 'Due date must be after the allow submissions from date.';
$string['editattemptfeedback'] = 'Edit the grade and feedback for attempt number {$a}.';
+$string['editonline'] = 'Edit online';
$string['editingpreviousfeedbackwarning'] = 'You are editing the feedback for a previous attempt. This is attempt {$a->attemptnumber} out of {$a->totalattempts}.';
$string['editoverride'] = 'Edit override';
$string['editsubmission'] = 'Edit submission';
diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php
index aec06e8f61e..4deb7ba05d6 100644
--- a/mod/assign/locallib.php
+++ b/mod/assign/locallib.php
@@ -4360,6 +4360,25 @@ class assign {
return false;
}
+ /**
+ * Perform an access check to see if the current $USER can edit this group submission.
+ *
+ * @param int $groupid
+ * @return bool
+ */
+ public function can_edit_group_submission($groupid) {
+ global $USER;
+
+ $members = $this->get_submission_group_members($groupid, true);
+ foreach ($members as $member) {
+ // If we can edit any members submission, we can edit the submission for the group.
+ if ($this->can_edit_submission($member->id)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Perform an access check to see if the current $USER can view this group submission.
*
diff --git a/mod/assign/renderable.php b/mod/assign/renderable.php
index 6ee0e490ce7..8c6435e6a4f 100644
--- a/mod/assign/renderable.php
+++ b/mod/assign/renderable.php
@@ -911,6 +911,7 @@ class assign_files implements renderable {
*/
public function preprocess($dir, $filearea, $component) {
global $CFG;
+
foreach ($dir['subdirs'] as $subdir) {
$this->preprocess($subdir, $filearea, $component);
}
diff --git a/mod/assign/renderer.php b/mod/assign/renderer.php
index 2abf463a503..a987385578d 100644
--- a/mod/assign/renderer.php
+++ b/mod/assign/renderer.php
@@ -1420,7 +1420,7 @@ class mod_assign_renderer extends plugin_renderer_base {
$result .= '
' .
'' . $image . ' ' .
$file->fileurl . ' ' .
- $plagiarismlinks .
+ $plagiarismlinks . ' ' .
$file->portfoliobutton . '
' .
'';
}
diff --git a/mod/assign/submission/file/locallib.php b/mod/assign/submission/file/locallib.php
index a044a03ea7a..cd54142cb34 100644
--- a/mod/assign/submission/file/locallib.php
+++ b/mod/assign/submission/file/locallib.php
@@ -125,11 +125,11 @@ class assign_submission_file extends assign_submission_plugin {
* @return array
*/
private function get_file_options() {
- $fileoptions = array('subdirs'=>1,
- 'maxbytes'=>$this->get_config('maxsubmissionsizebytes'),
- 'maxfiles'=>$this->get_config('maxfilesubmissions'),
- 'accepted_types'=>'*',
- 'return_types'=>FILE_INTERNAL);
+ $fileoptions = array('subdirs' => 1,
+ 'maxbytes' => $this->get_config('maxsubmissionsizebytes'),
+ 'maxfiles' => $this->get_config('maxfilesubmissions'),
+ 'accepted_types' => '*',
+ 'return_types' => (FILE_INTERNAL | FILE_CONTROLLED_LINK));
if ($fileoptions['maxbytes'] == 0) {
// Use module default.
$fileoptions['maxbytes'] = get_config('assignsubmission_file', 'maxbytes');
@@ -174,7 +174,6 @@ class assign_submission_file extends assign_submission_plugin {
* @return int
*/
private function count_files($submissionid, $area) {
-
$fs = get_file_storage();
$files = $fs->get_area_files($this->assignment->get_context()->id,
'assignsubmission_file',
diff --git a/mod/assign/submission/onlinetext/locallib.php b/mod/assign/submission/onlinetext/locallib.php
index 8cd05284fd2..c57646022fa 100644
--- a/mod/assign/submission/onlinetext/locallib.php
+++ b/mod/assign/submission/onlinetext/locallib.php
@@ -174,7 +174,7 @@ class assign_submission_onlinetext extends assign_submission_plugin {
'maxfiles' => EDITOR_UNLIMITED_FILES,
'maxbytes' => $this->assignment->get_course()->maxbytes,
'context' => $this->assignment->get_context(),
- 'return_types' => FILE_INTERNAL | FILE_EXTERNAL
+ 'return_types' => (FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK)
);
return $editoroptions;
}
diff --git a/mod/data/field/file/field.class.php b/mod/data/field/file/field.class.php
index af455718aac..251fa7c93dc 100644
--- a/mod/data/field/file/field.class.php
+++ b/mod/data/field/file/field.class.php
@@ -85,7 +85,7 @@ class data_field_file extends data_field_base {
$options->maxfiles = 1; // Limit to one file for the moment, this may be changed if requested as a feature in the future.
$options->itemid = $itemid;
$options->accepted_types = '*';
- $options->return_types = FILE_INTERNAL;
+ $options->return_types = FILE_INTERNAL | FILE_CONTROLLED_LINK;
$options->context = $PAGE->context;
$fm = new form_filemanager($options);
diff --git a/mod/forum/classes/post_form.php b/mod/forum/classes/post_form.php
index 6ee9586b1a6..a9e8e82e7eb 100644
--- a/mod/forum/classes/post_form.php
+++ b/mod/forum/classes/post_form.php
@@ -50,7 +50,7 @@ class mod_forum_post_form extends moodleform {
'maxbytes' => $maxbytes,
'maxfiles' => $forum->maxattachments,
'accepted_types' => '*',
- 'return_types' => FILE_INTERNAL
+ 'return_types' => FILE_INTERNAL | FILE_CONTROLLED_LINK
);
}
diff --git a/mod/forum/lib.php b/mod/forum/lib.php
index 8c5ec559005..a14d1eddb80 100644
--- a/mod/forum/lib.php
+++ b/mod/forum/lib.php
@@ -559,13 +559,14 @@ function forum_cron() {
}
}
+ $modcontext = context_module::instance($coursemodules[$forumid]->id);
+
// Save the Inbound Message datakey here to reduce DB queries later.
$messageinboundgenerator->set_data($pid);
$messageinboundhandlers[$pid] = $messageinboundgenerator->fetch_data_key();
// Caching subscribed users of each forum.
if (!isset($subscribedusers[$forumid])) {
- $modcontext = context_module::instance($coursemodules[$forumid]->id);
if ($subusers = \mod_forum\subscriptions::fetch_subscribed_users($forums[$forumid], 0, $modcontext, 'u.*', true)) {
foreach ($subusers as $postuser) {
diff --git a/mod/wiki/filesedit.php b/mod/wiki/filesedit.php
index 5edab9afae1..91ed22e61a2 100644
--- a/mod/wiki/filesedit.php
+++ b/mod/wiki/filesedit.php
@@ -83,7 +83,8 @@ $data = new stdClass();
$data->returnurl = $returnurl;
$data->subwikiid = $subwiki->id;
$maxbytes = get_max_upload_file_size($CFG->maxbytes, $COURSE->maxbytes);
-$options = array('subdirs'=>0, 'maxbytes'=>$maxbytes, 'maxfiles'=>-1, 'accepted_types'=>'*', 'return_types'=>FILE_INTERNAL | FILE_REFERENCE);
+$types = FILE_INTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK;
+$options = array('subdirs' => 0, 'maxbytes' => $maxbytes, 'maxfiles' => -1, 'accepted_types' => '*', 'return_types' => $types);
file_prepare_standard_filemanager($data, 'files', $options, $context, 'mod_wiki', 'attachments', $subwiki->id);
$mform = new mod_wiki_filesedit_form(null, array('data'=>$data, 'options'=>$options));
diff --git a/mod/workshop/locallib.php b/mod/workshop/locallib.php
index 9016190e3af..af0dca3c231 100644
--- a/mod/workshop/locallib.php
+++ b/mod/workshop/locallib.php
@@ -2512,7 +2512,7 @@ class workshop {
'subdirs' => true,
'maxfiles' => $this->nattachments,
'maxbytes' => $this->maxbytes,
- 'return_types' => FILE_INTERNAL,
+ 'return_types' => FILE_INTERNAL | FILE_CONTROLLED_LINK,
);
if ($acceptedtypes = self::normalize_file_extensions($this->submissionfiletypes)) {
@@ -2554,7 +2554,7 @@ class workshop {
'subdirs' => 1,
'maxbytes' => $this->overallfeedbackmaxbytes,
'maxfiles' => $this->overallfeedbackfiles,
- 'return_types' => FILE_INTERNAL,
+ 'return_types' => FILE_INTERNAL | FILE_CONTROLLED_LINK,
);
if ($acceptedtypes = self::normalize_file_extensions($this->overallfeedbackfiletypes)) {
diff --git a/pluginfile.php b/pluginfile.php
index 8cc087786dd..9148b624447 100644
--- a/pluginfile.php
+++ b/pluginfile.php
@@ -33,5 +33,8 @@ require_once('lib/filelib.php');
$relativepath = get_file_argument();
$forcedownload = optional_param('forcedownload', 0, PARAM_BOOL);
$preview = optional_param('preview', null, PARAM_ALPHANUM);
+// Offline means download the file from the repository and serve it, even if it was an external link.
+// The repository may have to export the file to an offline format.
+$offline = optional_param('offline', 0, PARAM_BOOL);
-file_pluginfile($relativepath, $forcedownload, $preview);
+file_pluginfile($relativepath, $forcedownload, $preview, $offline);
diff --git a/question/type/essay/renderer.php b/question/type/essay/renderer.php
index f8db5516dec..a6352ef2580 100644
--- a/question/type/essay/renderer.php
+++ b/question/type/essay/renderer.php
@@ -115,7 +115,7 @@ class qtype_essay_renderer extends qtype_renderer {
$pickeroptions->itemid = $qa->prepare_response_files_draft_itemid(
'attachments', $options->context->id);
$pickeroptions->context = $options->context;
- $pickeroptions->return_types = FILE_INTERNAL;
+ $pickeroptions->return_types = FILE_INTERNAL | FILE_CONTROLLED_LINK;
$pickeroptions->itemid = $qa->prepare_response_files_draft_itemid(
'attachments', $options->context->id);
diff --git a/repository/areafiles/lib.php b/repository/areafiles/lib.php
index 364028b47c7..ac3045aa86f 100644
--- a/repository/areafiles/lib.php
+++ b/repository/areafiles/lib.php
@@ -106,6 +106,7 @@ class repository_areafiles extends repository {
'author' => $file->get_author(),
'license' => $file->get_license(),
'isref' => $file->is_external_file(),
+ 'iscontrolledlink' => $file->is_controlled_link(),
'icon' => $OUTPUT->image_url(file_file_icon($file, 24))->out(false),
'thumbnail' => $OUTPUT->image_url(file_file_icon($file, 90))->out(false)
);
diff --git a/repository/filepicker.js b/repository/filepicker.js
index 386a6abcd17..65d58464023 100644
--- a/repository/filepicker.js
+++ b/repository/filepicker.js
@@ -29,6 +29,7 @@
* Active repository options
* =====
* this.active_repo.id
+ * this.active_repo.defaultreturntype
* this.active_repo.nosearch
* this.active_repo.norefresh
* this.active_repo.nologin
@@ -895,6 +896,9 @@ M.core_filepicker.init = function(Y, options) {
if (node.isref) {
classname = classname + ' fp-isreference';
}
+ if (node.iscontrolledlink) {
+ classname = classname + ' fp-iscontrolledlink';
+ }
if (node.refcount) {
classname = classname + ' fp-hasreferences';
}
@@ -1080,7 +1084,7 @@ M.core_filepicker.init = function(Y, options) {
selectnode.one('.fp-thumbnail').setContent('').appendChild(imgnode);
// filelink is the array of file-link-types available for this repository in this env
- var filelinktypes = [2/*FILE_INTERNAL*/,1/*FILE_EXTERNAL*/,4/*FILE_REFERENCE*/];
+ var filelinktypes = [2/*FILE_INTERNAL*/,1/*FILE_EXTERNAL*/,4/*FILE_REFERENCE*/,8/*FILE_CONTROLLED_LINK*/];
var filelink = {}, firstfilelink = null, filelinkcount = 0;
for (var i in filelinktypes) {
var allowed = (return_types & filelinktypes[i]) &&
@@ -1094,6 +1098,12 @@ M.core_filepicker.init = function(Y, options) {
firstfilelink = (firstfilelink==null && allowed) ? filelinktypes[i] : firstfilelink;
filelinkcount += allowed ? 1 : 0;
}
+ var defaultreturntype = this.options.repositories[this.active_repo.id].defaultreturntype;
+ if (defaultreturntype) {
+ if (filelink[defaultreturntype]) {
+ firstfilelink = defaultreturntype;
+ }
+ }
// make radio buttons enabled if this file-link-type is available and only if there are more than one file-link-type option
// check the first available file-link-type option
for (var linktype in filelink) {
@@ -1122,12 +1132,13 @@ M.core_filepicker.init = function(Y, options) {
var selectnode = this.selectnode;
var getfile = selectnode.one('.fp-select-confirm');
// bind labels with corresponding inputs
- selectnode.all('.fp-saveas,.fp-linktype-2,.fp-linktype-1,.fp-linktype-4,.fp-setauthor,.fp-setlicense').each(function (node) {
+ selectnode.all('.fp-saveas,.fp-linktype-2,.fp-linktype-1,.fp-linktype-4,fp-linktype-8,.fp-setauthor,.fp-setlicense').each(function (node) {
node.all('label').set('for', node.one('input,select').generateID());
});
selectnode.one('.fp-linktype-2 input').setAttrs({value: 2, name: 'linktype'});
selectnode.one('.fp-linktype-1 input').setAttrs({value: 1, name: 'linktype'});
selectnode.one('.fp-linktype-4 input').setAttrs({value: 4, name: 'linktype'});
+ selectnode.one('.fp-linktype-8 input').setAttrs({value: 8, name: 'linktype'});
var changelinktype = function(e) {
if (e.currentTarget.get('checked')) {
var allowinputs = e.currentTarget.get('value') != 1/*FILE_EXTERNAL*/;
@@ -1137,7 +1148,7 @@ M.core_filepicker.init = function(Y, options) {
});
}
};
- selectnode.all('.fp-linktype-2,.fp-linktype-1,.fp-linktype-4').each(function (node) {
+ selectnode.all('.fp-linktype-2,.fp-linktype-1,.fp-linktype-4,.fp-linktype-8').each(function (node) {
node.one('input').on('change', changelinktype, this);
});
this.populate_licenses_select(selectnode.one('.fp-setlicense select'));
@@ -1175,6 +1186,10 @@ M.core_filepicker.init = function(Y, options) {
(this.options.return_types & 4/*FILE_REFERENCE*/) &&
selectnode.one('.fp-linktype-4 input').get('checked')) {
params['usefilereference'] = '1';
+ } else if ((return_types & 8/*FILE_CONTROLLED_LINK*/) &&
+ (this.options.return_types & 8/*FILE_CONTROLLED_LINK*/) &&
+ selectnode.one('.fp-linktype-8 input').get('checked')) {
+ params['usecontrolledlink'] = '1';
}
selectnode.addClass('loading');
@@ -1415,6 +1430,7 @@ M.core_filepicker.init = function(Y, options) {
this.objecttag = data.object?data.object:null;
this.active_repo = {};
this.active_repo.issearchresult = data.issearchresult ? true : false;
+ this.active_repo.defaultreturntype = data.defaultreturntype?data.defaultreturntype:null;
this.active_repo.dynload = data.dynload?data.dynload:false;
this.active_repo.pages = Number(data.pages?data.pages:null);
this.active_repo.page = Number(data.page?data.page:null);
diff --git a/repository/googledocs/classes/rest.php b/repository/googledocs/classes/rest.php
new file mode 100644
index 00000000000..6f46c55c05f
--- /dev/null
+++ b/repository/googledocs/classes/rest.php
@@ -0,0 +1,128 @@
+.
+
+/**
+ * Google Drive Rest API.
+ *
+ * @package repository_googledocs
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace repository_googledocs;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Google Drive Rest API.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class rest extends \core\oauth2\rest {
+
+ /**
+ * Define the functions of the rest API.
+ *
+ * @return array Example:
+ * [ 'listFiles' => [ 'method' => 'get', 'endpoint' => 'http://...', 'args' => [ 'folder' => PARAM_STRING ] ] ]
+ */
+ public function get_api_functions() {
+ return [
+ 'list' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files',
+ 'method' => 'get',
+ 'args' => [
+ 'corpus' => PARAM_RAW,
+ 'orderBy' => PARAM_RAW,
+ 'fields' => PARAM_RAW,
+ 'pageSize' => PARAM_INT,
+ 'pageToken' => PARAM_RAW,
+ 'q' => PARAM_RAW,
+ 'spaces' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'get' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}',
+ 'method' => 'get',
+ 'args' => [
+ 'fields' => PARAM_RAW,
+ 'fileid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'copy' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/copy',
+ 'method' => 'post',
+ 'args' => [
+ 'fields' => PARAM_RAW,
+ 'fileid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'delete' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}',
+ 'method' => 'delete',
+ 'args' => [
+ 'fileid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'create' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files',
+ 'method' => 'post',
+ 'args' => [
+ 'fields' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'update' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}',
+ 'method' => 'patch',
+ 'args' => [
+ 'fileid' => PARAM_RAW,
+ 'fields' => PARAM_RAW,
+ 'addParents' => PARAM_RAW,
+ 'removeParents' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'create_permission' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/permissions',
+ 'method' => 'post',
+ 'args' => [
+ 'fileid' => PARAM_RAW,
+ 'emailMessage' => PARAM_RAW,
+ 'sendNotificationEmail' => PARAM_RAW,
+ 'transferOwnership' => PARAM_RAW,
+ ],
+ 'response' => 'json'
+ ],
+ 'update_permission' => [
+ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/permissions/{permissionid}',
+ 'method' => 'patch',
+ 'args' => [
+ 'fileid' => PARAM_RAW,
+ 'permissionid' => PARAM_RAW,
+ 'emailMessage' => PARAM_RAW,
+ 'sendNotificationEmail' => PARAM_RAW,
+ 'transferOwnership' => PARAM_RAW,
+ ],
+ 'response' => 'json'
+ ],
+ ];
+ }
+}
diff --git a/repository/googledocs/db/caches.php b/repository/googledocs/db/caches.php
new file mode 100644
index 00000000000..a751ed9b8cd
--- /dev/null
+++ b/repository/googledocs/db/caches.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * Googledocs repository cache definitions.
+ *
+ * This file is part of Moodle's cache API, affectionately called MUC.
+ * It contains the components that are requried in order to use caching.
+ *
+ * @package repository_googledocs
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$definitions = array(
+
+ // Used to store file ids for folders.
+ // The keys used are full path to the folder, the values are the id in google drive.
+ // The static acceleration size has been based upon the depths of a single path.
+ 'folder' => array(
+ 'mode' => cache_store::MODE_APPLICATION,
+ 'simplekeys' => false,
+ 'simpledata' => true,
+ 'staticacceleration' => true,
+ 'staticaccelerationsize' => 10,
+ 'canuselocalstore' => true
+ ),
+);
diff --git a/repository/googledocs/db/upgrade.php b/repository/googledocs/db/upgrade.php
index da8d731a947..381571e7233 100644
--- a/repository/googledocs/db/upgrade.php
+++ b/repository/googledocs/db/upgrade.php
@@ -48,6 +48,27 @@ function xmldb_repository_googledocs_upgrade($oldversion) {
// Plugin savepoint reached.
upgrade_plugin_savepoint(true, 2017011100, 'repository', 'googledocs');
}
+ if ($oldversion < 2017030500) {
+ $clientid = get_config('clientid', 'googledocs');
+ $secret = get_config('secret', 'googledocs');
+
+ // Update from repo config to use an OAuth service.
+ if (!empty($clientid) && !empty($secret)) {
+ $issuer = \core\oauth2\api::create_standard_issuer('google');
+
+ $issuer->set('clientid', $clientid);
+ $issuer->set('secret', $secret);
+
+ $issuer->update();
+
+ set_config('issuerid', $issuer->get('id'), 'googledocs');
+ }
+ upgrade_plugin_savepoint(true, 2017030500, 'repository', 'googledocs');
+ }
+ if ($oldversion < 2017030600) {
+ set_config('supportedfiles', 'both', 'googledocs');
+ upgrade_plugin_savepoint(true, 2017030600, 'repository', 'googledocs');
+ }
return true;
}
diff --git a/repository/googledocs/lang/en/repository_googledocs.php b/repository/googledocs/lang/en/repository_googledocs.php
index ca4d255ca5f..6374a5cca33 100644
--- a/repository/googledocs/lang/en/repository_googledocs.php
+++ b/repository/googledocs/lang/en/repository_googledocs.php
@@ -22,15 +22,29 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-$string['clientid'] = 'Client ID';
$string['configplugin'] = 'Configure Google Drive plugin';
$string['docsformat'] = 'Default document import format';
$string['drawingformat'] = 'Default drawing import format';
$string['googledocs:view'] = 'View Google Drive repository';
$string['importformat'] = 'Configure the default import formats from google';
-$string['oauthinfo'] = '
To use this plugin, you must register your site with Google, as described in the documentation Google OAuth 2.0 setup.
As part of the registration process, you will need to enter the following URL as \'Authorized Redirect URIs\':
{$a->callbackurl}
Once registered, you will be provided with a client ID and secret which can be used to configure all Google Drive and Picasa plugins.
Please also note that you will have to enable the service \'Drive API\'.
';
$string['pluginname'] = 'Google Drive';
$string['presentationformat'] = 'Default presentation import format';
-$string['secret'] = 'Secret';
-$string['servicenotenabled'] = 'Access not configured. Make sure the service \'Drive API\' is enabled.';
$string['spreadsheetformat'] = 'Default spreadsheet import format';
+$string['issuer'] = 'OAuth 2 service';
+$string['issuer_help'] = 'Select the OAuth 2 service that is configured to talk to the Google Drive API. If the services does not exist yet, you might need to create it.';
+$string['servicenotenabled'] = 'Access not configured. Make sure the service \'Drive API\' is enabled.';
+$string['oauth2serviceslink'] = '
OAuth 2 Services Configuration';
+$string['searchfor'] = 'Search for {$a}';
+$string['internal'] = 'Internal (files stored in Moodle)';
+$string['external'] = 'External (only links stored in Moodle)';
+$string['both'] = 'Internal and External';
+$string['supportedreturntypes'] = 'Supported files';
+$string['defaultreturntype'] = 'Default return type';
+$string['fileoptions'] = 'The types and defaults for returned files is configurable here. Note that all files linked externally will be updated so that the owner is the Moodle system account.';
+$string['owner'] = 'Owned by: {$a}';
+$string['cachedef_folder'] = 'Google File IDs for folders in the system account';
+
+// Deprecated since Moodle 3.3.
+$string['oauthinfo'] = '
To use this plugin, you must register your site with Google, as described in the documentation Google OAuth 2.0 setup.
As part of the registration process, you will need to enter the following URL as \'Authorized Redirect URIs\':
{$a->callbackurl}
Once registered, you will be provided with a client ID and secret which can be used to configure all Google Drive and Picasa plugins.
Please also note that you will have to enable the service \'Drive API\'.
';
+$string['secret'] = 'Secret';
+$string['clientid'] = 'Client ID';
diff --git a/repository/googledocs/lib.php b/repository/googledocs/lib.php
index 82fdfe45e4f..1ce0e1bdc15 100644
--- a/repository/googledocs/lib.php
+++ b/repository/googledocs/lib.php
@@ -26,7 +26,7 @@
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/repository/lib.php');
-require_once($CFG->libdir . '/google/lib.php');
+require_once($CFG->libdir . '/filebrowser/file_browser.php');
/**
* Google Docs Plugin
@@ -39,28 +39,21 @@ require_once($CFG->libdir . '/google/lib.php');
class repository_googledocs extends repository {
/**
- * Google Client.
- * @var Google_Client
+ * OAuth 2 client
+ * @var \core\oauth2\client
*/
private $client = null;
/**
- * Google Drive Service.
- * @var Google_Drive_Service
+ * OAuth 2 Issuer
+ * @var \core\oauth2\issuer
*/
- private $service = null;
+ private $issuer = null;
/**
- * Session key to store the accesstoken.
- * @var string
+ * Additional scopes required for drive.
*/
- const SESSIONKEY = 'googledrive_accesstoken';
-
- /**
- * URI to the callback file for OAuth.
- * @var string
- */
- const CALLBACKURL = '/admin/oauth2callback.php';
+ const SCOPES = 'https://www.googleapis.com/auth/drive';
/**
* Constructor.
@@ -74,65 +67,49 @@ class repository_googledocs extends repository {
public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array(), $readonly = 0) {
parent::__construct($repositoryid, $context, $options, $readonly = 0);
- $callbackurl = new moodle_url(self::CALLBACKURL);
-
- $this->client = get_google_client();
- $this->client->setClientId(get_config('googledocs', 'clientid'));
- $this->client->setClientSecret(get_config('googledocs', 'secret'));
- $this->client->setScopes(array(Google_Service_Drive::DRIVE_READONLY));
- $this->client->setRedirectUri($callbackurl->out(false));
- $this->service = new Google_Service_Drive($this->client);
-
- $this->check_login();
- }
-
- /**
- * Returns the access token if any.
- *
- * @return string|null access token.
- */
- protected function get_access_token() {
- global $SESSION;
- if (isset($SESSION->{self::SESSIONKEY})) {
- return $SESSION->{self::SESSIONKEY};
+ try {
+ $this->issuer = \core\oauth2\api::get_issuer(get_config('googledocs', 'issuerid'));
+ } catch (dml_missing_record_exception $e) {
+ $this->disabled = true;
}
- return null;
- }
- /**
- * Store the access token in the session.
- *
- * @param string $token token to store.
- * @return void
- */
- protected function store_access_token($token) {
- global $SESSION;
- $SESSION->{self::SESSIONKEY} = $token;
- }
-
- /**
- * Callback method during authentication.
- *
- * @return void
- */
- public function callback() {
- if ($code = optional_param('oauth2code', null, PARAM_RAW)) {
- $this->client->authenticate($code);
- $this->store_access_token($this->client->getAccessToken());
+ if ($this->issuer && !$this->issuer->get('enabled')) {
+ $this->disabled = true;
}
}
+ /**
+ * Get a cached user authenticated oauth client.
+ *
+ * @param moodle_url $overrideurl - Use this url instead of the repo callback.
+ * @return \core\oauth2\client
+ */
+ protected function get_user_oauth_client($overrideurl = false) {
+ if ($this->client) {
+ return $this->client;
+ }
+ if ($overrideurl) {
+ $returnurl = $overrideurl;
+ } else {
+ $returnurl = new moodle_url('/repository/repository_callback.php');
+ $returnurl->param('callback', 'yes');
+ $returnurl->param('repo_id', $this->id);
+ $returnurl->param('sesskey', sesskey());
+ }
+
+ $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
+
+ return $this->client;
+ }
+
/**
* Checks whether the user is authenticate or not.
*
* @return bool true when logged in.
*/
public function check_login() {
- if ($token = $this->get_access_token()) {
- $this->client->setAccessToken($token);
- return true;
- }
- return false;
+ $client = $this->get_user_oauth_client();
+ return $client->is_logged_in();
}
/**
@@ -141,13 +118,9 @@ class repository_googledocs extends repository {
* @return void|array for ajax.
*/
public function print_login() {
- $returnurl = new moodle_url('/repository/repository_callback.php');
- $returnurl->param('callback', 'yes');
- $returnurl->param('repo_id', $this->id);
- $returnurl->param('sesskey', sesskey());
+ $client = $this->get_user_oauth_client();
+ $url = $client->get_login_url();
- $url = new moodle_url($this->client->createAuthUrl());
- $url->param('state', $returnurl->out_as_local_url(false));
if ($this->options['ajax']) {
$popup = new stdClass();
$popup->type = 'popup';
@@ -159,11 +132,11 @@ class repository_googledocs extends repository {
}
/**
- * Build the breadcrumb from a path.
- *
- * @param string $path to create a breadcrumb from.
- * @return array containing name and path of each crumb.
- */
+ * Build the breadcrumb from a path.
+ *
+ * @param string $path to create a breadcrumb from.
+ * @return array containing name and path of each crumb.
+ */
protected function build_breadcrumb($path) {
$bread = explode('/', $path);
$crumbtrail = '';
@@ -181,15 +154,15 @@ class repository_googledocs extends repository {
}
/**
- * Generates a safe path to a node.
- *
- * Typically, a node will be id|Name of the node.
- *
- * @param string $id of the node.
- * @param string $name of the node, will be URL encoded.
- * @param string $root to append the node on, must be a result of this function.
- * @return string path to the node.
- */
+ * Generates a safe path to a node.
+ *
+ * Typically, a node will be id|Name of the node.
+ *
+ * @param string $id of the node.
+ * @param string $name of the node, will be URL encoded.
+ * @param string $root to append the node on, must be a result of this function.
+ * @return string path to the node.
+ */
protected function build_node_path($id, $name = '', $root = '') {
$path = $id;
if (!empty($name)) {
@@ -202,12 +175,12 @@ class repository_googledocs extends repository {
}
/**
- * Returns information about a node in a path.
- *
- * @see self::build_node_path()
- * @param string $node to extrat information from.
- * @return array about the node.
- */
+ * Returns information about a node in a path.
+ *
+ * @see self::build_node_path()
+ * @param string $node to extrat information from.
+ * @return array about the node.
+ */
protected function explode_node_path($node) {
if (strpos($node, '|') !== false) {
list($id, $name) = explode('|', $node, 2);
@@ -225,7 +198,6 @@ class repository_googledocs extends repository {
);
}
-
/**
* List the files and folders.
*
@@ -237,6 +209,10 @@ class repository_googledocs extends repository {
if (empty($path)) {
$path = $this->build_node_path('root', get_string('pluginname', 'repository_googledocs'));
}
+ if (!$this->issuer->get('enabled')) {
+ // Empty list of files for disabled repository.
+ return ['dynload' => false, 'list' => [], 'nologin' => true];
+ }
// We analyse the path to extract what to browse.
$trail = explode('/', $path);
@@ -257,24 +233,28 @@ class repository_googledocs extends repository {
$ret = array();
$ret['dynload'] = true;
+ $ret['defaultreturntype'] = $this->default_returntype();
$ret['path'] = $this->build_breadcrumb($path);
$ret['list'] = $results;
+ $ret['manage'] = 'https://drive.google.com/';
+
return $ret;
}
/**
* Search throughout the Google Drive.
*
- * @param string $search_text text to search for.
+ * @param string $searchtext text to search for.
* @param int $page search page.
* @return array of results.
*/
- public function search($search_text, $page = 0) {
+ public function search($searchtext, $page = 0) {
$path = $this->build_node_path('root', get_string('pluginname', 'repository_googledocs'));
- $path = $this->build_node_path('search', $search_text, $path);
+ $str = get_string('searchfor', 'repository_googledocs', $searchtext);
+ $path = $this->build_node_path('search', $str, $path);
// Query the Drive.
- $q = "fullText contains '" . str_replace("'", "\'", $search_text) . "'";
+ $q = "fullText contains '" . str_replace("'", "\'", $searchtext) . "'";
$q .= ' AND trashed = false';
$results = $this->query($q, $path);
@@ -282,6 +262,7 @@ class repository_googledocs extends repository {
$ret['dynload'] = true;
$ret['path'] = $this->build_breadcrumb($path);
$ret['list'] = $results;
+ $ret['manage'] = 'https://drive.google.com/';
return $ret;
}
@@ -304,14 +285,17 @@ class repository_googledocs extends repository {
$files = array();
$folders = array();
- $fields = "items(id,title,mimeType,downloadUrl,fileExtension,exportLinks,modifiedDate,fileSize,thumbnailLink)";
- $params = array('q' => $q, 'fields' => $fields);
$config = get_config('googledocs');
+ $fields = "files(id,name,mimeType,webContentLink,webViewLink,fileExtension,modifiedTime,size,thumbnailLink,iconLink)";
+ $params = array('q' => $q, 'fields' => $fields, 'spaces' => 'drive');
try {
// Retrieving files and folders.
- $response = $this->service->files->listFiles($params);
- } catch (Google_Service_Exception $e) {
+ $client = $this->get_user_oauth_client();
+ $service = new repository_googledocs\rest($client);
+
+ $response = $service->call('list', $params);
+ } catch (Exception $e) {
if ($e->getCode() == 403 && strpos($e->getMessage(), 'Access Not Configured') !== false) {
// This is raised when the service Drive API has not been enabled on Google APIs control panel.
throw new repository_exception('servicenotenabled', 'repository_googledocs');
@@ -320,14 +304,14 @@ class repository_googledocs extends repository {
}
}
- $items = isset($response['items']) ? $response['items'] : array();
- foreach ($items as $item) {
- if ($item['mimeType'] == 'application/vnd.google-apps.folder') {
+ $gfiles = isset($response->files) ? $response->files : array();
+ foreach ($gfiles as $gfile) {
+ if ($gfile->mimeType == 'application/vnd.google-apps.folder') {
// This is a folder.
- $folders[$item['title'] . $item['id']] = array(
- 'title' => $item['title'],
- 'path' => $this->build_node_path($item['id'], $item['title'], $path),
- 'date' => strtotime($item['modifiedDate']),
+ $folders[$gfile->name . $gfile->id] = array(
+ 'title' => $gfile->name,
+ 'path' => $this->build_node_path($gfile->id, $gfile->name, $path),
+ 'date' => strtotime($gfile->modifiedTime),
'thumbnail' => $OUTPUT->image_url(file_folder_icon(64))->out(false),
'thumbnail_height' => 64,
'thumbnail_width' => 64,
@@ -335,74 +319,84 @@ class repository_googledocs extends repository {
);
} else {
// This is a file.
- if (isset($item['fileExtension'])) {
- // The file has an extension, therefore there is a download link.
- $title = $item['title'];
- $source = $item['downloadUrl'];
+ $link = isset($gfile->webViewLink) ? $gfile->webViewLink : '';
+ if (empty($link)) {
+ $link = isset($gfile->webContentLink) ? $gfile->webContentLink : '';
+ }
+ if (isset($gfile->fileExtension)) {
+ // The file has an extension, therefore we can download it.
+ $source = json_encode([
+ 'id' => $gfile->id,
+ 'name' => $gfile->name,
+ 'exportformat' => 'download',
+ 'link' => $link
+ ]);
+ $title = $gfile->name;
} else {
// The file is probably a Google Doc file, we get the corresponding export link.
// This should be improved by allowing the user to select the type of export they'd like.
- $type = str_replace('application/vnd.google-apps.', '', $item['mimeType']);
+ $type = str_replace('application/vnd.google-apps.', '', $gfile->mimeType);
$title = '';
- $exportType = '';
+ $exporttype = '';
$types = get_mimetypes_array();
switch ($type){
case 'document':
$ext = $config->documentformat;
- $title = $item['title'] . '.'. $ext;
+ $title = $gfile->name . '.gdoc';
if ($ext === 'rtf') {
// Moodle user 'text/rtf' as the MIME type for RTF files.
// Google uses 'application/rtf' for the same type of file.
// See https://developers.google.com/drive/v3/web/manage-downloads.
- $exportType = 'application/rtf';
+ $exporttype = 'application/rtf';
} else {
- $exportType = $types[$ext]['type'];
+ $exporttype = $types[$ext]['type'];
}
break;
case 'presentation':
$ext = $config->presentationformat;
- $title = $item['title'] . '.'. $ext;
- $exportType = $types[$ext]['type'];
+ $title = $gfile->name . '.gslides';
+ $exporttype = $types[$ext]['type'];
break;
case 'spreadsheet':
$ext = $config->spreadsheetformat;
- $title = $item['title'] . '.'. $ext;
- $exportType = $types[$ext]['type'];
+ $title = $gfile->name . '.gsheet';
+ $exporttype = $types[$ext]['type'];
break;
case 'drawing':
$ext = $config->drawingformat;
- $title = $item['title'] . '.'. $ext;
- $exportType = $types[$ext]['type'];
+ $title = $gfile->name . '.'. $ext;
+ $exporttype = $types[$ext]['type'];
break;
}
// Skips invalid/unknown types.
- if (empty($title) || !isset($item['exportLinks'][$exportType])) {
+ if (empty($title)) {
continue;
}
- $source = $item['exportLinks'][$exportType];
+ $source = json_encode([
+ 'id' => $gfile->id,
+ 'exportformat' => $exporttype,
+ 'link' => $link,
+ 'name' => $gfile->name
+ ]);
}
- // Adds the file to the file list. Using the itemId along with the title as key
+ // Adds the file to the file list. Using the itemId along with the name as key
// of the array because Google Drive allows files with identical names.
- $files[$title . $item['id']] = array(
+ $thumb = '';
+ if (isset($gfile->thumbnailLink)) {
+ $thumb = $gfile->thumbnailLink;
+ } else if (isset($gfile->iconLink)) {
+ $thumb = $gfile->iconLink;
+ }
+ $files[$title . $gfile->id] = array(
'title' => $title,
'source' => $source,
- 'date' => strtotime($item['modifiedDate']),
- 'size' => isset($item['fileSize']) ? $item['fileSize'] : null,
- 'thumbnail' => $OUTPUT->image_url(file_extension_icon($title, 64))->out(false),
+ 'date' => strtotime($gfile->modifiedTime),
+ 'size' => isset($gfile->size) ? $gfile->size : null,
+ 'thumbnail' => $thumb,
'thumbnail_height' => 64,
'thumbnail_width' => 64,
- // Do not use real thumbnails as they wouldn't work if the user disabled 3rd party
- // plugins in his browser, or if they're not logged in their Google account.
);
-
- // Sometimes the real thumbnails can't be displayed, for example if 3rd party cookies are disabled
- // or if the user is not logged in Google anymore. But this restriction does not seem to be applied
- // to a small subset of files.
- $extension = strtolower(pathinfo($title, PATHINFO_EXTENSION));
- if (isset($item['thumbnailLink']) && in_array($extension, array('jpg', 'png', 'txt', 'pdf'))) {
- $files[$title . $item['id']]['realthumbnail'] = $item['thumbnailLink'];
- }
}
}
@@ -419,7 +413,8 @@ class repository_googledocs extends repository {
* @return string
*/
public function logout() {
- $this->store_access_token(null);
+ $client = $this->get_user_oauth_client();
+ $client->log_out();
return parent::logout();
}
@@ -433,18 +428,53 @@ class repository_googledocs extends repository {
public function get_file($reference, $filename = '') {
global $CFG;
- $auth = $this->client->getAuth();
- $request = $auth->authenticatedRequest(new Google_Http_Request($reference));
- if ($request->getResponseHttpCode() == 200) {
- $path = $this->prepare_file($filename);
- $content = $request->getResponseBody();
- if (file_put_contents($path, $content) !== false) {
- @chmod($path, $CFG->filepermissions);
- return array(
- 'path' => $path,
- 'url' => $reference
- );
+ if (!$this->issuer->get('enabled')) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ $client = $this->get_user_oauth_client();
+ $base = 'https://www.googleapis.com/drive/v3';
+
+ $source = json_decode($reference);
+
+ $newfilename = false;
+ if ($source->exportformat == 'download') {
+ $params = ['alt' => 'media'];
+ $sourceurl = new moodle_url($base . '/files/' . $source->id, $params);
+ $source = $sourceurl->out(false);
+ } else {
+ $params = ['mimeType' => $source->exportformat];
+ $sourceurl = new moodle_url($base . '/files/' . $source->id . '/export', $params);
+ $types = get_mimetypes_array();
+ $checktype = $source->exportformat;
+ if ($checktype == 'application/rtf') {
+ $checktype = 'text/rtf';
}
+ foreach ($types as $extension => $info) {
+ if ($info['type'] == $checktype) {
+ $newfilename = $source->name . '.' . $extension;
+ break;
+ }
+ }
+ $source = $sourceurl->out(false);
+ }
+
+ // We use download_one and not the rest API because it has special timeouts etc.
+ $path = $this->prepare_file($filename);
+ $options = ['filepath' => $path, 'timeout' => 15, 'followlocation' => true, 'maxredirs' => 5];
+ $success = $client->download_one($source, null, $options);
+
+ if ($success) {
+ @chmod($path, $CFG->filepermissions);
+
+ $result = [
+ 'path' => $path,
+ 'url' => $reference,
+ ];
+ if (!empty($newfilename)) {
+ $result['newfilename'] = $newfilename;
+ }
+ return $result;
}
throw new repository_exception('cannotdownload', 'repository');
}
@@ -459,7 +489,8 @@ class repository_googledocs extends repository {
* @return string file reference.
*/
public function get_file_reference($source) {
- return clean_param($source, PARAM_URL);
+ // We could do some magic upgrade code here.
+ return $source;
}
/**
@@ -475,12 +506,37 @@ class repository_googledocs extends repository {
/**
* Tells how the file can be picked from this repository.
*
- * Maximum value is FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE.
- *
* @return int
*/
public function supported_returntypes() {
- return FILE_INTERNAL;
+ // We can only support references if the system account is connected.
+ if (!empty($this->issuer) && $this->issuer->is_system_account_connected()) {
+ $setting = get_config('googledocs', 'supportedreturntypes');
+ if ($setting == 'internal') {
+ return FILE_INTERNAL;
+ } else if ($setting == 'external') {
+ return FILE_CONTROLLED_LINK;
+ } else {
+ return FILE_CONTROLLED_LINK | FILE_INTERNAL;
+ }
+ } else {
+ return FILE_INTERNAL;
+ }
+ }
+
+ /**
+ * Which return type should be selected by default.
+ *
+ * @return int
+ */
+ public function default_returntype() {
+ $setting = get_config('googledocs', 'defaultreturntype');
+ $supported = get_config('googledocs', 'supportedreturntypes');
+ if (($setting == FILE_INTERNAL && $supported != 'external') || $supported == 'internal') {
+ return FILE_INTERNAL;
+ } else {
+ return FILE_CONTROLLED_LINK;
+ }
}
/**
@@ -490,9 +546,444 @@ class repository_googledocs extends repository {
* @return array
*/
public static function get_type_option_names() {
- return array('clientid', 'secret', 'pluginname',
+ return array('issuerid', 'pluginname',
'documentformat', 'drawingformat',
- 'presentationformat', 'spreadsheetformat');
+ 'presentationformat', 'spreadsheetformat',
+ 'defaultreturntype', 'supportedreturntypes');
+ }
+
+ /**
+ * Store the access token.
+ */
+ public function callback() {
+ $client = $this->get_user_oauth_client();
+ // This will upgrade to an access token if we have an authorization code and save the access token in the session.
+ $client->is_logged_in();
+ }
+
+ /**
+ * Repository method to serve the referenced file
+ *
+ * @see send_stored_file
+ *
+ * @param stored_file $storedfile the file that contains the reference
+ * @param int $lifetime Number of seconds before the file should expire from caches (null means $CFG->filelifetime)
+ * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
+ * @param bool $forcedownload If true (default false), forces download of file rather than view in browser/plugin
+ * @param array $options additional options affecting the file serving
+ */
+ public function send_file($storedfile, $lifetime=null , $filter=0, $forcedownload=false, array $options = null) {
+ if (!$this->issuer->get('enabled')) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ $source = json_decode($storedfile->get_reference());
+
+ $fb = get_file_browser();
+ $context = context::instance_by_id($storedfile->get_contextid(), MUST_EXIST);
+ $info = $fb->get_file_info($context,
+ $storedfile->get_component(),
+ $storedfile->get_filearea(),
+ $storedfile->get_itemid(),
+ $storedfile->get_filepath(),
+ $storedfile->get_filename());
+
+ if (empty($options['offline']) && !empty($info) && $info->is_writable()) {
+ // Add the current user as an OAuth writer.
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ $details = 'Cannot connect as system user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $systemservice = new repository_googledocs\rest($systemauth);
+
+ // Get the user oauth so we can get the account to add.
+ $url = moodle_url::make_pluginfile_url($storedfile->get_contextid(),
+ $storedfile->get_component(),
+ $storedfile->get_filearea(),
+ $storedfile->get_itemid(),
+ $storedfile->get_filepath(),
+ $storedfile->get_filename(),
+ $forcedownload);
+ $url->param('sesskey', sesskey());
+ $userauth = $this->get_user_oauth_client($url);
+ if (!$userauth->is_logged_in()) {
+ redirect($userauth->get_login_url());
+ }
+ if ($userauth === false) {
+ $details = 'Cannot connect as current user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $userinfo = $userauth->get_userinfo();
+ $useremail = $userinfo['email'];
+
+ $this->add_temp_writer_to_file($systemservice, $source->id, $useremail);
+ }
+
+ if (!empty($options['offline'])) {
+ $downloaded = $this->get_file($storedfile->get_reference(), $storedfile->get_filename());
+
+ $filename = $storedfile->get_filename();
+ if (isset($downloaded['newfilename'])) {
+ $filename = $downloaded['newfilename'];
+ }
+ send_file($downloaded['path'], $filename, $lifetime, $filter, false, $forcedownload, '', false, $options);
+ } else if ($source->link) {
+ redirect($source->link);
+ } else {
+ $details = 'File is missing source link';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ }
+
+ /**
+ * See if a folder exists within a folder
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $foldername The folder we are looking for.
+ * @param string $parentid The parent folder we are looking in.
+ * @return string|boolean The file id if it exists or false.
+ */
+ protected function folder_exists_in_folder(\repository_googledocs\rest $client, $foldername, $parentid) {
+ $q = '\'' . addslashes($parentid) . '\' in parents and trashed = false and name = \'' . addslashes($foldername). '\'';
+ $fields = 'files(id, name)';
+ $params = [ 'q' => $q, 'fields' => $fields];
+ $response = $client->call('list', $params);
+ $missing = true;
+ foreach ($response->files as $child) {
+ if ($child->name == $foldername) {
+ return $child->id;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Create a folder within a folder
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $foldername The folder we are creating.
+ * @param string $parentid The parent folder we are creating in.
+ *
+ * @return string The file id of the new folder.
+ */
+ protected function create_folder_in_folder(\repository_googledocs\rest $client, $foldername, $parentid) {
+ $fields = 'id';
+ $params = ['fields' => $fields];
+ $folder = ['mimeType' => 'application/vnd.google-apps.folder', 'name' => $foldername, 'parents' => [$parentid]];
+ $created = $client->call('create', $params, json_encode($folder));
+ if (empty($created->id)) {
+ $details = 'Cannot create folder:' . $foldername;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return $created->id;
+ }
+
+ /**
+ * Get simple file info for humans.
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are querying.
+ *
+ * @return stdClass
+ */
+ protected function get_file_summary(\repository_googledocs\rest $client, $fileid) {
+ $fields = "id,name,owners,parents";
+ $params = [
+ 'fileid' => $fileid,
+ 'fields' => $fields
+ ];
+ return $client->call('get', $params);
+ }
+
+ /**
+ * Copy a file and return the new file details. A side effect of the copy
+ * is that the owner will be the account authenticated with this oauth client.
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are copying.
+ * @param string $name The original filename (don't change it).
+ *
+ * @return stdClass file details.
+ */
+ protected function copy_file(\repository_googledocs\rest $client, $fileid, $name) {
+ $fields = "id,name,mimeType,webContentLink,webViewLink,size,thumbnailLink,iconLink";
+ $params = [
+ 'fileid' => $fileid,
+ 'fields' => $fields,
+ ];
+ // Keep the original name (don't put copy at the end of it).
+ $copyinfo = [];
+ if (!empty($name)) {
+ $copyinfo = [ 'name' => $name ];
+ }
+ $fileinfo = $client->call('copy', $params, json_encode($copyinfo));
+ if (empty($fileinfo->id)) {
+ $details = 'Cannot copy file:' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return $fileinfo;
+ }
+
+ /**
+ * Add a writer to the permissions on the file (temporary).
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @param string $email The email of the writer account to add.
+ * @return boolean
+ */
+ protected function add_temp_writer_to_file(\repository_googledocs\rest $client, $fileid, $email) {
+ // Expires in 7 days.
+ $expires = new DateTime();
+ $expires->add(new DateInterval("P7D"));
+
+ $updateeditor = [
+ 'emailAddress' => $email,
+ 'role' => 'writer',
+ 'type' => 'user',
+ 'expirationTime' => $expires->format(DateTime::RFC3339)
+ ];
+ $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false'];
+ $response = $client->call('create_permission', $params, json_encode($updateeditor));
+ if (empty($response->id)) {
+ $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+
+ /**
+ * Add a writer to the permissions on the file.
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @param string $email The email of the writer account to add.
+ * @return boolean
+ */
+ protected function add_writer_to_file(\repository_googledocs\rest $client, $fileid, $email) {
+ $updateeditor = [
+ 'emailAddress' => $email,
+ 'role' => 'writer',
+ 'type' => 'user'
+ ];
+ $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false'];
+ $response = $client->call('create_permission', $params, json_encode($updateeditor));
+ if (empty($response->id)) {
+ $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Move from root to folder
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @param string $folderid The id of the folder we are moving to
+ * @return boolean
+ */
+ protected function move_file_from_root_to_folder(\repository_googledocs\rest $client, $fileid, $folderid) {
+ // Set the parent.
+ $params = [
+ 'fileid' => $fileid, 'addParents' => $folderid, 'removeParents' => 'root'
+ ];
+ $response = $client->call('update', $params, ' ');
+ if (empty($response->id)) {
+ $details = 'Cannot move the file to a folder: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Prevent writers from sharing.
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @return boolean
+ */
+ protected function prevent_writers_from_sharing_file(\repository_googledocs\rest $client, $fileid) {
+ // We don't want anyone but Moodle to change the sharing settings.
+ $params = [
+ 'fileid' => $fileid
+ ];
+ $update = [
+ 'writersCanShare' => false
+ ];
+ $response = $client->call('update', $params, json_encode($update));
+ if (empty($response->id)) {
+ $details = 'Cannot prevent writers from sharing document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Allow anyone with the link to read the file.
+ *
+ * @param \repository_googledocs\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @return boolean
+ */
+ protected function set_file_sharing_anyone_with_link_can_read(\repository_googledocs\rest $client, $fileid) {
+ $updateread = [
+ 'type' => 'anyone',
+ 'role' => 'reader',
+ 'allowFileDiscovery' => 'false'
+ ];
+ $params = ['fileid' => $fileid];
+ $response = $client->call('create_permission', $params, json_encode($updateread));
+ if (empty($response->id) || $response->id != 'anyoneWithLink') {
+ $details = 'Cannot update link sharing for the document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Called when a file is selected as a "link".
+ * Invoked at MOODLE/repository/repository_ajax.php
+ *
+ * This is called at the point the reference files are being copied from the draft area to the real area
+ * (when the file has really really been selected.
+ *
+ * @param string $reference this reference is generated by
+ * repository::get_file_reference()
+ * @param context $context the target context for this new file.
+ * @param string $component the target component for this new file.
+ * @param string $filearea the target filearea for this new file.
+ * @param string $itemid the target itemid for this new file.
+ * @return string updated reference (final one before it's saved to db).
+ */
+ public function reference_file_selected($reference, $context, $component, $filearea, $itemid) {
+ // What we need to do here is transfer ownership to the system user (or copy)
+ // then set the permissions so anyone with the share link can view,
+ // finally update the reference to contain the share link if it was not
+ // already there (and point to new file id if we copied).
+
+
+ // Check this issuer is enabled.
+ if ($this->disabled) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ // Get a system oauth client and a user oauth client.
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ $details = 'Cannot connect as system user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ // Get the system user email so we can share the file with this user.
+ $systemuserinfo = $systemauth->get_userinfo();
+ $systemuseremail = $systemuserinfo['email'];
+
+ $userauth = $this->get_user_oauth_client();
+ if ($userauth === false) {
+ $details = 'Cannot connect as current user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+
+ // Get the details from the reference.
+ $source = json_decode($reference);
+ $userservice = new repository_googledocs\rest($userauth);
+ $systemservice = new repository_googledocs\rest($systemauth);
+
+ // Add Moodle as writer.
+ $this->add_writer_to_file($userservice, $source->id, $systemuseremail);
+
+ // Now move it to a sensible folder.
+ $contextlist = array_reverse($context->get_parent_contexts(true));
+
+ $cache = cache::make('repository_googledocs', 'folder');
+ $parentid = 'root';
+ $fullpath = 'root';
+ $allfolders = [];
+ foreach ($contextlist as $context) {
+ // Make sure a folder exists here.
+ $foldername = clean_param($context->get_context_name(), PARAM_PATH);
+ $allfolders[] = $foldername;
+ }
+
+ $allfolders[] = clean_param($component, PARAM_PATH);
+ $allfolders[] = clean_param($filearea, PARAM_PATH);
+ $allfolders[] = clean_param($itemid, PARAM_PATH);
+
+ // Variable $allfolders is the full path we want to put the file in - so walk it and create each folder.
+
+ foreach ($allfolders as $foldername) {
+ // Make sure a folder exists here.
+ $fullpath .= '/' . $foldername;
+
+ $folderid = $cache->get($fullpath);
+ if (empty($folderid)) {
+ $folderid = $this->folder_exists_in_folder($systemservice, $foldername, $parentid);
+ }
+ if ($folderid !== false) {
+ $cache->set($fullpath, $folderid);
+ $parentid = $folderid;
+ } else {
+ // Create it.
+ $parentid = $this->create_folder_in_folder($systemservice, $foldername, $parentid);
+ $cache->set($fullpath, $parentid);
+ }
+ }
+
+ // Copy the file so we get a snapshot file owned by Moodle.
+ $newsource = $this->copy_file($systemservice, $source->id, $source->name);
+ // Move the copied file to the correct folder.
+ $this->move_file_from_root_to_folder($systemservice, $newsource->id, $parentid);
+
+ // Set the sharing options.
+ $this->set_file_sharing_anyone_with_link_can_read($systemservice, $newsource->id);
+ $this->prevent_writers_from_sharing_file($systemservice, $newsource->id);
+
+ // Update the returned reference so that the stored_file in moodle points to the newly copied file.
+ $source->id = $newsource->id;
+ $source->link = isset($newsource->webViewLink) ? $newsource->webViewLink : '';
+ if (empty($source->link)) {
+ $source->link = isset($newsource->webContentLink) ? $newsource->webContentLink : '';
+ }
+ $reference = json_encode($source);
+
+ return $reference;
+ }
+
+ /**
+ * Get human readable file info from a the reference.
+ *
+ * @param string $reference
+ * @param int $filestatus
+ */
+ public function get_reference_details($reference, $filestatus = 0) {
+ if ($this->disabled) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+ if (empty($reference)) {
+ return get_string('unknownsource', 'repository');
+ }
+ $source = json_decode($reference);
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ return '';
+ }
+ $systemservice = new repository_googledocs\rest($systemauth);
+ $info = $this->get_file_summary($systemservice, $source->id);
+
+ $owner = '';
+ if (!empty($info->owners[0]->displayName)) {
+ $owner = $info->owners[0]->displayName;
+ }
+ if ($owner) {
+ return get_string('owner', 'repository_googledocs', $owner);
+ } else {
+ return $info->name;
+ }
}
/**
@@ -502,25 +993,40 @@ class repository_googledocs extends repository {
* @param string $classname repository class name.
*/
public static function type_config_form($mform, $classname = 'repository') {
- $callbackurl = new moodle_url(self::CALLBACKURL);
+ $url = new moodle_url('/admin/tool/oauth2/issuers.php');
+ $url = $url->out();
- $a = new stdClass;
- $a->docsurl = get_docs_url('Google_OAuth_2.0_setup');
- $a->callbackurl = $callbackurl->out(false);
-
- $mform->addElement('static', null, '', get_string('oauthinfo', 'repository_googledocs', $a));
+ $mform->addElement('static', null, '', get_string('oauth2serviceslink', 'repository_googledocs', $url));
parent::type_config_form($mform);
- $mform->addElement('text', 'clientid', get_string('clientid', 'repository_googledocs'));
- $mform->setType('clientid', PARAM_RAW_TRIMMED);
- $mform->addElement('text', 'secret', get_string('secret', 'repository_googledocs'));
- $mform->setType('secret', PARAM_RAW_TRIMMED);
+ $options = [];
+ $issuers = \core\oauth2\api::get_all_issuers();
+
+ foreach ($issuers as $issuer) {
+ $options[$issuer->get('id')] = s($issuer->get('name'));
+ }
$strrequired = get_string('required');
- $mform->addRule('clientid', $strrequired, 'required', null, 'client');
- $mform->addRule('secret', $strrequired, 'required', null, 'client');
- $mform->addElement('static', null, '', get_string('importformat', 'repository_googledocs', $a));
+ $mform->addElement('select', 'issuerid', get_string('issuer', 'repository_googledocs'), $options);
+ $mform->addHelpButton('issuerid', 'issuer', 'repository_googledocs');
+ $mform->addRule('issuerid', $strrequired, 'required', null, 'client');
+
+ $mform->addElement('static', null, '', get_string('fileoptions', 'repository_googledocs'));
+ $choices = [
+ 'internal' => get_string('internal', 'repository_googledocs'),
+ 'external' => get_string('external', 'repository_googledocs'),
+ 'both' => get_string('both', 'repository_googledocs')
+ ];
+ $mform->addElement('select', 'supportedreturntypes', get_string('supportedreturntypes', 'repository_googledocs'), $choices);
+
+ $choices = [
+ FILE_INTERNAL => get_string('internal', 'repository_googledocs'),
+ FILE_CONTROLLED_LINK => get_string('external', 'repository_googledocs'),
+ ];
+ $mform->addElement('select', 'defaultreturntype', get_string('defaultreturntype', 'repository_googledocs'), $choices);
+
+ $mform->addElement('static', null, '', get_string('importformat', 'repository_googledocs'));
// Documents.
$docsformat = array();
@@ -555,7 +1061,8 @@ class repository_googledocs extends repository {
$presentationformat['txt'] = 'txt';
core_collator::ksort($presentationformat, core_collator::SORT_NATURAL);
- $mform->addElement('select', 'presentationformat', get_string('presentationformat', 'repository_googledocs'), $presentationformat);
+ $str = get_string('presentationformat', 'repository_googledocs');
+ $mform->addElement('select', 'presentationformat', $str, $presentationformat);
$mform->setDefault('presentationformat', $presentationformat['pptx']);
$mform->setType('presentationformat', PARAM_ALPHANUM);
@@ -567,9 +1074,22 @@ class repository_googledocs extends repository {
$spreadsheetformat['xlsx'] = 'xlsx';
core_collator::ksort($spreadsheetformat, core_collator::SORT_NATURAL);
- $mform->addElement('select', 'spreadsheetformat', get_string('spreadsheetformat', 'repository_googledocs'), $spreadsheetformat);
+ $str = get_string('spreadsheetformat', 'repository_googledocs');
+ $mform->addElement('select', 'spreadsheetformat', $str, $spreadsheetformat);
$mform->setDefault('spreadsheetformat', $spreadsheetformat['xlsx']);
$mform->setType('spreadsheetformat', PARAM_ALPHANUM);
}
}
-// Icon from: http://www.iconspedia.com/icon/google-2706.html.
+
+/**
+ * Callback to get the required scopes for system account.
+ *
+ * @param \core\oauth2\issuer $issuer
+ * @return string
+ */
+function repository_googledocs_oauth2_system_scopes(\core\oauth2\issuer $issuer) {
+ if ($issuer->get('id') == get_config('googledocs', 'issuerid')) {
+ return 'https://www.googleapis.com/auth/drive';
+ }
+ return '';
+}
diff --git a/repository/googledocs/tests/generator/lib.php b/repository/googledocs/tests/generator/lib.php
index 168c406a795..977bc9c007e 100644
--- a/repository/googledocs/tests/generator/lib.php
+++ b/repository/googledocs/tests/generator/lib.php
@@ -23,6 +23,9 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+use \core\oauth2\issuer;
+use \core\oauth2\endpoint;
+
/**
* Google Docs repository data generator class
*
@@ -41,11 +44,33 @@ class repository_googledocs_generator extends testing_repository_generator {
*/
protected function prepare_type_record(array $record) {
$record = parent::prepare_type_record($record);
- if (!isset($record['clientid'])) {
- $record['clientid'] = 'clientid';
+ $issuerrecord = (object) [
+ 'name' => 'Google',
+ 'image' => 'https://accounts.google.com/favicon.ico',
+ 'baseurl' => 'http://accounts.google.com/',
+ 'loginparamsoffline' => 'access_type=offline&prompt=consent',
+ 'showonloginpage' => true
+ ];
+
+ $issuer = new issuer(0, $issuerrecord);
+ $issuer->create();
+
+ $endpointrecord = (object) [
+ 'issuerid' => $issuer->get('id'),
+ 'name' => 'discovery_endpoint',
+ 'url' => 'https://accounts.google.com/.well-known/openid-configuration'
+ ];
+ $endpoint = new endpoint(0, $endpointrecord);
+ $endpoint->create();
+
+ if (!isset($record['issuerid'])) {
+ $record['issuerid'] = $issuer->get('id');
}
- if (!isset($record['secret'])) {
- $record['secret'] = 'secret';
+ if (!isset($record['defaultreturntype'])) {
+ $record['defaultreturntype'] = FILE_INTERNAL;
+ }
+ if (!isset($record['supportedreturntypes'])) {
+ $record['supportedreturntypes'] = 'both';
}
if (!isset($record['documentformat'])) {
$record['documentformat'] = 'pdf';
diff --git a/repository/googledocs/version.php b/repository/googledocs/version.php
index 04eaa0e8fab..64be4b11666 100644
--- a/repository/googledocs/version.php
+++ b/repository/googledocs/version.php
@@ -25,6 +25,6 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2017011100; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2017031001; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2016112900; // Requires this Moodle version.
$plugin->component = 'repository_googledocs'; // Full name of the plugin (used for diagnostics).
diff --git a/repository/lib.php b/repository/lib.php
index c11f4cdc61f..7c0fa0b2da1 100644
--- a/repository/lib.php
+++ b/repository/lib.php
@@ -30,6 +30,8 @@ require_once($CFG->libdir . '/formslib.php');
define('FILE_EXTERNAL', 1);
define('FILE_INTERNAL', 2);
define('FILE_REFERENCE', 4);
+define('FILE_CONTROLLED_LINK', 8);
+
define('RENAME_SUFFIX', '_2');
/**
@@ -1000,7 +1002,7 @@ abstract class repository implements cacheable_object {
* onlyvisible : bool (default true)
* type : string return instances of this type only
* accepted_types : string|array return instances that contain files of those types (*, web_image, .pdf, ...)
- * return_types : int combination of FILE_INTERNAL & FILE_EXTERNAL & FILE_REFERENCE.
+ * return_types : int combination of FILE_INTERNAL & FILE_EXTERNAL & FILE_REFERENCE & FILE_CONTROLLED_LINK.
* 0 means every type. The default is FILE_INTERNAL | FILE_EXTERNAL.
* userid : int if specified, instances belonging to other users will not be returned
*
@@ -1281,6 +1283,28 @@ abstract class repository implements cacheable_object {
public function cache_file_by_reference($reference, $storedfile) {
}
+ /**
+ * reference_file_selected
+ *
+ * This function is called when a controlled link file is selected in a file picker and the form is
+ * saved. The expected behaviour for repositories supporting controlled links is to
+ * - copy the file to the moodle system account
+ * - put it in a folder that reflects the context it is being used
+ * - make sure the sharing permissions are correct (read-only with the link)
+ * - return a new reference string pointing to the newly copied file.
+ *
+ * @param string $reference this reference is generated by
+ * repository::get_file_reference()
+ * @param context $context the target context for this new file.
+ * @param string $component the target component for this new file.
+ * @param string $filearea the target filearea for this new file.
+ * @param string $itemid the target itemid for this new file.
+ * @return string updated reference (final one before it's saved to db).
+ */
+ public function reference_file_selected($reference, $context, $component, $filearea, $itemid) {
+ return $reference;
+ }
+
/**
* Return the source information
*
@@ -1890,6 +1914,17 @@ abstract class repository implements cacheable_object {
return (FILE_INTERNAL | FILE_EXTERNAL);
}
+ /**
+ * Tells how the file can be picked from this repository
+ *
+ * Maximum value is FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE
+ *
+ * @return int
+ */
+ public function default_returntype() {
+ return FILE_INTERNAL;
+ }
+
/**
* Provide repository instance information for Ajax
*
@@ -1904,6 +1939,7 @@ abstract class repository implements cacheable_object {
$meta->icon = $OUTPUT->image_url('icon', 'repository_'.$meta->type)->out(false);
$meta->supported_types = file_get_typegroup('extension', $this->supported_filetypes());
$meta->return_types = $this->supported_returntypes();
+ $meta->defaultreturntype = $this->default_returntype();
$meta->sortorder = $this->options['sortorder'];
return $meta;
}
diff --git a/repository/onedrive/classes/access.php b/repository/onedrive/classes/access.php
new file mode 100644
index 00000000000..10800cd26dd
--- /dev/null
+++ b/repository/onedrive/classes/access.php
@@ -0,0 +1,57 @@
+.
+
+/**
+ * Class for loading/storing access records from the DB.
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace repository_onedrive;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class for loading/storing issuer from the DB
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class access extends persistent {
+
+ const TABLE = 'repository_onedrive_access';
+
+ /**
+ * Return the definition of the properties of this model.
+ *
+ * @return array
+ */
+ protected static function define_properties() {
+ return array(
+ 'permissionid' => array(
+ 'type' => PARAM_RAW
+ ),
+ 'itemid' => array(
+ 'type' => PARAM_RAW
+ )
+ );
+ }
+
+}
diff --git a/repository/onedrive/classes/remove_temp_access_task.php b/repository/onedrive/classes/remove_temp_access_task.php
new file mode 100644
index 00000000000..d6189d42aeb
--- /dev/null
+++ b/repository/onedrive/classes/remove_temp_access_task.php
@@ -0,0 +1,79 @@
+.
+
+/**
+ * A scheduled task.
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace repository_onedrive;
+
+use \core\task\scheduled_task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Simple task to delete temporary permission records.
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class remove_temp_access_task extends scheduled_task {
+
+ /**
+ * Get a descriptive name for this task (shown to admins).
+ *
+ * @return string
+ */
+ public function get_name() {
+ return get_string('removetempaccesstask', 'repository_onedrive');
+ }
+
+ /**
+ * Do the job.
+ * Throw exceptions on errors (the job will be retried).
+ */
+ public function execute() {
+ $accessrecords = access::get_records();
+ $expires = new DateTime();
+ $expires->sub(new DateInterval("P7D"));
+ $timestamp = $expires->getTimestamp();
+
+ $issuerid = get_config('repository_onedrive', 'issuerid');
+ $issuer = \core\oauth2\api::get_issuer_by_id($issuerid);
+
+ // Add the current user as an OAuth writer.
+ $systemauth = \core\oauth2\api::get_system_oauth_client($issuer);
+
+ if ($systemauth === false) {
+ $details = 'Cannot connect as system user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $systemservice = new repository_onedrive\rest($systemauth);
+
+ foreach ($accessrecords as $access) {
+ if ($access->get('timemodified') < $timestamp) {
+ $params = ['permissionid' => $access->get('permissionid'), 'itemid' => $access->get('itemid')];
+ $systemservice->call('delete_permission', $params);
+ $access->delete();
+ }
+ }
+ }
+
+}
diff --git a/repository/onedrive/classes/rest.php b/repository/onedrive/classes/rest.php
new file mode 100644
index 00000000000..47fac12e577
--- /dev/null
+++ b/repository/onedrive/classes/rest.php
@@ -0,0 +1,159 @@
+.
+
+/**
+ * Microsoft Graph API Rest Interface.
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace repository_onedrive;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Microsoft Graph API Rest Interface.
+ *
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class rest extends \core\oauth2\rest {
+
+ /**
+ * Define the functions of the rest API.
+ *
+ * @return array Example:
+ * [ 'listFiles' => [ 'method' => 'get', 'endpoint' => 'http://...', 'args' => [ 'folder' => PARAM_STRING ] ] ]
+ */
+ public function get_api_functions() {
+ return [
+ 'list' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/{parent}/children',
+ 'method' => 'get',
+ 'args' => [
+ '$select' => PARAM_RAW,
+ '$expand' => PARAM_RAW,
+ 'parent' => PARAM_RAW,
+ '$skip' => PARAM_INT,
+ '$skipToken' => PARAM_RAW,
+ '$count' => PARAM_INT
+ ],
+ 'response' => 'json'
+ ],
+ 'search' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/{parent}/search(q=\'{search}\')',
+ 'method' => 'get',
+ 'args' => [
+ 'search' => PARAM_NOTAGS,
+ '$select' => PARAM_RAW,
+ 'parent' => PARAM_RAW,
+ '$skip' => PARAM_INT,
+ '$skipToken' => PARAM_RAW,
+ '$count' => PARAM_INT
+ ],
+ 'response' => 'json'
+ ],
+ 'get' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{fileid}',
+ 'method' => 'get',
+ 'args' => [
+ 'fileid' => PARAM_RAW,
+ '$select' => PARAM_RAW,
+ '$expand' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'list_permissions' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{fileid}/permissions',
+ 'method' => 'get',
+ 'args' => [
+ '$select' => PARAM_RAW,
+ '$expand' => PARAM_RAW,
+ 'fileid' => PARAM_RAW,
+ '$skip' => PARAM_INT,
+ '$skipToken' => PARAM_RAW,
+ '$count' => PARAM_INT
+ ],
+ 'response' => 'json'
+ ],
+ 'create_permission' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{fileid}/invite',
+ 'method' => 'post',
+ 'args' => [
+ 'fileid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'get_file_by_path' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/root:/{fullpath}',
+ 'method' => 'get',
+ 'args' => [
+ 'fullpath' => PARAM_RAW,
+ '$select' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'create_folder' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{parentid}/children',
+ 'method' => 'post',
+ 'args' => [
+ 'parentid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'create_link' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{fileid}/createLink',
+ 'method' => 'post',
+ 'args' => [
+ 'fileid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ 'get_drive' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive',
+ 'method' => 'get',
+ 'args' => [],
+ 'response' => 'json'
+ ],
+ 'delete_file_by_path' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/root:/{fullpath}',
+ 'method' => 'delete',
+ 'args' => [
+ 'fullpath' => PARAM_RAW,
+ ],
+ 'response' => 'json'
+ ],
+ 'copy_share' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/shares/{sharetoken}/root/copy',
+ 'method' => 'post',
+ 'args' => [
+ 'sharetoken' => PARAM_RAW,
+ ],
+ 'response' => 'json'
+ ],
+ 'delete_permission' => [
+ 'endpoint' => 'https://graph.microsoft.com/v1.0/me/drive/items/{fileid}/permissions/{permissionid}',
+ 'method' => 'delete',
+ 'args' => [
+ 'fileid' => PARAM_RAW,
+ 'permissionid' => PARAM_RAW
+ ],
+ 'response' => 'json'
+ ],
+ ];
+ }
+}
diff --git a/repository/onedrive/db/access.php b/repository/onedrive/db/access.php
new file mode 100644
index 00000000000..2a1eec0609e
--- /dev/null
+++ b/repository/onedrive/db/access.php
@@ -0,0 +1,33 @@
+.
+
+/**
+ * Capability definitions for onedrive repository
+ *
+ * @package repository_onedrive
+ * @copyright 2012 Lancaster University Network Services Ltd
+ * @author Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$capabilities = array(
+ 'repository/onedrive:view' => array(
+ 'captype' => 'read',
+ 'contextlevel' => CONTEXT_MODULE,
+ 'archetypes' => array(
+ 'user' => CAP_ALLOW
+ )
+ )
+);
diff --git a/repository/onedrive/db/caches.php b/repository/onedrive/db/caches.php
new file mode 100644
index 00000000000..23f15b0d481
--- /dev/null
+++ b/repository/onedrive/db/caches.php
@@ -0,0 +1,39 @@
+.
+
+/**
+ * Cache definitions.
+ *
+ * @package repository_onedrive
+ * @copyright 2013 Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$definitions = array(
+ // Used to store file ids for folders.
+ // The keys used are full path to the folder, the values are the id in office 365.
+ // The static acceleration size has been based upon the depths of a single path.
+ 'folder' => array(
+ 'mode' => cache_store::MODE_APPLICATION,
+ 'simplekeys' => false,
+ 'simpledata' => true,
+ 'staticacceleration' => true,
+ 'staticaccelerationsize' => 10,
+ 'canuselocalstore' => true
+ ),
+);
diff --git a/repository/onedrive/db/install.xml b/repository/onedrive/db/install.xml
new file mode 100644
index 00000000000..3c56b317681
--- /dev/null
+++ b/repository/onedrive/db/install.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/repository/onedrive/db/tasks.php b/repository/onedrive/db/tasks.php
new file mode 100644
index 00000000000..1a30f8d6e6a
--- /dev/null
+++ b/repository/onedrive/db/tasks.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * Definition of repository_onedrive scheduled tasks.
+ *
+ * The handlers defined on this file are processed and registered into
+ * the Moodle DB after any install or upgrade operation. All plugins
+ * support this.
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/* List of handlers */
+
+$tasks = array(
+ array(
+ 'classname' => 'repository_onedrive\remove_temp_access_task',
+ 'blocking' => 0,
+ 'minute' => 'R',
+ 'hour' => 'R',
+ 'day' => '*',
+ 'dayofweek' => 'R',
+ 'month' => '*'
+ ),
+);
diff --git a/repository/onedrive/db/upgrade.php b/repository/onedrive/db/upgrade.php
new file mode 100644
index 00000000000..e6d78b6fc15
--- /dev/null
+++ b/repository/onedrive/db/upgrade.php
@@ -0,0 +1,32 @@
+.
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Upgrade this plugin.
+ *
+ * @param int $oldversion the version we are upgrading from
+ * @package repository_onedrive
+ * @return bool result
+ */
+function xmldb_repository_onedrive_upgrade($oldversion) {
+ global $DB;
+
+ $dbman = $DB->get_manager();
+
+ return true;
+}
diff --git a/repository/onedrive/importskydrive.php b/repository/onedrive/importskydrive.php
new file mode 100644
index 00000000000..6eeeb13de7d
--- /dev/null
+++ b/repository/onedrive/importskydrive.php
@@ -0,0 +1,57 @@
+.
+
+/**
+ * Import files from skydrive.
+ *
+ * @package repository_onedrive
+ * @copyright 2017 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../config.php');
+
+$PAGE->set_url('/repository/onedrive/importskydrive.php');
+$PAGE->set_context(context_system::instance());
+$strheading = get_string('importskydrivefiles', 'repository_onedrive');
+$PAGE->set_title($strheading);
+$PAGE->set_heading($strheading);
+
+require_login();
+
+require_capability('moodle/site:config', context_system::instance());
+
+$confirm = optional_param('confirm', false, PARAM_BOOL);
+
+if ($confirm) {
+ require_sesskey();
+ require_once($CFG->dirroot . '/repository/lib.php');
+ require_once($CFG->dirroot . '/repository/onedrive/lib.php');
+
+ if (repository_onedrive::import_skydrive_files()) {
+ $mesg = get_string('skydrivefilesimported', 'repository_onedrive');
+ redirect(new moodle_url('/admin/repository.php'), $mesg, null, \core\output\notification::NOTIFY_SUCCESS);
+ } else {
+ $mesg = get_string('skydrivefilesnotimported', 'repository_onedrive');
+ redirect(new moodle_url('/admin/repository.php'), $mesg, null, \core\output\notification::NOTIFY_ERROR);
+ }
+} else {
+ $continueurl = new moodle_url('/repository/onedrive/importskydrive.php', ['confirm' => true]);
+ $cancelurl = new moodle_url('/admin/repository.php');
+ echo $OUTPUT->header();
+ echo $OUTPUT->confirm(get_string('confirmimportskydrive', 'repository_onedrive'), $continueurl, $cancelurl);
+ echo $OUTPUT->footer();
+}
diff --git a/repository/onedrive/lang/en/repository_onedrive.php b/repository/onedrive/lang/en/repository_onedrive.php
new file mode 100644
index 00000000000..46f32672beb
--- /dev/null
+++ b/repository/onedrive/lang/en/repository_onedrive.php
@@ -0,0 +1,46 @@
+.
+
+/**
+ * Language file definitions for skydrive repository
+ *
+ * @package repository_skydrive
+ * @copyright 2012 Lancaster University Network Services Ltd
+ * @author Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$string['configplugin'] = 'Configure OneDrive plugin';
+$string['skydrive:view'] = 'View OneDrive repository';
+$string['pluginname'] = 'Microsoft OneDrive';
+$string['importskydrivefiles'] = 'Import files from Microsoft SkyDrive repository';
+$string['issuer'] = 'OAuth 2 service';
+$string['issuer_help'] = 'Select the OAuth 2 service that is configured to talk to the OneDrive API. If the services does not exist yet, you might need to create it.';
+$string['servicenotenabled'] = 'Access not configured.';
+$string['oauth2serviceslink'] = 'OAuth 2 Services Configuration';
+$string['searchfor'] = 'Search for {$a}';
+$string['internal'] = 'Internal (files stored in Moodle)';
+$string['external'] = 'External (only links stored in Moodle)';
+$string['both'] = 'Internal and External';
+$string['skydrivefilesexist'] = 'Files found in the Microsoft SkyDrive repository. This repository is deprecated by Microsoft - the files can be automatically imported to this Microsoft OneDrive repository.';
+$string['supportedreturntypes'] = 'Supported files';
+$string['defaultreturntype'] = 'Default return type';
+$string['fileoptions'] = 'The types and defaults for returned files is configurable here. Note that all files linked externally will be updated so that the owner is the Moodle system account.';
+$string['owner'] = 'Owned by: {$a}';
+$string['cachedef_folder'] = 'OneDrive File IDs for folders in the system account';
+$string['confirmimportskydrive'] = 'Are you sure you want to import all files from the "Microsoft SkyDrive" repository to the "Microsoft OneDrive" repository? As long as the Microsoft OneDrive repository is already configured and working - all imported files will continue working as before. There is no way to undo these changes.';
+$string['skydrivefilesimported'] = 'All files were imported from the Microsoft SkyDrive repository.';
+$string['skydrivefilesnotimported'] = 'Some files could not be imported from the Microsoft SkyDrive repository.';
+
diff --git a/repository/onedrive/lib.php b/repository/onedrive/lib.php
new file mode 100644
index 00000000000..73118a34230
--- /dev/null
+++ b/repository/onedrive/lib.php
@@ -0,0 +1,1118 @@
+.
+
+/**
+ * Microsoft Live Skydrive Repository Plugin
+ *
+ * @package repository_onedrive
+ * @copyright 2012 Lancaster University Network Services Ltd
+ * @author Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Microsoft onedrive repository plugin.
+ *
+ * @package repository_onedrive
+ * @copyright 2012 Lancaster University Network Services Ltd
+ * @author Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class repository_onedrive extends repository {
+ /**
+ * OAuth 2 client
+ * @var \core\oauth2\client
+ */
+ private $client = null;
+
+ /**
+ * OAuth 2 Issuer
+ * @var \core\oauth2\issuer
+ */
+ private $issuer = null;
+
+ /**
+ * Additional scopes required for drive.
+ */
+ const SCOPES = 'files.readwrite.all';
+
+ /**
+ * Constructor.
+ *
+ * @param int $repositoryid repository instance id.
+ * @param int|stdClass $context a context id or context object.
+ * @param array $options repository options.
+ * @param int $readonly indicate this repo is readonly or not.
+ * @return void
+ */
+ public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array(), $readonly = 0) {
+ parent::__construct($repositoryid, $context, $options, $readonly = 0);
+
+ try {
+ $this->issuer = \core\oauth2\api::get_issuer(get_config('onedrive', 'issuerid'));
+ } catch (dml_missing_record_exception $e) {
+ $this->disabled = true;
+ }
+
+ if ($this->issuer && !$this->issuer->get('enabled')) {
+ $this->disabled = true;
+ }
+ }
+
+ /**
+ * Get a cached user authenticated oauth client.
+ *
+ * @param moodle_url $overrideurl - Use this url instead of the repo callback.
+ * @return \core\oauth2\client
+ */
+ protected function get_user_oauth_client($overrideurl = false) {
+ if ($this->client) {
+ return $this->client;
+ }
+ if ($overrideurl) {
+ $returnurl = $overrideurl;
+ } else {
+ $returnurl = new moodle_url('/repository/repository_callback.php');
+ $returnurl->param('callback', 'yes');
+ $returnurl->param('repo_id', $this->id);
+ $returnurl->param('sesskey', sesskey());
+ }
+
+ $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
+
+ return $this->client;
+ }
+
+ /**
+ * Checks whether the user is authenticate or not.
+ *
+ * @return bool true when logged in.
+ */
+ public function check_login() {
+ $client = $this->get_user_oauth_client();
+ return $client->is_logged_in();
+ }
+
+ /**
+ * Print or return the login form.
+ *
+ * @return void|array for ajax.
+ */
+ public function print_login() {
+ $client = $this->get_user_oauth_client();
+ $url = $client->get_login_url();
+
+ if ($this->options['ajax']) {
+ $popup = new stdClass();
+ $popup->type = 'popup';
+ $popup->url = $url->out(false);
+ return array('login' => array($popup));
+ } else {
+ echo ''.get_string('login', 'repository').'';
+ }
+ }
+
+ /**
+ * Build the breadcrumb from a path.
+ *
+ * @param string $path to create a breadcrumb from.
+ * @return array containing name and path of each crumb.
+ */
+ protected function build_breadcrumb($path) {
+ $bread = explode('/', $path);
+ $crumbtrail = '';
+ foreach ($bread as $crumb) {
+ list($id, $name) = $this->explode_node_path($crumb);
+ $name = empty($name) ? $id : $name;
+ $breadcrumb[] = array(
+ 'name' => $name,
+ 'path' => $this->build_node_path($id, $name, $crumbtrail)
+ );
+ $tmp = end($breadcrumb);
+ $crumbtrail = $tmp['path'];
+ }
+ return $breadcrumb;
+ }
+
+ /**
+ * Generates a safe path to a node.
+ *
+ * Typically, a node will be id|Name of the node.
+ *
+ * @param string $id of the node.
+ * @param string $name of the node, will be URL encoded.
+ * @param string $root to append the node on, must be a result of this function.
+ * @return string path to the node.
+ */
+ protected function build_node_path($id, $name = '', $root = '') {
+ $path = $id;
+ if (!empty($name)) {
+ $path .= '|' . urlencode($name);
+ }
+ if (!empty($root)) {
+ $path = trim($root, '/') . '/' . $path;
+ }
+ return $path;
+ }
+
+ /**
+ * Returns information about a node in a path.
+ *
+ * @see self::build_node_path()
+ * @param string $node to extrat information from.
+ * @return array about the node.
+ */
+ protected function explode_node_path($node) {
+ if (strpos($node, '|') !== false) {
+ list($id, $name) = explode('|', $node, 2);
+ $name = urldecode($name);
+ } else {
+ $id = $node;
+ $name = '';
+ }
+ $id = urldecode($id);
+ return array(
+ 0 => $id,
+ 1 => $name,
+ 'id' => $id,
+ 'name' => $name
+ );
+ }
+
+ /**
+ * List the files and folders.
+ *
+ * @param string $path path to browse.
+ * @param string $page page to browse.
+ * @return array of result.
+ */
+ public function get_listing($path='', $page = '') {
+ if (empty($path)) {
+ $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
+ }
+
+ if ($this->disabled) {
+ // Empty list of files for disabled repository.
+ return ['dynload' => false, 'list' => [], 'nologin' => true];
+ }
+
+ // We analyse the path to extract what to browse.
+ $trail = explode('/', $path);
+ $uri = array_pop($trail);
+ list($id, $name) = $this->explode_node_path($uri);
+
+ // Handle the special keyword 'search', which we defined in self::search() so that
+ // we could set up a breadcrumb in the search results. In any other case ID would be
+ // 'root' which is a special keyword, or a parent (folder) ID.
+ if ($id === 'search') {
+ $q = $name;
+ $id = 'root';
+
+ // Append the active path for search.
+ $str = get_string('searchfor', 'repository_onedrive', $searchtext);
+ $path = $this->build_node_path('search', $str, $path);
+ }
+
+ // Query the Drive.
+ $parent = $id;
+ if ($parent != 'root') {
+ $parent = 'items/' . $parent;
+ }
+ $q = '';
+ $results = $this->query($q, $path, $parent);
+
+ $ret = [];
+ $ret['dynload'] = true;
+ $ret['path'] = $this->build_breadcrumb($path);
+ $ret['list'] = $results;
+ $ret['manage'] = 'https://www.office.com/';
+ return $ret;
+ }
+
+ /**
+ * Search throughout the OneDrive
+ *
+ * @param string $searchtext text to search for.
+ * @param int $page search page.
+ * @return array of results.
+ */
+ public function search($searchtext, $page = 0) {
+ $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
+ $str = get_string('searchfor', 'repository_onedrive', $searchtext);
+ $path = $this->build_node_path('search', $str, $path);
+
+ // Query the Drive.
+ $parent = 'root';
+ $results = $this->query($searchtext, $path, 'root');
+
+ $ret = [];
+ $ret['dynload'] = true;
+ $ret['path'] = $this->build_breadcrumb($path);
+ $ret['list'] = $results;
+ $ret['manage'] = 'https://www.office.com/';
+ return $ret;
+ }
+
+ /**
+ * Query OneDrive for files and folders using a search query.
+ *
+ * Documentation about the query format can be found here:
+ * https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/driveitem
+ * https://developer.microsoft.com/en-us/graph/docs/overview/query_parameters
+ *
+ * This returns a list of files and folders with their details as they should be
+ * formatted and returned by functions such as get_listing() or search().
+ *
+ * @param string $q search query as expected by the Graph API.
+ * @param string $path parent path of the current files, will not be used for the query.
+ * @param string $parent Parent id.
+ * @param int $page page.
+ * @return array of files and folders.
+ */
+ protected function query($q, $path = null, $parent = null, $page = 0) {
+ global $OUTPUT;
+
+ $files = [];
+ $folders = [];
+ $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,thumbnails";
+ $params = ['$select' => $fields, '$expand' => 'thumbnails', 'parent' => $parent];
+
+ try {
+ // Retrieving files and folders.
+ $client = $this->get_user_oauth_client();
+ $service = new repository_onedrive\rest($client);
+
+ if (!empty($q)) {
+ $params['search'] = urlencode($q);
+
+ // MS does not return thumbnails on a search.
+ unset($params['$expand']);
+ $response = $service->call('search', $params);
+ } else {
+ $response = $service->call('list', $params);
+ }
+ } catch (Exception $e) {
+ if ($e->getCode() == 403 && strpos($e->getMessage(), 'Access Not Configured') !== false) {
+ throw new repository_exception('servicenotenabled', 'repository_onedrive');
+ } else {
+ throw $e;
+ }
+ }
+
+ $remotefiles = isset($response->value) ? $response->value : [];
+ foreach ($remotefiles as $remotefile) {
+ if (!empty($remotefile->folder)) {
+ // This is a folder.
+ $folders[$remotefile->id] = [
+ 'title' => $remotefile->name,
+ 'path' => $this->build_node_path($remotefile->id, $remotefile->name, $path),
+ 'date' => strtotime($remotefile->lastModifiedDateTime),
+ 'thumbnail' => $OUTPUT->pix_url(file_folder_icon(64))->out(false),
+ 'thumbnail_height' => 64,
+ 'thumbnail_width' => 64,
+ 'children' => []
+ ];
+ } else {
+ // We can download all other file types.
+ $title = $remotefile->name;
+ $source = json_encode([
+ 'id' => $remotefile->id,
+ 'name' => $remotefile->name,
+ 'link' => $remotefile->webUrl
+ ]);
+
+ $thumb = '';
+ $thumbwidth = 0;
+ $thumbheight = 0;
+ $extendedinfoerr = false;
+
+ if (empty($remotefile->thumbnails)) {
+ // Try and get it directly from the item.
+ $params = ['fileid' => $remotefile->id, '$select' => $fields, '$expand' => 'thumbnails'];
+ try {
+ $response = $service->call('get', $params);
+ $remotefile = $response;
+ } catch (Exception $e) {
+ // This is not a failure condition - we just could not get extended info about the file.
+ $extendedinfoerr = true;
+ }
+ }
+
+ if (!empty($remotefile->thumbnails)) {
+ $thumbs = $remotefile->thumbnails;
+ if (count($thumbs)) {
+ $first = reset($thumbs);
+ if (!empty($first->medium) && !empty($first->medium->url)) {
+ $thumb = $first->medium->url;
+ $thumbwidth = min($first->medium->width, 64);
+ $thumbheight = min($first->medium->height, 64);
+ }
+ }
+ }
+
+ $files[$remotefile->id] = [
+ 'title' => $title,
+ 'source' => $source,
+ 'date' => strtotime($remotefile->lastModifiedDateTime),
+ 'size' => isset($remotefile->size) ? $remotefile->size : null,
+ 'thumbnail' => $thumb,
+ 'thumbnail_height' => $thumbwidth,
+ 'thumbnail_width' => $thumbheight,
+ ];
+ }
+ }
+
+ // Filter and order the results.
+ $files = array_filter($files, [$this, 'filter']);
+ core_collator::ksort($files, core_collator::SORT_NATURAL);
+ core_collator::ksort($folders, core_collator::SORT_NATURAL);
+ return array_merge(array_values($folders), array_values($files));
+ }
+
+ /**
+ * Logout.
+ *
+ * @return string
+ */
+ public function logout() {
+ $client = $this->get_user_oauth_client();
+ $client->log_out();
+ return parent::logout();
+ }
+
+ /**
+ * Get a file.
+ *
+ * @param string $reference reference of the file.
+ * @param string $filename filename to save the file to.
+ * @return string JSON encoded array of information about the file.
+ */
+ public function get_file($reference, $filename = '') {
+ global $CFG;
+
+ if ($this->disabled) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ $client = $this->get_user_oauth_client();
+ $base = 'https://graph.microsoft.com/v1.0/';
+
+ $sourceinfo = json_decode($reference);
+ $sourceurl = new moodle_url($base . 'me/drive/items/' . $sourceinfo->id . '/content');
+ $source = $sourceurl->out(false);
+
+ // We use download_one and not the rest API because it has special timeouts etc.
+ $path = $this->prepare_file($filename);
+ $options = ['filepath' => $path, 'timeout' => 15, 'followlocation' => true, 'maxredirs' => 5];
+ $result = $client->download_one($source, null, $options);
+
+ if ($result) {
+ @chmod($path, $CFG->filepermissions);
+ return array(
+ 'path' => $path,
+ 'url' => $reference
+ );
+ }
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ /**
+ * Prepare file reference information.
+ *
+ * We are using this method to clean up the source to make sure that it
+ * is a valid source.
+ *
+ * @param string $source of the file.
+ * @return string file reference.
+ */
+ public function get_file_reference($source) {
+ // We could do some magic upgrade code here.
+ return $source;
+ }
+
+ /**
+ * What kind of files will be in this repository?
+ *
+ * @return array return '*' means this repository support any files, otherwise
+ * return mimetypes of files, it can be an array
+ */
+ public function supported_filetypes() {
+ return '*';
+ }
+
+ /**
+ * Tells how the file can be picked from this repository.
+ *
+ * @return int
+ */
+ public function supported_returntypes() {
+ // We can only support references if the system account is connected.
+ if (!empty($this->issuer) && $this->issuer->is_system_account_connected()) {
+ $setting = get_config('onedrive', 'supportedreturntypes');
+ if ($setting == 'internal') {
+ return FILE_INTERNAL;
+ } else if ($setting == 'external') {
+ return FILE_CONTROLLED_LINK;
+ } else {
+ return FILE_CONTROLLED_LINK | FILE_INTERNAL;
+ }
+ } else {
+ return FILE_INTERNAL;
+ }
+ }
+
+ /**
+ * Which return type should be selected by default.
+ *
+ * @return int
+ */
+ public function default_returntype() {
+ $setting = get_config('onedrive', 'defaultreturntype');
+ $supported = get_config('onedrive', 'supportedreturntypes');
+ if (($setting == FILE_INTERNAL && $supported != 'external') || $supported == 'internal') {
+ return FILE_INTERNAL;
+ } else {
+ return FILE_CONTROLLED_LINK;
+ }
+ }
+
+ /**
+ * Return names of the general options.
+ * By default: no general option name.
+ *
+ * @return array
+ */
+ public static function get_type_option_names() {
+ return array('issuerid', 'pluginname', 'defaultreturntype', 'supportedreturntypes');
+ }
+
+ /**
+ * Store the access token.
+ */
+ public function callback() {
+ $client = $this->get_user_oauth_client();
+ // This will upgrade to an access token if we have an authorization code and save the access token in the session.
+ $client->is_logged_in();
+ }
+
+ /**
+ * Repository method to serve the referenced file
+ *
+ * @see send_stored_file
+ *
+ * @param stored_file $storedfile the file that contains the reference
+ * @param int $lifetime Number of seconds before the file should expire from caches (null means $CFG->filelifetime)
+ * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
+ * @param bool $forcedownload If true (default false), forces download of file rather than view in browser/plugin
+ * @param array $options additional options affecting the file serving
+ */
+ public function send_file($storedfile, $lifetime=null , $filter=0, $forcedownload=false, array $options = null) {
+ if ($this->disabled) {
+ throw new repository_exception('cannotdownload', 'repository');
+ }
+
+ $source = json_decode($storedfile->get_reference());
+
+ $fb = get_file_browser();
+ $context = context::instance_by_id($storedfile->get_contextid(), MUST_EXIST);
+ $info = $fb->get_file_info($context,
+ $storedfile->get_component(),
+ $storedfile->get_filearea(),
+ $storedfile->get_itemid(),
+ $storedfile->get_filepath(),
+ $storedfile->get_filename());
+
+ if (empty($options['offline']) && !empty($info) && $info->is_writable()) {
+ // Add the current user as an OAuth writer.
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ $details = 'Cannot connect as system user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $systemservice = new repository_onedrive\rest($systemauth);
+
+ // Get the user oauth so we can get the account to add.
+ $url = moodle_url::make_pluginfile_url($storedfile->get_contextid(),
+ $storedfile->get_component(),
+ $storedfile->get_filearea(),
+ $storedfile->get_itemid(),
+ $storedfile->get_filepath(),
+ $storedfile->get_filename(),
+ $forcedownload);
+ $url->param('sesskey', sesskey());
+ $userauth = $this->get_user_oauth_client($url);
+ if (!$userauth->is_logged_in()) {
+ redirect($userauth->get_login_url());
+ }
+ if ($userauth === false) {
+ $details = 'Cannot connect as current user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $userinfo = $userauth->get_userinfo();
+ $useremail = $userinfo['email'];
+
+ $this->add_temp_writer_to_file($systemservice, $source->id, $useremail);
+ }
+
+ if (!empty($options['offline'])) {
+ $downloaded = $this->get_file($storedfile->get_reference(), $storedfile->get_filename());
+ $filename = $storedfile->get_filename();
+ send_file($downloaded['path'], $filename, $lifetime, $filter, false, $forcedownload, '', false, $options);
+ } else if ($source->link) {
+ redirect($source->link);
+ } else {
+ $details = 'File is missing source link';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ }
+
+ /**
+ * List the permissions on a file.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fileid The id of the file.
+ * @return array
+ */
+ protected function list_file_permissions(\repository_onedrive\rest $client, $fileid) {
+ $fields = "id,roles,link,grantedTo";
+ return $client->call('list_permissions', ['fileid' => $fileid, '$select' => $fields]);
+ }
+
+ /**
+ * See if a folder exists within a folder
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fullpath
+ * @return string|boolean The file id if it exists or false.
+ */
+ protected function get_file_id_by_path(\repository_onedrive\rest $client, $fullpath) {
+ $fields = "id";
+ try {
+ $response = $client->call('get_file_by_path', ['fullpath' => $fullpath, '$select' => $fields]);
+ } catch (\core\oauth2\rest_exception $re) {
+ return false;
+ }
+ return $response->id;
+ }
+
+ /**
+ * Delete a file by full path.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fullpath
+ * @return boolean
+ */
+ protected function delete_file_by_path(\repository_onedrive\rest $client, $fullpath) {
+ try {
+ $response = $client->call('delete_file_by_path', ['fullpath' => $fullpath]);
+ } catch (\core\oauth2\rest_exception $re) {
+ return false;
+ }
+ return true;
+ }
+
+
+ /**
+ * Get a file summary by full path.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fullpath
+ * @return stdClass
+ */
+ protected function get_file_summary_by_path(\repository_onedrive\rest $client, $fullpath) {
+ $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,createdByUser";
+ $response = $client->call('get_file_by_path', ['fullpath' => $fullpath, '$select' => $fields]);
+ if (empty($response->id)) {
+ $details = 'Cannot get file summary:' . $fullpath;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return $response;
+ }
+
+ /**
+ * Create a folder within a folder
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $foldername The folder we are creating.
+ * @param string $parentid The parent folder we are creating in.
+ *
+ * @return string The file id of the new folder.
+ */
+ protected function create_folder_in_folder(\repository_onedrive\rest $client, $foldername, $parentid) {
+ $params = ['parentid' => $parentid];
+ $folder = [ 'name' => $foldername, 'folder' => [ 'childCount' => 0 ]];
+ $created = $client->call('create_folder', $params, json_encode($folder));
+ if (empty($created->id)) {
+ $details = 'Cannot create folder:' . $foldername;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return $created->id;
+ }
+
+ /**
+ * Get simple file info for humans.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fileid The file we are querying.
+ *
+ * @return stdClass
+ */
+ protected function get_file_summary(\repository_onedrive\rest $client, $fileid) {
+ $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,createdByUser";
+ $response = $client->call('get', ['fileid' => $fileid, '$select' => $fields]);
+ return $response;
+ }
+
+ /**
+ * Get the id of this users root drive.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ *
+ * @return string id
+ */
+ protected function get_root_drive_id(\repository_onedrive\rest $client) {
+ $response = $client->call('get_drive', []);
+
+ if (empty($response->id)) {
+ $details = 'Cannot get driveid';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return $response->id;
+ }
+
+ /**
+ * Add a writer to the permissions on the file (temporary).
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @param string $email The email of the writer account to add.
+ * @return boolean
+ */
+ protected function add_temp_writer_to_file(\repository_onedrive\rest $client, $fileid, $email) {
+ // Expires in 7 days.
+ $expires = new DateTime();
+ $expires->add(new DateInterval("P7D"));
+
+ $updateeditor = [
+ 'recipients' => [[ 'email' => $email ]],
+ 'roles' => ['write'],
+ 'requireSignIn' => true,
+ 'sendInvitation' => false
+ ];
+ $params = ['fileid' => $fileid];
+ $response = $client->call('create_permission', $params, json_encode($updateeditor));
+ if (empty($response->value[0]->id)) {
+ $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ // Store the permission id in the DB. Scheduled task will remove this permission after 7 days.
+ if ($access = repository_onedrive\access::get_record(['permissionid' => $response->value[0]->id, 'itemid' => $fileid ])) {
+ // Update the timemodified.
+ $access->update();
+ } else {
+ $record = (object) [ 'permissionid' => $response->value[0]->id, 'itemid' => $fileid ];
+ $access = new repository_onedrive\access(0, $record);
+ $access->create();
+ }
+ return true;
+ }
+
+ /**
+ * Add a writer to the permissions on the file.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @param string $useremail The user email of the writer account to add.
+ * @return boolean
+ */
+ protected function add_writer_to_file(\repository_onedrive\rest $client, $fileid, $useremail) {
+ $updateeditor = [
+ 'recipients' => [ [ 'email' => $useremail ] ],
+ 'roles' => ['write'],
+ 'requireSignIn' => true,
+ 'sendInvitation' => false
+ ];
+ $params = [ 'fileid' => $fileid ];
+ $response = $client->call('create_permission', $params, json_encode($updateeditor));
+ if (empty($response->value)) {
+ $details = 'Cannot add user ' . $useremail . ' as a writer for document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Allow anyone with the link to read the file.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $fileid The file we are updating.
+ * @return boolean
+ */
+ protected function set_file_sharing_anyone_with_link_can_read(\repository_onedrive\rest $client, $fileid) {
+ $updateread = [
+ 'type' => 'view',
+ 'scope' => 'anonymous'
+ ];
+ $params = ['fileid' => $fileid];
+ $response = $client->call('create_link', $params, json_encode($updateread));
+ if (empty($response->link)) {
+ $details = 'Cannot update link sharing for the document: ' . $fileid;
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ return true;
+ }
+
+ /**
+ * Copy a shared file to a new folder.
+ *
+ * @param \repository_onedrive\rest $client Authenticated client.
+ * @param string $sharetoken The share we are querying.
+ * @param string $newdrive Id of the drive to copy to.
+ * @param string $parentid Id of the folder to copy to.
+ * @return stdClass
+ */
+ protected function copy_share(\repository_onedrive\rest $client, $sharetoken, $newdrive, $parentid) {
+ $folder = [
+ 'parentReference' => ['id' => $parentid, 'driveId' => $newdrive]
+ ];
+ $params = ['sharetoken' => $sharetoken];
+ $response = $client->call('copy_share', $params, json_encode($folder));
+ return true;
+ }
+
+ /**
+ * From MS docs - to get a share token from a url, do this:
+ * Reference: https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/shares_get
+ * To access a sharing URL using the shares API, the URL needs to be transformed into a sharing token.
+ * To transform a URL into a sharing token:
+ * Base64 encode the sharing URL.
+ * Convert the base64 encoded data to unpadded base64url format by:
+ * Trim trailing = characeters from the string.
+ * Replace unsafe URL characters with an equivelent character; replace / with _ and + with -.
+ * Append u! to the beginning of the string.
+ *
+ * @param string $shareurl
+ * @return string The sharing token
+ */
+ protected function get_share_token($shareurl) {
+ return 'u!' . str_replace(['/', '+'], ['_', '-'], rtrim(base64_encode($shareurl), '='));
+ }
+
+ /**
+ * Called when a file is selected as a "link".
+ * Invoked at MOODLE/repository/repository_ajax.php
+ *
+ * What should happen here is that the file should be copied to a new file owned by the moodle system user.
+ * It should be organised in a folder based on the file context.
+ * It's sharing permissions should allow read access with the link.
+ * The returned reference should point to the newly copied file - not the original.
+ *
+ * @param string $reference this reference is generated by
+ * repository::get_file_reference()
+ * @param context $context the target context for this new file.
+ * @param string $component the target component for this new file.
+ * @param string $filearea the target filearea for this new file.
+ * @param string $itemid the target itemid for this new file.
+ * @return string $modifiedreference (final one before saving to DB)
+ */
+ public function reference_file_selected($reference, $context, $component, $filearea, $itemid) {
+ // What we need to do here is transfer ownership to the system user (or copy)
+ // then set the permissions so anyone with the share link can view,
+ // finally update the reference to contain the share link if it was not
+ // already there (and point to new file id if we copied).
+
+ // Get a system and a user oauth client.
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ $details = 'Cannot connect as system user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $systemuserinfo = $systemauth->get_userinfo();
+ $systemuseremail = $systemuserinfo['email'];
+
+ $source = json_decode($reference);
+
+ $userauth = $this->get_user_oauth_client();
+ if ($userauth === false) {
+ $details = 'Cannot connect as current user';
+ throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
+ }
+ $userinfo = $userauth->get_userinfo();
+ $useremail = $userinfo['email'];
+
+ $userservice = new repository_onedrive\rest($userauth);
+ $systemservice = new repository_onedrive\rest($systemauth);
+
+ // Get the list of existing permissions so we can see if the owner is already the system account,
+ // and whether we need to update the link sharing options.
+ $permissions = $this->list_file_permissions($userservice, $source->id);
+
+ $readshareupdaterequired = true;
+ $ownerupdaterequired = true;
+ foreach ($permissions->value as $permission) {
+ if (!empty($permission->link)) {
+ if ($permission->link->scope == 'anonymous' &&
+ $permission->link->type == 'view') {
+ $shareurl = $permission->link->webUrl;
+ $readshareupdaterequired = false;
+ break;
+ }
+ }
+ }
+
+ // Add Moodle as writer.
+ $this->add_writer_to_file($userservice, $source->id, $systemuseremail);
+
+ // Now copy it to a sensible folder.
+ $contextlist = array_reverse($context->get_parent_contexts(true));
+
+ $cache = cache::make('repository_onedrive', 'folder');
+ $parentid = 'root';
+ $fullpath = '';
+ $allfolders = [];
+ foreach ($contextlist as $context) {
+ // Make sure a folder exists here.
+ $foldername = urlencode(clean_param($context->get_context_name(), PARAM_PATH));
+ $allfolders[] = $foldername;
+ }
+
+ $allfolders[] = urlencode(clean_param($component, PARAM_PATH));
+ $allfolders[] = urlencode(clean_param($filearea, PARAM_PATH));
+ $allfolders[] = urlencode(clean_param($itemid, PARAM_PATH));
+
+ // Variable $allfolders now has the complete path we want to store the file in.
+ // Create each folder in $allfolders under the system account.
+ foreach ($allfolders as $foldername) {
+ if ($fullpath) {
+ $fullpath .= '/';
+ }
+ $fullpath .= $foldername;
+
+ $folderid = $cache->get($fullpath);
+ if (empty($folderid)) {
+ $folderid = $this->get_file_id_by_path($systemservice, $fullpath);
+ }
+ if ($folderid !== false) {
+ $cache->set($fullpath, $folderid);
+ $parentid = $folderid;
+ } else {
+ // Create it.
+ $parentid = $this->create_folder_in_folder($systemservice, $foldername, $parentid);
+ $cache->set($fullpath, $parentid);
+ }
+ }
+
+ // Get the users drive id.
+ $newdrive = $this->get_root_drive_id($systemservice);
+
+ if ($readshareupdaterequired) {
+ $response = $this->set_file_sharing_anyone_with_link_can_read($userservice, $source->id);
+ $shareurl = $response->value->webUrl;
+ }
+
+ // Turn the share url into a sharing token.
+ $sharetoken = $this->get_share_token($shareurl);
+
+ // Delete any existing file at this path.
+ $path = $fullpath . '/' . $source->name;
+ $this->delete_file_by_path($systemservice, $path);
+
+ // Copy the file to the moodle account.
+ // Note this method (copying via a share link) is the only way to copy a file in
+ // office 365 from one user to another.
+ $this->copy_share($systemservice, $sharetoken, $newdrive, $parentid);
+
+ $summary = $this->get_file_summary_by_path($systemservice, $path);
+
+ // Update the details in the file reference before it is saved.
+ $source->id = $summary->id;
+ $source->link = $summary->webUrl;
+
+ $reference = json_encode($source);
+
+ return $reference;
+ }
+
+ /**
+ * Get human readable file info from the reference.
+ *
+ * @param string $reference
+ * @param int $filestatus
+ */
+ public function get_reference_details($reference, $filestatus = 0) {
+ if (empty($reference)) {
+ return get_string('unknownsource', 'repository');
+ }
+ $source = json_decode($reference);
+ $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
+
+ if ($systemauth === false) {
+ return '';
+ }
+ $systemservice = new repository_onedrive\rest($systemauth);
+ $info = $this->get_file_summary($systemservice, $source->id);
+
+ $owner = '';
+ if (!empty($info->createdByUser->displayName)) {
+ $owner = $info->createdByUser->displayName;
+ }
+ if ($owner) {
+ return get_string('owner', 'repository_onedrive', $owner);
+ } else {
+ return $info->name;
+ }
+ }
+
+ /**
+ * Return true if any instances of the skydrive repo exist - and we can import them.
+ *
+ * @return bool
+ */
+ public static function can_import_skydrive_files() {
+ global $DB;
+
+ $skydrive = $DB->get_record('repository', ['type' => 'skydrive'], 'id', IGNORE_MISSING);
+ $onedrive = $DB->get_record('repository', ['type' => 'onedrive'], 'id', IGNORE_MISSING);
+
+ if (empty($skydrive) || empty($onedrive)) {
+ return false;
+ }
+
+ $ready = true;
+ try {
+ $issuer = \core\oauth2\api::get_issuer(get_config('onedrive', 'issuerid'));
+ if (!$issuer->get('enabled')) {
+ $ready = false;
+ }
+ if (!$issuer->is_configured()) {
+ $ready = false;
+ }
+ } catch (dml_missing_record_exception $e) {
+ $ready = false;
+ }
+ if (!$ready) {
+ return false;
+ }
+
+ $sql = "SELECT count('x')
+ FROM {repository_instances} i, {repository} r
+ WHERE r.type=:plugin AND r.id=i.typeid";
+ $params = array('plugin' => 'skydrive');
+ return $DB->count_records_sql($sql, $params) > 0;
+ }
+
+ /**
+ * Import all the files that were created with the skydrive repo to this repo.
+ *
+ * @return bool
+ */
+ public static function import_skydrive_files() {
+ global $DB;
+
+ if (!self::can_import_skydrive_files()) {
+ return false;
+ }
+ // Should only be one of each.
+ $skydrivetype = repository::get_type_by_typename('skydrive');
+
+ $skydriveinstances = repository::get_instances(['type' => 'skydrive']);
+ $skydriveinstance = reset($skydriveinstances);
+ $onedriveinstances = repository::get_instances(['type' => 'onedrive']);
+ $onedriveinstance = reset($onedriveinstances);
+
+ // Update all file references.
+ $DB->set_field('files_reference', 'repositoryid', $onedriveinstance->id, ['repositoryid' => $skydriveinstance->id]);
+
+ // Delete and disable the skydrive repo.
+ $skydrivetype->delete();
+ core_plugin_manager::reset_caches();
+
+ $sql = "SELECT count('x')
+ FROM {repository_instances} i, {repository} r
+ WHERE r.type=:plugin AND r.id=i.typeid";
+ $params = array('plugin' => 'skydrive');
+ return $DB->count_records_sql($sql, $params) == 0;
+ }
+
+ /**
+ * Edit/Create Admin Settings Moodle form.
+ *
+ * @param moodleform $mform Moodle form (passed by reference).
+ * @param string $classname repository class name.
+ */
+ public static function type_config_form($mform, $classname = 'repository') {
+ global $OUTPUT;
+
+ $url = new moodle_url('/admin/tool/oauth2/issuers.php');
+ $url = $url->out();
+
+ $mform->addElement('static', null, '', get_string('oauth2serviceslink', 'repository_onedrive', $url));
+
+ if (self::can_import_skydrive_files()) {
+ $notice = get_string('skydrivefilesexist', 'repository_onedrive');
+ $url = new moodle_url('/repository/onedrive/importskydrive.php');
+ $attrs = ['class' => 'btn btn-primary'];
+ $button = $OUTPUT->action_link($url, get_string('importskydrivefiles', 'repository_onedrive'), null, $attrs);
+ $mform->addElement('static', null, '', $OUTPUT->notification($notice) . $button);
+ }
+
+ parent::type_config_form($mform);
+ $options = [];
+ $issuers = \core\oauth2\api::get_all_issuers();
+
+ foreach ($issuers as $issuer) {
+ $options[$issuer->get('id')] = s($issuer->get('name'));
+ }
+
+ $strrequired = get_string('required');
+
+ $mform->addElement('select', 'issuerid', get_string('issuer', 'repository_onedrive'), $options);
+ $mform->addHelpButton('issuerid', 'issuer', 'repository_onedrive');
+ $mform->addRule('issuerid', $strrequired, 'required', null, 'client');
+
+ $mform->addElement('static', null, '', get_string('fileoptions', 'repository_onedrive'));
+ $choices = [
+ 'internal' => get_string('internal', 'repository_onedrive'),
+ 'external' => get_string('external', 'repository_onedrive'),
+ 'both' => get_string('both', 'repository_onedrive')
+ ];
+ $mform->addElement('select', 'supportedreturntypes', get_string('supportedreturntypes', 'repository_onedrive'), $choices);
+
+ $choices = [
+ FILE_INTERNAL => get_string('internal', 'repository_onedrive'),
+ FILE_CONTROLLED_LINK => get_string('external', 'repository_onedrive'),
+ ];
+ $mform->addElement('select', 'defaultreturntype', get_string('defaultreturntype', 'repository_onedrive'), $choices);
+
+ }
+}
+
+/**
+ * Callback to get the required scopes for system account.
+ *
+ * @param \core\oauth2\issuer $issuer
+ * @return string
+ */
+function repository_onedrive_oauth2_system_scopes(\core\oauth2\issuer $issuer) {
+ if ($issuer->get('id') == get_config('onedrive', 'issuerid')) {
+ return repository_onedrive::SCOPES;
+ }
+ return '';
+}
diff --git a/repository/onedrive/pix/icon.png b/repository/onedrive/pix/icon.png
new file mode 100644
index 00000000000..186d49c06db
Binary files /dev/null and b/repository/onedrive/pix/icon.png differ
diff --git a/repository/onedrive/version.php b/repository/onedrive/version.php
new file mode 100644
index 00000000000..93f8c247a07
--- /dev/null
+++ b/repository/onedrive/version.php
@@ -0,0 +1,30 @@
+.
+
+/**
+ * Version details for onedrive repository
+ *
+ * @package repository_onedrive
+ * @copyright 2012 Lancaster University Network Services Ltd
+ * @author Dan Poltawski
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version = 2017032900; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires = 2016112900; // Requires this Moodle version.
+$plugin->component = 'repository_onedrive'; // Full name of the plugin (used for diagnostics).
diff --git a/repository/repository_ajax.php b/repository/repository_ajax.php
index 631d4d1a0d0..726486a8e43 100644
--- a/repository/repository_ajax.php
+++ b/repository/repository_ajax.php
@@ -51,6 +51,7 @@ $saveas_path = optional_param('savepath', '/', PARAM_PATH); // save as file
$search_text = optional_param('s', '', PARAM_CLEANHTML);
$linkexternal = optional_param('linkexternal', '', PARAM_ALPHA);
$usefilereference = optional_param('usefilereference', false, PARAM_BOOL);
+$usecontrolledlink = optional_param('usecontrolledlink', false, PARAM_BOOL);
list($context, $course, $cm) = get_context_info_array($contextid);
require_login($course, false, $cm, false, true);
@@ -220,12 +221,13 @@ switch ($action) {
}
}
- if ($usefilereference) {
+ if ($usefilereference || $usecontrolledlink) {
if ($repo->has_moodle_files()) {
$sourcefile = repository::get_moodle_file($reference);
$record->contenthash = $sourcefile->get_contenthash();
$record->filesize = $sourcefile->get_filesize();
}
+
// Check if file exists.
if (repository::draftfile_exists($itemid, $saveas_path, $saveas_filename)) {
// File name being used, rename it.
@@ -276,6 +278,10 @@ switch ($action) {
} else {
// Download file to moodle.
$downloadedfile = $repo->get_file($reference, $saveas_filename);
+
+ if (!empty($downloadedfile['newfilename'])) {
+ $record->filename = $downloadedfile['newfilename'];
+ }
if (empty($downloadedfile['path'])) {
$err->error = get_string('cannotdownload', 'repository');
die(json_encode($err));
diff --git a/repository/skydrive/lang/en/repository_skydrive.php b/repository/skydrive/lang/en/repository_skydrive.php
index 1ed71f48fc5..ea63162fb0c 100644
--- a/repository/skydrive/lang/en/repository_skydrive.php
+++ b/repository/skydrive/lang/en/repository_skydrive.php
@@ -24,8 +24,9 @@
*/
$string['cachedef_foldername'] = 'Folder name cache';
$string['clientid'] = 'Client ID';
-$string['configplugin'] = 'Configure Microsoft OneDrive';
+$string['configplugin'] = 'Configure Microsoft SkyDrive';
$string['oauthinfo'] = 'To use this plugin, you must register your site with Microsoft.
As part of the registration process, you will need to enter the following URL as \'Redirect domain\':
{$a->callbackurl}
Once registered, you will be provided with a client ID and secret which can be entered here.';
-$string['pluginname'] = 'Microsoft OneDrive';
+$string['pluginname'] = 'Microsoft SkyDrive';
$string['secret'] = 'Secret';
-$string['skydrive:view'] = 'View OneDrive';
+$string['skydrive:view'] = 'View SkyDrive';
+$string['deprecatedwarning'] = 'Warning: The API used by this repository plugin has been deprecated by Microsoft and it will stop working eventually. Please migrate to the newer "Microsoft OneDrive" repository.';
diff --git a/repository/skydrive/lib.php b/repository/skydrive/lib.php
index 69c6316454d..0343f97eb56 100644
--- a/repository/skydrive/lib.php
+++ b/repository/skydrive/lib.php
@@ -157,10 +157,14 @@ class repository_skydrive extends repository {
* @param string $classname repository class name
*/
public static function type_config_form($mform, $classname = 'repository') {
+ global $OUTPUT;
+
$a = new stdClass;
$a->callbackurl = microsoft_skydrive::callback_url()->out(false);
$mform->addElement('static', null, '', get_string('oauthinfo', 'repository_skydrive', $a));
+ $mform->addElement('static', null, '', $OUTPUT->notification(get_string('deprecatedwarning', 'repository_skydrive', $a)));
+
parent::type_config_form($mform);
$strrequired = get_string('required');
$mform->addElement('text', 'clientid', get_string('clientid', 'repository_skydrive'));
diff --git a/repository/upgrade.txt b/repository/upgrade.txt
index 27814a84e54..2fccc91777e 100644
--- a/repository/upgrade.txt
+++ b/repository/upgrade.txt
@@ -3,6 +3,9 @@ information provided here is intended especially for developers. Full
details of the repository API are available on Moodle docs:
http://docs.moodle.org/dev/Repository_API
+=== 3.3 ===
+The skydrive repository is deprecated - please migrate to the newer onedrive repository.
+
=== 3.2 ===
* The method repository::uses_post_requests() has been deprecated and must not be used anymore.
diff --git a/theme/boost/templates/core/filemanager_modal_generallayout.mustache b/theme/boost/templates/core/filemanager_modal_generallayout.mustache
index e3b41e22026..f55f2d03b66 100644
--- a/theme/boost/templates/core/filemanager_modal_generallayout.mustache
+++ b/theme/boost/templates/core/filemanager_modal_generallayout.mustache
@@ -27,7 +27,7 @@
diff --git a/theme/boost/templates/core/filemanager_selectlayout.mustache b/theme/boost/templates/core/filemanager_selectlayout.mustache
index 03e89722c37..110a5e816b4 100644
--- a/theme/boost/templates/core/filemanager_selectlayout.mustache
+++ b/theme/boost/templates/core/filemanager_selectlayout.mustache
@@ -24,6 +24,12 @@
{{#str}}makefilereference, repository{{/str}}