MDL-59505 oauth2: Fix storage of access controlled links
[moodle.git] / repository / onedrive / lib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Microsoft Live Skydrive Repository Plugin
19  *
20  * @package    repository_onedrive
21  * @copyright  2012 Lancaster University Network Services Ltd
22  * @author     Dan Poltawski <dan.poltawski@luns.net.uk>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 defined('MOODLE_INTERNAL') || die();
28 /**
29  * Microsoft onedrive repository plugin.
30  *
31  * @package    repository_onedrive
32  * @copyright  2012 Lancaster University Network Services Ltd
33  * @author     Dan Poltawski <dan.poltawski@luns.net.uk>
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class repository_onedrive extends repository {
37     /**
38      * OAuth 2 client
39      * @var \core\oauth2\client
40      */
41     private $client = null;
43     /**
44      * OAuth 2 Issuer
45      * @var \core\oauth2\issuer
46      */
47     private $issuer = null;
49     /**
50      * Additional scopes required for drive.
51      */
52     const SCOPES = 'files.readwrite.all';
54     /**
55      * Constructor.
56      *
57      * @param int $repositoryid repository instance id.
58      * @param int|stdClass $context a context id or context object.
59      * @param array $options repository options.
60      * @param int $readonly indicate this repo is readonly or not.
61      * @return void
62      */
63     public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array(), $readonly = 0) {
64         parent::__construct($repositoryid, $context, $options, $readonly = 0);
66         try {
67             $this->issuer = \core\oauth2\api::get_issuer(get_config('onedrive', 'issuerid'));
68         } catch (dml_missing_record_exception $e) {
69             $this->disabled = true;
70         }
72         if ($this->issuer && !$this->issuer->get('enabled')) {
73             $this->disabled = true;
74         }
75     }
77     /**
78      * Get a cached user authenticated oauth client.
79      *
80      * @param moodle_url $overrideurl - Use this url instead of the repo callback.
81      * @return \core\oauth2\client
82      */
83     protected function get_user_oauth_client($overrideurl = false) {
84         if ($this->client) {
85             return $this->client;
86         }
87         if ($overrideurl) {
88             $returnurl = $overrideurl;
89         } else {
90             $returnurl = new moodle_url('/repository/repository_callback.php');
91             $returnurl->param('callback', 'yes');
92             $returnurl->param('repo_id', $this->id);
93             $returnurl->param('sesskey', sesskey());
94         }
96         $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
98         return $this->client;
99     }
101     /**
102      * Checks whether the user is authenticate or not.
103      *
104      * @return bool true when logged in.
105      */
106     public function check_login() {
107         $client = $this->get_user_oauth_client();
108         return $client->is_logged_in();
109     }
111     /**
112      * Print or return the login form.
113      *
114      * @return void|array for ajax.
115      */
116     public function print_login() {
117         $client = $this->get_user_oauth_client();
118         $url = $client->get_login_url();
120         if ($this->options['ajax']) {
121             $popup = new stdClass();
122             $popup->type = 'popup';
123             $popup->url = $url->out(false);
124             return array('login' => array($popup));
125         } else {
126             echo '<a target="_blank" href="'.$url->out(false).'">'.get_string('login', 'repository').'</a>';
127         }
128     }
130     /**
131      * Print the login in a popup.
132      *
133      * @param array|null $attr Custom attributes to be applied to popup div.
134      */
135     public function print_login_popup($attr = null) {
136         global $OUTPUT, $PAGE;
138         $client = $this->get_user_oauth_client(false);
139         $url = new moodle_url($client->get_login_url());
140         $state = $url->get_param('state') . '&reloadparent=true';
141         $url->param('state', $state);
143         $PAGE->set_pagelayout('embedded');
144         echo $OUTPUT->header();
146         $repositoryname = get_string('pluginname', 'repository_onedrive');
148         $button = new single_button($url, get_string('logintoaccount', 'repository', $repositoryname), 'post', true);
149         $button->add_action(new popup_action('click', $url, 'Login'));
150         $button->class = 'mdl-align';
151         $button = $OUTPUT->render($button);
152         echo html_writer::div($button, '', $attr);
154         echo $OUTPUT->footer();
155     }
157     /**
158      * Build the breadcrumb from a path.
159      *
160      * @param string $path to create a breadcrumb from.
161      * @return array containing name and path of each crumb.
162      */
163     protected function build_breadcrumb($path) {
164         $bread = explode('/', $path);
165         $crumbtrail = '';
166         foreach ($bread as $crumb) {
167             list($id, $name) = $this->explode_node_path($crumb);
168             $name = empty($name) ? $id : $name;
169             $breadcrumb[] = array(
170                 'name' => $name,
171                 'path' => $this->build_node_path($id, $name, $crumbtrail)
172             );
173             $tmp = end($breadcrumb);
174             $crumbtrail = $tmp['path'];
175         }
176         return $breadcrumb;
177     }
179     /**
180      * Generates a safe path to a node.
181      *
182      * Typically, a node will be id|Name of the node.
183      *
184      * @param string $id of the node.
185      * @param string $name of the node, will be URL encoded.
186      * @param string $root to append the node on, must be a result of this function.
187      * @return string path to the node.
188      */
189     protected function build_node_path($id, $name = '', $root = '') {
190         $path = $id;
191         if (!empty($name)) {
192             $path .= '|' . urlencode($name);
193         }
194         if (!empty($root)) {
195             $path = trim($root, '/') . '/' . $path;
196         }
197         return $path;
198     }
200     /**
201      * Returns information about a node in a path.
202      *
203      * @see self::build_node_path()
204      * @param string $node to extrat information from.
205      * @return array about the node.
206      */
207     protected function explode_node_path($node) {
208         if (strpos($node, '|') !== false) {
209             list($id, $name) = explode('|', $node, 2);
210             $name = urldecode($name);
211         } else {
212             $id = $node;
213             $name = '';
214         }
215         $id = urldecode($id);
216         return array(
217             0 => $id,
218             1 => $name,
219             'id' => $id,
220             'name' => $name
221         );
222     }
224     /**
225      * List the files and folders.
226      *
227      * @param  string $path path to browse.
228      * @param  string $page page to browse.
229      * @return array of result.
230      */
231     public function get_listing($path='', $page = '') {
232         if (empty($path)) {
233             $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
234         }
236         if ($this->disabled) {
237             // Empty list of files for disabled repository.
238             return ['dynload' => false, 'list' => [], 'nologin' => true];
239         }
241         // We analyse the path to extract what to browse.
242         $trail = explode('/', $path);
243         $uri = array_pop($trail);
244         list($id, $name) = $this->explode_node_path($uri);
246         // Handle the special keyword 'search', which we defined in self::search() so that
247         // we could set up a breadcrumb in the search results. In any other case ID would be
248         // 'root' which is a special keyword, or a parent (folder) ID.
249         if ($id === 'search') {
250             $q = $name;
251             $id = 'root';
253             // Append the active path for search.
254             $str = get_string('searchfor', 'repository_onedrive', $searchtext);
255             $path = $this->build_node_path('search', $str, $path);
256         }
258         // Query the Drive.
259         $parent = $id;
260         if ($parent != 'root') {
261             $parent = 'items/' . $parent;
262         }
263         $q = '';
264         $results = $this->query($q, $path, $parent);
266         $ret = [];
267         $ret['dynload'] = true;
268         $ret['path'] = $this->build_breadcrumb($path);
269         $ret['list'] = $results;
270         $ret['manage'] = 'https://www.office.com/';
271         return $ret;
272     }
274     /**
275      * Search throughout the OneDrive
276      *
277      * @param string $searchtext text to search for.
278      * @param int $page search page.
279      * @return array of results.
280      */
281     public function search($searchtext, $page = 0) {
282         $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
283         $str = get_string('searchfor', 'repository_onedrive', $searchtext);
284         $path = $this->build_node_path('search', $str, $path);
286         // Query the Drive.
287         $parent = 'root';
288         $results = $this->query($searchtext, $path, 'root');
290         $ret = [];
291         $ret['dynload'] = true;
292         $ret['path'] = $this->build_breadcrumb($path);
293         $ret['list'] = $results;
294         $ret['manage'] = 'https://www.office.com/';
295         return $ret;
296     }
298     /**
299      * Query OneDrive for files and folders using a search query.
300      *
301      * Documentation about the query format can be found here:
302      *   https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/driveitem
303      *   https://developer.microsoft.com/en-us/graph/docs/overview/query_parameters
304      *
305      * This returns a list of files and folders with their details as they should be
306      * formatted and returned by functions such as get_listing() or search().
307      *
308      * @param string $q search query as expected by the Graph API.
309      * @param string $path parent path of the current files, will not be used for the query.
310      * @param string $parent Parent id.
311      * @param int $page page.
312      * @return array of files and folders.
313      * @throws Exception
314      * @throws repository_exception
315      */
316     protected function query($q, $path = null, $parent = null, $page = 0) {
317         global $OUTPUT;
319         $files = [];
320         $folders = [];
321         $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,thumbnails";
322         $params = ['$select' => $fields, '$expand' => 'thumbnails', 'parent' => $parent];
324         try {
325             // Retrieving files and folders.
326             $client = $this->get_user_oauth_client();
327             $service = new repository_onedrive\rest($client);
329             if (!empty($q)) {
330                 $params['search'] = urlencode($q);
332                 // MS does not return thumbnails on a search.
333                 unset($params['$expand']);
334                 $response = $service->call('search', $params);
335             } else {
336                 $response = $service->call('list', $params);
337             }
338         } catch (Exception $e) {
339             if ($e->getCode() == 403 && strpos($e->getMessage(), 'Access Not Configured') !== false) {
340                 throw new repository_exception('servicenotenabled', 'repository_onedrive');
341             } else if (strpos($e->getMessage(), 'mysite not found') !== false) {
342                 throw new repository_exception('mysitenotfound', 'repository_onedrive');
343             }
344         }
346         $remotefiles = isset($response->value) ? $response->value : [];
347         foreach ($remotefiles as $remotefile) {
348             if (!empty($remotefile->folder)) {
349                 // This is a folder.
350                 $folders[$remotefile->id] = [
351                     'title' => $remotefile->name,
352                     'path' => $this->build_node_path($remotefile->id, $remotefile->name, $path),
353                     'date' => strtotime($remotefile->lastModifiedDateTime),
354                     'thumbnail' => $OUTPUT->image_url(file_folder_icon(64))->out(false),
355                     'thumbnail_height' => 64,
356                     'thumbnail_width' => 64,
357                     'children' => []
358                 ];
359             } else {
360                 // We can download all other file types.
361                 $title = $remotefile->name;
362                 $source = json_encode([
363                         'id' => $remotefile->id,
364                         'name' => $remotefile->name,
365                         'link' => $remotefile->webUrl
366                     ]);
368                 $thumb = '';
369                 $thumbwidth = 0;
370                 $thumbheight = 0;
371                 $extendedinfoerr = false;
373                 if (empty($remotefile->thumbnails)) {
374                     // Try and get it directly from the item.
375                     $params = ['fileid' => $remotefile->id, '$select' => $fields, '$expand' => 'thumbnails'];
376                     try {
377                         $response = $service->call('get', $params);
378                         $remotefile = $response;
379                     } catch (Exception $e) {
380                         // This is not a failure condition - we just could not get extended info about the file.
381                         $extendedinfoerr = true;
382                     }
383                 }
385                 if (!empty($remotefile->thumbnails)) {
386                     $thumbs = $remotefile->thumbnails;
387                     if (count($thumbs)) {
388                         $first = reset($thumbs);
389                         if (!empty($first->medium) && !empty($first->medium->url)) {
390                             $thumb = $first->medium->url;
391                             $thumbwidth = min($first->medium->width, 64);
392                             $thumbheight = min($first->medium->height, 64);
393                         }
394                     }
395                 }
397                 $files[$remotefile->id] = [
398                     'title' => $title,
399                     'source' => $source,
400                     'date' => strtotime($remotefile->lastModifiedDateTime),
401                     'size' => isset($remotefile->size) ? $remotefile->size : null,
402                     'thumbnail' => $thumb,
403                     'thumbnail_height' => $thumbwidth,
404                     'thumbnail_width' => $thumbheight,
405                 ];
406             }
407         }
409         // Filter and order the results.
410         $files = array_filter($files, [$this, 'filter']);
411         core_collator::ksort($files, core_collator::SORT_NATURAL);
412         core_collator::ksort($folders, core_collator::SORT_NATURAL);
413         return array_merge(array_values($folders), array_values($files));
414     }
416     /**
417      * Logout.
418      *
419      * @return string
420      */
421     public function logout() {
422         $client = $this->get_user_oauth_client();
423         $client->log_out();
424         return parent::logout();
425     }
427     /**
428      * Get a file.
429      *
430      * @param string $reference reference of the file.
431      * @param string $filename filename to save the file to.
432      * @return string JSON encoded array of information about the file.
433      */
434     public function get_file($reference, $filename = '') {
435         global $CFG;
437         if ($this->disabled) {
438             throw new repository_exception('cannotdownload', 'repository');
439         }
440         $sourceinfo = json_decode($reference);
442         $client = null;
443         if (!empty($sourceinfo->usesystem)) {
444             $client = \core\oauth2\api::get_system_oauth_client($this->issuer);
445         } else {
446             $client = $this->get_user_oauth_client();
447         }
449         $base = 'https://graph.microsoft.com/v1.0/';
451         $sourceurl = new moodle_url($base . 'me/drive/items/' . $sourceinfo->id . '/content');
452         $source = $sourceurl->out(false);
454         // We use download_one and not the rest API because it has special timeouts etc.
455         $path = $this->prepare_file($filename);
456         $options = ['filepath' => $path, 'timeout' => 15, 'followlocation' => true, 'maxredirs' => 5];
457         $result = $client->download_one($source, null, $options);
459         if ($result) {
460             @chmod($path, $CFG->filepermissions);
461             return array(
462                 'path' => $path,
463                 'url' => $reference
464             );
465         }
466         throw new repository_exception('cannotdownload', 'repository');
467     }
469     /**
470      * Prepare file reference information.
471      *
472      * We are using this method to clean up the source to make sure that it
473      * is a valid source.
474      *
475      * @param string $source of the file.
476      * @return string file reference.
477      */
478     public function get_file_reference($source) {
479         // We could do some magic upgrade code here.
480         return $source;
481     }
483     /**
484      * What kind of files will be in this repository?
485      *
486      * @return array return '*' means this repository support any files, otherwise
487      *               return mimetypes of files, it can be an array
488      */
489     public function supported_filetypes() {
490         return '*';
491     }
493     /**
494      * Tells how the file can be picked from this repository.
495      *
496      * @return int
497      */
498     public function supported_returntypes() {
499         // We can only support references if the system account is connected.
500         if (!empty($this->issuer) && $this->issuer->is_system_account_connected()) {
501             $setting = get_config('onedrive', 'supportedreturntypes');
502             if ($setting == 'internal') {
503                 return FILE_INTERNAL;
504             } else if ($setting == 'external') {
505                 return FILE_CONTROLLED_LINK;
506             } else {
507                 return FILE_CONTROLLED_LINK | FILE_INTERNAL;
508             }
509         } else {
510             return FILE_INTERNAL;
511         }
512     }
514     /**
515      * Which return type should be selected by default.
516      *
517      * @return int
518      */
519     public function default_returntype() {
520         $setting = get_config('onedrive', 'defaultreturntype');
521         $supported = get_config('onedrive', 'supportedreturntypes');
522         if (($setting == FILE_INTERNAL && $supported != 'external') || $supported == 'internal') {
523             return FILE_INTERNAL;
524         } else {
525             return FILE_CONTROLLED_LINK;
526         }
527     }
529     /**
530      * Return names of the general options.
531      * By default: no general option name.
532      *
533      * @return array
534      */
535     public static function get_type_option_names() {
536         return array('issuerid', 'pluginname', 'defaultreturntype', 'supportedreturntypes');
537     }
539     /**
540      * Store the access token.
541      */
542     public function callback() {
543         $client = $this->get_user_oauth_client();
544         // This will upgrade to an access token if we have an authorization code and save the access token in the session.
545         $client->is_logged_in();
546     }
548     /**
549      * Repository method to serve the referenced file
550      *
551      * @see send_stored_file
552      *
553      * @param stored_file $storedfile the file that contains the reference
554      * @param int $lifetime Number of seconds before the file should expire from caches (null means $CFG->filelifetime)
555      * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
556      * @param bool $forcedownload If true (default false), forces download of file rather than view in browser/plugin
557      * @param array $options additional options affecting the file serving
558      */
559     public function send_file($storedfile, $lifetime=null , $filter=0, $forcedownload=false, array $options = null) {
560         if ($this->disabled) {
561             throw new repository_exception('cannotdownload', 'repository');
562         }
564         $source = json_decode($storedfile->get_reference());
566         $fb = get_file_browser();
567         $context = context::instance_by_id($storedfile->get_contextid(), MUST_EXIST);
568         $info = $fb->get_file_info($context,
569                                    $storedfile->get_component(),
570                                    $storedfile->get_filearea(),
571                                    $storedfile->get_itemid(),
572                                    $storedfile->get_filepath(),
573                                    $storedfile->get_filename());
575         if (empty($options['offline']) && !empty($info) && $info->is_writable() && !empty($source->usesystem)) {
576             // Add the current user as an OAuth writer.
577             $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
579             if ($systemauth === false) {
580                 $details = 'Cannot connect as system user';
581                 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
582             }
583             $systemservice = new repository_onedrive\rest($systemauth);
585             // Get the user oauth so we can get the account to add.
586             $url = moodle_url::make_pluginfile_url($storedfile->get_contextid(),
587                                                    $storedfile->get_component(),
588                                                    $storedfile->get_filearea(),
589                                                    $storedfile->get_itemid(),
590                                                    $storedfile->get_filepath(),
591                                                    $storedfile->get_filename(),
592                                                    $forcedownload);
593             $url->param('sesskey', sesskey());
594             $param = ($options['embed'] == true) ? false : $url;
595             $userauth = $this->get_user_oauth_client($param);
597             if (!$userauth->is_logged_in()) {
598                 if ($options['embed'] == true) {
599                     // Due to Same-origin policy, we cannot redirect to onedrive login page.
600                     // If the requested file is embed and the user is not logged in, add option to log in using a popup.
601                     $this->print_login_popup(['style' => 'margin-top: 250px']);
602                     exit;
603                 }
604                 redirect($userauth->get_login_url());
605             }
606             if ($userauth === false) {
607                 $details = 'Cannot connect as current user';
608                 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
609             }
610             $userinfo = $userauth->get_userinfo();
611             $useremail = $userinfo['email'];
613             $this->add_temp_writer_to_file($systemservice, $source->id, $useremail);
614         }
616         if (!empty($options['offline'])) {
617             $downloaded = $this->get_file($storedfile->get_reference(), $storedfile->get_filename());
618             $filename = $storedfile->get_filename();
619             send_file($downloaded['path'], $filename, $lifetime, $filter, false, $forcedownload, '', false, $options);
620         } else if ($source->link) {
621             // Do not use redirect() here because is not compatible with webservice/pluginfile.php.
622             header('Location: ' . $source->link);
623         } else {
624             $details = 'File is missing source link';
625             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
626         }
627     }
629     /**
630      * See if a folder exists within a folder
631      *
632      * @param \repository_onedrive\rest $client Authenticated client.
633      * @param string $fullpath
634      * @return string|boolean The file id if it exists or false.
635      */
636     protected function get_file_id_by_path(\repository_onedrive\rest $client, $fullpath) {
637         $fields = "id";
638         try {
639             $response = $client->call('get_file_by_path', ['fullpath' => $fullpath, '$select' => $fields]);
640         } catch (\core\oauth2\rest_exception $re) {
641             return false;
642         }
643         return $response->id;
644     }
646     /**
647      * Delete a file by full path.
648      *
649      * @param \repository_onedrive\rest $client Authenticated client.
650      * @param string $fullpath
651      * @return boolean
652      */
653     protected function delete_file_by_path(\repository_onedrive\rest $client, $fullpath) {
654         try {
655             $response = $client->call('delete_file_by_path', ['fullpath' => $fullpath]);
656         } catch (\core\oauth2\rest_exception $re) {
657             return false;
658         }
659         return true;
660     }
662     /**
663      * Create a folder within a folder
664      *
665      * @param \repository_onedrive\rest $client Authenticated client.
666      * @param string $foldername The folder we are creating.
667      * @param string $parentid The parent folder we are creating in.
668      *
669      * @return string The file id of the new folder.
670      */
671     protected function create_folder_in_folder(\repository_onedrive\rest $client, $foldername, $parentid) {
672         $params = ['parentid' => $parentid];
673         $folder = [ 'name' => $foldername, 'folder' => [ 'childCount' => 0 ]];
674         $created = $client->call('create_folder', $params, json_encode($folder));
675         if (empty($created->id)) {
676             $details = 'Cannot create folder:' . $foldername;
677             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
678         }
679         return $created->id;
680     }
682     /**
683      * Get simple file info for humans.
684      *
685      * @param \repository_onedrive\rest $client Authenticated client.
686      * @param string $fileid The file we are querying.
687      *
688      * @return stdClass
689      */
690     protected function get_file_summary(\repository_onedrive\rest $client, $fileid) {
691         $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,createdByUser";
692         $response = $client->call('get', ['fileid' => $fileid, '$select' => $fields]);
693         return $response;
694     }
696     /**
697      * Add a writer to the permissions on the file (temporary).
698      *
699      * @param \repository_onedrive\rest $client Authenticated client.
700      * @param string $fileid The file we are updating.
701      * @param string $email The email of the writer account to add.
702      * @return boolean
703      */
704     protected function add_temp_writer_to_file(\repository_onedrive\rest $client, $fileid, $email) {
705         // Expires in 7 days.
706         $expires = new DateTime();
707         $expires->add(new DateInterval("P7D"));
709         $updateeditor = [
710             'recipients' => [[ 'email' => $email ]],
711             'roles' => ['write'],
712             'requireSignIn' => true,
713             'sendInvitation' => false
714         ];
715         $params = ['fileid' => $fileid];
716         $response = $client->call('create_permission', $params, json_encode($updateeditor));
717         if (empty($response->value[0]->id)) {
718             $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid;
719             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
720         }
721         // Store the permission id in the DB. Scheduled task will remove this permission after 7 days.
722         if ($access = repository_onedrive\access::get_record(['permissionid' => $response->value[0]->id, 'itemid' => $fileid ])) {
723             // Update the timemodified.
724             $access->update();
725         } else {
726             $record = (object) [ 'permissionid' => $response->value[0]->id, 'itemid' => $fileid ];
727             $access = new repository_onedrive\access(0, $record);
728             $access->create();
729         }
730         return true;
731     }
733     /**
734      * Allow anyone with the link to read the file.
735      *
736      * @param \repository_onedrive\rest $client Authenticated client.
737      * @param string $fileid The file we are updating.
738      * @return boolean
739      */
740     protected function set_file_sharing_anyone_with_link_can_read(\repository_onedrive\rest $client, $fileid) {
742         $type = (isset($this->options['embed']) && $this->options['embed'] == true) ? 'embed' : 'view';
743         $updateread = [
744             'type' => $type,
745             'scope' => 'anonymous'
746         ];
747         $params = ['fileid' => $fileid];
748         $response = $client->call('create_link', $params, json_encode($updateread));
749         if (empty($response->link)) {
750             $details = 'Cannot update link sharing for the document: ' . $fileid;
751             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
752         }
753         return $response->link->webUrl;
754     }
756     /**
757      * Given a filename, use the core_filetypes registered types to guess a mimetype.
758      *
759      * If no mimetype is known, return 'application/unknown';
760      *
761      * @param string $filename
762      * @return string $mimetype
763      */
764     protected function get_mimetype_from_filename($filename) {
765         $mimetype = 'application/unknown';
766         $types = core_filetypes::get_types();
767         $fileextension = '.bin';
768         if (strpos($filename, '.') !== false) {
769             $fileextension = substr($filename, strrpos($filename, '.') + 1);
770         }
772         if (isset($types[$fileextension])) {
773             $mimetype = $types[$fileextension]['type'];
774         }
775         return $mimetype;
776     }
778     /**
779      * Upload a file to onedrive.
780      *
781      * @param \repository_onedrive\rest $service Authenticated client.
782      * @param \curl $curl Curl client to perform the put operation (with no auth headers).
783      * @param \curl $authcurl Curl client that will send authentication headers
784      * @param string $filepath The local path to the file to upload
785      * @param string $mimetype The new mimetype
786      * @param string $parentid The folder to put it.
787      * @param string $filename The name of the new file
788      * @return string $fileid
789      */
790     protected function upload_file(\repository_onedrive\rest $service, \curl $curl, \curl $authcurl,
791                                    $filepath, $mimetype, $parentid, $filename) {
792         // Start an upload session.
793         // Docs https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/item_createuploadsession link.
795         $params = ['parentid' => $parentid, 'filename' => urlencode($filename)];
796         $behaviour = [ 'item' => [ "@microsoft.graph.conflictBehavior" => "rename" ] ];
797         $created = $service->call('create_upload', $params, json_encode($behaviour));
798         if (empty($created->uploadUrl)) {
799             $details = 'Cannot begin upload session:' . $parentid;
800             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
801         }
803         $options = ['file' => $filepath];
805         // Try each curl class in turn until we succeed.
806         // First attempt an upload with no auth headers (will work for personal onedrive accounts).
807         // If that fails, try an upload with the auth headers (will work for work onedrive accounts).
808         $curls = [$curl, $authcurl];
809         $response = null;
810         foreach ($curls as $curlinstance) {
811             $curlinstance->setHeader('Content-type: ' . $mimetype);
812             $size = filesize($filepath);
813             $curlinstance->setHeader('Content-Range: bytes 0-' . ($size - 1) . '/' . $size);
814             $response = $curlinstance->put($created->uploadUrl, $options);
815             if ($curlinstance->errno == 0) {
816                 $response = json_decode($response);
817             }
818             if (!empty($response->id)) {
819                 // We can stop now - there is a valid file returned.
820                 break;
821             }
822         }
824         if (empty($response->id)) {
825             $details = 'File not created';
826             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
827         }
829         return $response->id;
830     }
833     /**
834      * Called when a file is selected as a "link".
835      * Invoked at MOODLE/repository/repository_ajax.php
836      *
837      * What should happen here is that the file should be copied to a new file owned by the moodle system user.
838      * It should be organised in a folder based on the file context.
839      * It's sharing permissions should allow read access with the link.
840      * The returned reference should point to the newly copied file - not the original.
841      *
842      * @param string $reference this reference is generated by
843      *                          repository::get_file_reference()
844      * @param context $context the target context for this new file.
845      * @param string $component the target component for this new file.
846      * @param string $filearea the target filearea for this new file.
847      * @param string $itemid the target itemid for this new file.
848      * @return string $modifiedreference (final one before saving to DB)
849      */
850     public function reference_file_selected($reference, $context, $component, $filearea, $itemid) {
851         global $CFG, $SITE;
853         // What we need to do here is transfer ownership to the system user (or copy)
854         // then set the permissions so anyone with the share link can view,
855         // finally update the reference to contain the share link if it was not
856         // already there (and point to new file id if we copied).
857         $source = json_decode($reference);
858         if (!empty($source->usesystem)) {
859             // If we already copied this file to the system account - we are done.
860             return $reference;
861         }
863         // Get a system and a user oauth client.
864         $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
866         if ($systemauth === false) {
867             $details = 'Cannot connect as system user';
868             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
869         }
871         $userauth = $this->get_user_oauth_client();
872         if ($userauth === false) {
873             $details = 'Cannot connect as current user';
874             throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
875         }
877         $systemservice = new repository_onedrive\rest($systemauth);
879         // Download the file.
880         $tmpfilename = clean_param($source->id, PARAM_PATH);
881         $temppath = make_request_directory() . $tmpfilename;
883         $options = ['filepath' => $temppath, 'timeout' => 60, 'followlocation' => true, 'maxredirs' => 5];
884         $base = 'https://graph.microsoft.com/v1.0/';
885         $sourceurl = new moodle_url($base . 'me/drive/items/' . $source->id . '/content');
886         $sourceurl = $sourceurl->out(false);
888         $result = $userauth->download_one($sourceurl, null, $options);
890         if (!$result) {
891             throw new repository_exception('cannotdownload', 'repository');
892         }
894         // Now copy it to a sensible folder.
895         $contextlist = array_reverse($context->get_parent_contexts(true));
897         $cache = cache::make('repository_onedrive', 'folder');
898         $parentid = 'root';
899         $fullpath = '';
900         $allfolders = [];
901         foreach ($contextlist as $context) {
902             // Prepare human readable context folders names, making sure they are still unique within the site.
903             $prevlang = force_current_language($CFG->lang);
904             $foldername = $context->get_context_name();
905             force_current_language($prevlang);
907             if ($context->contextlevel == CONTEXT_SYSTEM) {
908                 // Append the site short name to the root folder.
909                 $foldername .= '_'.$SITE->shortname;
910                 // Append the relevant object id.
911             } else if ($context->instanceid) {
912                 $foldername .= '_id_'.$context->instanceid;
913             } else {
914                 // This does not really happen but just in case.
915                 $foldername .= '_ctx_'.$context->id;
916             }
918             $foldername = urlencode(clean_param($foldername, PARAM_PATH));
919             $allfolders[] = $foldername;
920         }
922         $allfolders[] = urlencode(clean_param($component, PARAM_PATH));
923         $allfolders[] = urlencode(clean_param($filearea, PARAM_PATH));
924         $allfolders[] = urlencode(clean_param($itemid, PARAM_PATH));
926         // Variable $allfolders now has the complete path we want to store the file in.
927         // Create each folder in $allfolders under the system account.
928         foreach ($allfolders as $foldername) {
929             if ($fullpath) {
930                 $fullpath .= '/';
931             }
932             $fullpath .= $foldername;
934             $folderid = $cache->get($fullpath);
935             if (empty($folderid)) {
936                 $folderid = $this->get_file_id_by_path($systemservice, $fullpath);
937             }
938             if ($folderid !== false) {
939                 $cache->set($fullpath, $folderid);
940                 $parentid = $folderid;
941             } else {
942                 // Create it.
943                 $parentid = $this->create_folder_in_folder($systemservice, $foldername, $parentid);
944                 $cache->set($fullpath, $parentid);
945             }
946         }
948         // Delete any existing file at this path.
949         $path = $fullpath . '/' . urlencode(clean_param($source->name, PARAM_PATH));
950         $this->delete_file_by_path($systemservice, $path);
952         // Upload the file.
953         $safefilename = clean_param($source->name, PARAM_PATH);
954         $mimetype = $this->get_mimetype_from_filename($safefilename);
955         // We cannot send authorization headers in the upload or personal microsoft accounts will fail (what a joke!).
956         $curl = new \curl();
957         $fileid = $this->upload_file($systemservice, $curl, $systemauth, $temppath, $mimetype, $parentid, $safefilename);
959         // Read with link.
960         $link = $this->set_file_sharing_anyone_with_link_can_read($systemservice, $fileid);
962         $summary = $this->get_file_summary($systemservice, $fileid);
964         // Update the details in the file reference before it is saved.
965         $source->id = $summary->id;
966         $source->link = $link;
967         $source->usesystem = true;
969         $reference = json_encode($source);
971         return $reference;
972     }
974     /**
975      * Get human readable file info from the reference.
976      *
977      * @param string $reference
978      * @param int $filestatus
979      */
980     public function get_reference_details($reference, $filestatus = 0) {
981         if (empty($reference)) {
982             return get_string('unknownsource', 'repository');
983         }
984         $source = json_decode($reference);
985         if (empty($source->usesystem)) {
986             return '';
987         }
988         $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
990         if ($systemauth === false) {
991             return '';
992         }
993         $systemservice = new repository_onedrive\rest($systemauth);
994         $info = $this->get_file_summary($systemservice, $source->id);
996         $owner = '';
997         if (!empty($info->createdByUser->displayName)) {
998             $owner = $info->createdByUser->displayName;
999         }
1000         if ($owner) {
1001             return get_string('owner', 'repository_onedrive', $owner);
1002         } else {
1003             return $info->name;
1004         }
1005     }
1007     /**
1008      * Return true if any instances of the skydrive repo exist - and we can import them.
1009      *
1010      * @return bool
1011      */
1012     public static function can_import_skydrive_files() {
1013         global $DB;
1015         $skydrive = $DB->get_record('repository', ['type' => 'skydrive'], 'id', IGNORE_MISSING);
1016         $onedrive = $DB->get_record('repository', ['type' => 'onedrive'], 'id', IGNORE_MISSING);
1018         if (empty($skydrive) || empty($onedrive)) {
1019             return false;
1020         }
1022         $ready = true;
1023         try {
1024             $issuer = \core\oauth2\api::get_issuer(get_config('onedrive', 'issuerid'));
1025             if (!$issuer->get('enabled')) {
1026                 $ready = false;
1027             }
1028             if (!$issuer->is_configured()) {
1029                 $ready = false;
1030             }
1031         } catch (dml_missing_record_exception $e) {
1032             $ready = false;
1033         }
1034         if (!$ready) {
1035             return false;
1036         }
1038         $sql = "SELECT count('x')
1039                   FROM {repository_instances} i, {repository} r
1040                  WHERE r.type=:plugin AND r.id=i.typeid";
1041         $params = array('plugin' => 'skydrive');
1042         return $DB->count_records_sql($sql, $params) > 0;
1043     }
1045     /**
1046      * Import all the files that were created with the skydrive repo to this repo.
1047      *
1048      * @return bool
1049      */
1050     public static function import_skydrive_files() {
1051         global $DB;
1053         if (!self::can_import_skydrive_files()) {
1054             return false;
1055         }
1056         // Should only be one of each.
1057         $skydrivetype = repository::get_type_by_typename('skydrive');
1059         $skydriveinstances = repository::get_instances(['type' => 'skydrive']);
1060         $skydriveinstance = reset($skydriveinstances);
1061         $onedriveinstances = repository::get_instances(['type' => 'onedrive']);
1062         $onedriveinstance = reset($onedriveinstances);
1064         // Update all file references.
1065         $DB->set_field('files_reference', 'repositoryid', $onedriveinstance->id, ['repositoryid' => $skydriveinstance->id]);
1067         // Delete and disable the skydrive repo.
1068         $skydrivetype->delete();
1069         core_plugin_manager::reset_caches();
1071         $sql = "SELECT count('x')
1072                   FROM {repository_instances} i, {repository} r
1073                  WHERE r.type=:plugin AND r.id=i.typeid";
1074         $params = array('plugin' => 'skydrive');
1075         return $DB->count_records_sql($sql, $params) == 0;
1076     }
1078     /**
1079      * Edit/Create Admin Settings Moodle form.
1080      *
1081      * @param moodleform $mform Moodle form (passed by reference).
1082      * @param string $classname repository class name.
1083      */
1084     public static function type_config_form($mform, $classname = 'repository') {
1085         global $OUTPUT;
1087         $url = new moodle_url('/admin/tool/oauth2/issuers.php');
1088         $url = $url->out();
1090         $mform->addElement('static', null, '', get_string('oauth2serviceslink', 'repository_onedrive', $url));
1092         if (self::can_import_skydrive_files()) {
1093             $notice = get_string('skydrivefilesexist', 'repository_onedrive');
1094             $url = new moodle_url('/repository/onedrive/importskydrive.php');
1095             $attrs = ['class' => 'btn btn-primary'];
1096             $button = $OUTPUT->action_link($url, get_string('importskydrivefiles', 'repository_onedrive'), null, $attrs);
1097             $mform->addElement('static', null, '', $OUTPUT->notification($notice) . $button);
1098         }
1100         parent::type_config_form($mform);
1101         $options = [];
1102         $issuers = \core\oauth2\api::get_all_issuers();
1104         foreach ($issuers as $issuer) {
1105             $options[$issuer->get('id')] = s($issuer->get('name'));
1106         }
1108         $strrequired = get_string('required');
1110         $mform->addElement('select', 'issuerid', get_string('issuer', 'repository_onedrive'), $options);
1111         $mform->addHelpButton('issuerid', 'issuer', 'repository_onedrive');
1112         $mform->addRule('issuerid', $strrequired, 'required', null, 'client');
1114         $mform->addElement('static', null, '', get_string('fileoptions', 'repository_onedrive'));
1115         $choices = [
1116             'internal' => get_string('internal', 'repository_onedrive'),
1117             'external' => get_string('external', 'repository_onedrive'),
1118             'both' => get_string('both', 'repository_onedrive')
1119         ];
1120         $mform->addElement('select', 'supportedreturntypes', get_string('supportedreturntypes', 'repository_onedrive'), $choices);
1122         $choices = [
1123             FILE_INTERNAL => get_string('internal', 'repository_onedrive'),
1124             FILE_CONTROLLED_LINK => get_string('external', 'repository_onedrive'),
1125         ];
1126         $mform->addElement('select', 'defaultreturntype', get_string('defaultreturntype', 'repository_onedrive'), $choices);
1128     }
1131 /**
1132  * Callback to get the required scopes for system account.
1133  *
1134  * @param \core\oauth2\issuer $issuer
1135  * @return string
1136  */
1137 function repository_onedrive_oauth2_system_scopes(\core\oauth2\issuer $issuer) {
1138     if ($issuer->get('id') == get_config('onedrive', 'issuerid')) {
1139         return repository_onedrive::SCOPES;
1140     }
1141     return '';