MDL-59505 oauth2: Fix storage of access controlled links
[moodle.git] / repository / onedrive / lib.php
CommitLineData
74462841
DP
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/>.
16
17/**
18 * Microsoft Live Skydrive Repository Plugin
19 *
e518ea79 20 * @package repository_onedrive
74462841
DP
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 */
25
26defined('MOODLE_INTERNAL') || die();
27
74462841 28/**
e518ea79 29 * Microsoft onedrive repository plugin.
74462841 30 *
e518ea79 31 * @package repository_onedrive
74462841
DP
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 */
e518ea79 36class repository_onedrive extends repository {
ffda3e39
DW
37 /**
38 * OAuth 2 client
39 * @var \core\oauth2\client
40 */
41 private $client = null;
42
43 /**
44 * OAuth 2 Issuer
45 * @var \core\oauth2\issuer
46 */
47 private $issuer = null;
48
49 /**
50 * Additional scopes required for drive.
51 */
52 const SCOPES = 'files.readwrite.all';
74462841
DP
53
54 /**
ffda3e39 55 * Constructor.
74462841
DP
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.
ffda3e39
DW
60 * @param int $readonly indicate this repo is readonly or not.
61 * @return void
74462841 62 */
ffda3e39
DW
63 public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array(), $readonly = 0) {
64 parent::__construct($repositoryid, $context, $options, $readonly = 0);
65
5afb4f0e 66 try {
e518ea79 67 $this->issuer = \core\oauth2\api::get_issuer(get_config('onedrive', 'issuerid'));
5afb4f0e
DP
68 } catch (dml_missing_record_exception $e) {
69 $this->disabled = true;
70 }
33536fb2
DW
71
72 if ($this->issuer && !$this->issuer->get('enabled')) {
73 $this->disabled = true;
74 }
ffda3e39
DW
75 }
76
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 }
74462841 95
ffda3e39 96 $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES);
74462841 97
ffda3e39 98 return $this->client;
74462841
DP
99 }
100
101 /**
ffda3e39 102 * Checks whether the user is authenticate or not.
74462841 103 *
ffda3e39 104 * @return bool true when logged in.
74462841
DP
105 */
106 public function check_login() {
ffda3e39
DW
107 $client = $this->get_user_oauth_client();
108 return $client->is_logged_in();
74462841
DP
109 }
110
111 /**
ffda3e39 112 * Print or return the login form.
74462841 113 *
ffda3e39 114 * @return void|array for ajax.
74462841
DP
115 */
116 public function print_login() {
ffda3e39
DW
117 $client = $this->get_user_oauth_client();
118 $url = $client->get_login_url();
c4a8d8e4
DP
119
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 }
74462841
DP
128 }
129
1ba1412f
SL
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;
137
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);
142
143 $PAGE->set_pagelayout('embedded');
144 echo $OUTPUT->header();
145
146 $repositoryname = get_string('pluginname', 'repository_onedrive');
147
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);
153
154 echo $OUTPUT->footer();
155 }
156
74462841 157 /**
ffda3e39 158 * Build the breadcrumb from a path.
74462841 159 *
ffda3e39
DW
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 }
178
179 /**
180 * Generates a safe path to a node.
181 *
182 * Typically, a node will be id|Name of the node.
74462841 183 *
ffda3e39
DW
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 }
199
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 }
223
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.
74462841
DP
230 */
231 public function get_listing($path='', $page = '') {
ffda3e39 232 if (empty($path)) {
e518ea79 233 $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
ffda3e39
DW
234 }
235
33536fb2 236 if ($this->disabled) {
eca128bf
DW
237 // Empty list of files for disabled repository.
238 return ['dynload' => false, 'list' => [], 'nologin' => true];
239 }
240
ffda3e39
DW
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);
245
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';
252
253 // Append the active path for search.
e518ea79 254 $str = get_string('searchfor', 'repository_onedrive', $searchtext);
ffda3e39
DW
255 $path = $this->build_node_path('search', $str, $path);
256 }
257
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);
265
266 $ret = [];
74462841 267 $ret['dynload'] = true;
ffda3e39
DW
268 $ret['path'] = $this->build_breadcrumb($path);
269 $ret['list'] = $results;
270 $ret['manage'] = 'https://www.office.com/';
271 return $ret;
272 }
273
274 /**
ba3b0145 275 * Search throughout the OneDrive
ffda3e39
DW
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) {
e518ea79
DW
282 $path = $this->build_node_path('root', get_string('pluginname', 'repository_onedrive'));
283 $str = get_string('searchfor', 'repository_onedrive', $searchtext);
ffda3e39
DW
284 $path = $this->build_node_path('search', $str, $path);
285
286 // Query the Drive.
287 $parent = 'root';
288 $results = $this->query($searchtext, $path, 'root');
289
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 }
297
298 /**
ba3b0145 299 * Query OneDrive for files and folders using a search query.
ffda3e39
DW
300 *
301 * Documentation about the query format can be found here:
ba3b0145
DW
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
ffda3e39
DW
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 *
ba3b0145 308 * @param string $q search query as expected by the Graph API.
ffda3e39 309 * @param string $path parent path of the current files, will not be used for the query.
092304a3 310 * @param string $parent Parent id.
ffda3e39
DW
311 * @param int $page page.
312 * @return array of files and folders.
089810fb
JP
313 * @throws Exception
314 * @throws repository_exception
ffda3e39
DW
315 */
316 protected function query($q, $path = null, $parent = null, $page = 0) {
317 global $OUTPUT;
318
319 $files = [];
320 $folders = [];
321 $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,thumbnails";
322 $params = ['$select' => $fields, '$expand' => 'thumbnails', 'parent' => $parent];
323
324 try {
325 // Retrieving files and folders.
326 $client = $this->get_user_oauth_client();
e518ea79 327 $service = new repository_onedrive\rest($client);
ffda3e39
DW
328
329 if (!empty($q)) {
330 $params['search'] = urlencode($q);
331
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) {
e518ea79 340 throw new repository_exception('servicenotenabled', 'repository_onedrive');
c0a4efdc
DW
341 } else if (strpos($e->getMessage(), 'mysite not found') !== false) {
342 throw new repository_exception('mysitenotfound', 'repository_onedrive');
ffda3e39
DW
343 }
344 }
345
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),
089810fb 354 'thumbnail' => $OUTPUT->image_url(file_folder_icon(64))->out(false),
ffda3e39
DW
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 ]);
367
ffda3e39
DW
368 $thumb = '';
369 $thumbwidth = 0;
370 $thumbheight = 0;
371 $extendedinfoerr = false;
372
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 }
384
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 }
d4fd856b 395 }
ffda3e39
DW
396
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 ];
d4fd856b
DP
406 }
407 }
408
ffda3e39
DW
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 }
415
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();
74462841
DP
425 }
426
427 /**
ffda3e39 428 * Get a file.
74462841 429 *
ffda3e39 430 * @param string $reference reference of the file.
092304a3 431 * @param string $filename filename to save the file to.
ffda3e39 432 * @return string JSON encoded array of information about the file.
74462841 433 */
ffda3e39
DW
434 public function get_file($reference, $filename = '') {
435 global $CFG;
436
33536fb2 437 if ($this->disabled) {
eca128bf
DW
438 throw new repository_exception('cannotdownload', 'repository');
439 }
f942de3c
DW
440 $sourceinfo = json_decode($reference);
441
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 }
eca128bf 448
ffda3e39
DW
449 $base = 'https://graph.microsoft.com/v1.0/';
450
ffda3e39
DW
451 $sourceurl = new moodle_url($base . 'me/drive/items/' . $sourceinfo->id . '/content');
452 $source = $sourceurl->out(false);
453
454 // We use download_one and not the rest API because it has special timeouts etc.
74462841 455 $path = $this->prepare_file($filename);
ffda3e39
DW
456 $options = ['filepath' => $path, 'timeout' => 15, 'followlocation' => true, 'maxredirs' => 5];
457 $result = $client->download_one($source, null, $options);
458
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 }
468
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 }
482
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 '*';
74462841
DP
491 }
492
493 /**
ffda3e39 494 * Tells how the file can be picked from this repository.
74462841 495 *
ffda3e39
DW
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()) {
e518ea79 501 $setting = get_config('onedrive', 'supportedreturntypes');
ffda3e39
DW
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 }
513
514 /**
515 * Which return type should be selected by default.
516 *
517 * @return int
518 */
519 public function default_returntype() {
e518ea79
DW
520 $setting = get_config('onedrive', 'defaultreturntype');
521 $supported = get_config('onedrive', 'supportedreturntypes');
ffda3e39
DW
522 if (($setting == FILE_INTERNAL && $supported != 'external') || $supported == 'internal') {
523 return FILE_INTERNAL;
524 } else {
525 return FILE_CONTROLLED_LINK;
526 }
527 }
528
529 /**
530 * Return names of the general options.
531 * By default: no general option name.
532 *
533 * @return array
74462841
DP
534 */
535 public static function get_type_option_names() {
ffda3e39 536 return array('issuerid', 'pluginname', 'defaultreturntype', 'supportedreturntypes');
74462841
DP
537 }
538
539 /**
ffda3e39
DW
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 }
547
548 /**
549 * Repository method to serve the referenced file
550 *
551 * @see send_stored_file
74462841 552 *
ffda3e39
DW
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
74462841 558 */
ffda3e39 559 public function send_file($storedfile, $lifetime=null , $filter=0, $forcedownload=false, array $options = null) {
33536fb2 560 if ($this->disabled) {
eca128bf
DW
561 throw new repository_exception('cannotdownload', 'repository');
562 }
563
ffda3e39 564 $source = json_decode($storedfile->get_reference());
74462841 565
ffda3e39
DW
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());
574
f942de3c 575 if (empty($options['offline']) && !empty($info) && $info->is_writable() && !empty($source->usesystem)) {
ffda3e39
DW
576 // Add the current user as an OAuth writer.
577 $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
578
579 if ($systemauth === false) {
580 $details = 'Cannot connect as system user';
581 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
582 }
e518ea79 583 $systemservice = new repository_onedrive\rest($systemauth);
ffda3e39
DW
584
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());
1ba1412f
SL
594 $param = ($options['embed'] == true) ? false : $url;
595 $userauth = $this->get_user_oauth_client($param);
596
ffda3e39 597 if (!$userauth->is_logged_in()) {
1ba1412f
SL
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 }
ffda3e39
DW
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'];
612
613 $this->add_temp_writer_to_file($systemservice, $source->id, $useremail);
614 }
615
d5bb9f1f
DW
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) {
fcdd7730
JL
621 // Do not use redirect() here because is not compatible with webservice/pluginfile.php.
622 header('Location: ' . $source->link);
ffda3e39
DW
623 } else {
624 $details = 'File is missing source link';
625 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
626 }
74462841
DP
627 }
628
74462841 629 /**
ffda3e39 630 * See if a folder exists within a folder
74462841 631 *
e518ea79 632 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
633 * @param string $fullpath
634 * @return string|boolean The file id if it exists or false.
74462841 635 */
e518ea79 636 protected function get_file_id_by_path(\repository_onedrive\rest $client, $fullpath) {
ffda3e39
DW
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;
74462841
DP
644 }
645
646 /**
ffda3e39 647 * Delete a file by full path.
74462841 648 *
e518ea79 649 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
650 * @param string $fullpath
651 * @return boolean
74462841 652 */
e518ea79 653 protected function delete_file_by_path(\repository_onedrive\rest $client, $fullpath) {
ffda3e39
DW
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;
74462841
DP
660 }
661
ffda3e39
DW
662 /**
663 * Create a folder within a folder
664 *
e518ea79 665 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
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 */
e518ea79 671 protected function create_folder_in_folder(\repository_onedrive\rest $client, $foldername, $parentid) {
ffda3e39
DW
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 }
681
682 /**
683 * Get simple file info for humans.
684 *
e518ea79 685 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
686 * @param string $fileid The file we are querying.
687 *
688 * @return stdClass
689 */
e518ea79 690 protected function get_file_summary(\repository_onedrive\rest $client, $fileid) {
ffda3e39
DW
691 $fields = "folder,id,lastModifiedDateTime,name,size,webUrl,createdByUser";
692 $response = $client->call('get', ['fileid' => $fileid, '$select' => $fields]);
693 return $response;
694 }
695
ffda3e39
DW
696 /**
697 * Add a writer to the permissions on the file (temporary).
698 *
e518ea79 699 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
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 */
e518ea79 704 protected function add_temp_writer_to_file(\repository_onedrive\rest $client, $fileid, $email) {
ffda3e39
DW
705 // Expires in 7 days.
706 $expires = new DateTime();
707 $expires->add(new DateInterval("P7D"));
708
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.
e518ea79 722 if ($access = repository_onedrive\access::get_record(['permissionid' => $response->value[0]->id, 'itemid' => $fileid ])) {
ffda3e39
DW
723 // Update the timemodified.
724 $access->update();
725 } else {
726 $record = (object) [ 'permissionid' => $response->value[0]->id, 'itemid' => $fileid ];
e518ea79 727 $access = new repository_onedrive\access(0, $record);
ffda3e39
DW
728 $access->create();
729 }
730 return true;
731 }
732
ffda3e39
DW
733 /**
734 * Allow anyone with the link to read the file.
735 *
e518ea79 736 * @param \repository_onedrive\rest $client Authenticated client.
ffda3e39
DW
737 * @param string $fileid The file we are updating.
738 * @return boolean
739 */
e518ea79 740 protected function set_file_sharing_anyone_with_link_can_read(\repository_onedrive\rest $client, $fileid) {
1ba1412f
SL
741
742 $type = (isset($this->options['embed']) && $this->options['embed'] == true) ? 'embed' : 'view';
ffda3e39 743 $updateread = [
1ba1412f 744 'type' => $type,
ffda3e39
DW
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 }
13d21db3 753 return $response->link->webUrl;
ffda3e39
DW
754 }
755
756 /**
536ed579 757 * Given a filename, use the core_filetypes registered types to guess a mimetype.
ffda3e39 758 *
536ed579
DW
759 * If no mimetype is known, return 'application/unknown';
760 *
761 * @param string $filename
762 * @return string $mimetype
ffda3e39 763 */
536ed579
DW
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 }
771
772 if (isset($types[$fileextension])) {
773 $mimetype = $types[$fileextension]['type'];
774 }
775 return $mimetype;
ffda3e39
DW
776 }
777
778 /**
536ed579
DW
779 * Upload a file to onedrive.
780 *
781 * @param \repository_onedrive\rest $service Authenticated client.
eeaee38a
DW
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
536ed579
DW
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
ffda3e39 789 */
eeaee38a
DW
790 protected function upload_file(\repository_onedrive\rest $service, \curl $curl, \curl $authcurl,
791 $filepath, $mimetype, $parentid, $filename) {
536ed579
DW
792 // Start an upload session.
793 // Docs https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/item_createuploadsession link.
794
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 }
802
803 $options = ['file' => $filepath];
eeaee38a
DW
804
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 }
536ed579
DW
822 }
823
824 if (empty($response->id)) {
825 $details = 'File not created';
826 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
827 }
828
829 return $response->id;
ffda3e39
DW
830 }
831
536ed579 832
ffda3e39
DW
833 /**
834 * Called when a file is selected as a "link".
835 * Invoked at MOODLE/repository/repository_ajax.php
836 *
eb47ad4a
DW
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 *
ffda3e39
DW
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) {
e0a90324
DM
851 global $CFG, $SITE;
852
ffda3e39
DW
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).
6a7552fe
DW
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 }
eb47ad4a
DW
862
863 // Get a system and a user oauth client.
ffda3e39
DW
864 $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
865
866 if ($systemauth === false) {
867 $details = 'Cannot connect as system user';
868 throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details);
869 }
ffda3e39 870
ffda3e39
DW
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 }
ffda3e39 876
e518ea79 877 $systemservice = new repository_onedrive\rest($systemauth);
ffda3e39 878
536ed579
DW
879 // Download the file.
880 $tmpfilename = clean_param($source->id, PARAM_PATH);
881 $temppath = make_request_directory() . $tmpfilename;
ffda3e39 882
536ed579
DW
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);
887
8381b05d 888 $result = $userauth->download_one($sourceurl, null, $options);
536ed579
DW
889
890 if (!$result) {
891 throw new repository_exception('cannotdownload', 'repository');
892 }
ffda3e39
DW
893
894 // Now copy it to a sensible folder.
895 $contextlist = array_reverse($context->get_parent_contexts(true));
896
e518ea79 897 $cache = cache::make('repository_onedrive', 'folder');
ffda3e39
DW
898 $parentid = 'root';
899 $fullpath = '';
900 $allfolders = [];
901 foreach ($contextlist as $context) {
e0a90324
DM
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);
906
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 }
917
918 $foldername = urlencode(clean_param($foldername, PARAM_PATH));
ffda3e39
DW
919 $allfolders[] = $foldername;
920 }
921
922 $allfolders[] = urlencode(clean_param($component, PARAM_PATH));
923 $allfolders[] = urlencode(clean_param($filearea, PARAM_PATH));
924 $allfolders[] = urlencode(clean_param($itemid, PARAM_PATH));
925
eb47ad4a
DW
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.
ffda3e39
DW
928 foreach ($allfolders as $foldername) {
929 if ($fullpath) {
930 $fullpath .= '/';
931 }
932 $fullpath .= $foldername;
933
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 }
947
ffda3e39 948 // Delete any existing file at this path.
536ed579 949 $path = $fullpath . '/' . urlencode(clean_param($source->name, PARAM_PATH));
ffda3e39
DW
950 $this->delete_file_by_path($systemservice, $path);
951
536ed579
DW
952 // Upload the file.
953 $safefilename = clean_param($source->name, PARAM_PATH);
954 $mimetype = $this->get_mimetype_from_filename($safefilename);
68c6fd20
DW
955 // We cannot send authorization headers in the upload or personal microsoft accounts will fail (what a joke!).
956 $curl = new \curl();
eeaee38a 957 $fileid = $this->upload_file($systemservice, $curl, $systemauth, $temppath, $mimetype, $parentid, $safefilename);
536ed579
DW
958
959 // Read with link.
13d21db3 960 $link = $this->set_file_sharing_anyone_with_link_can_read($systemservice, $fileid);
ffda3e39 961
536ed579 962 $summary = $this->get_file_summary($systemservice, $fileid);
ffda3e39
DW
963
964 // Update the details in the file reference before it is saved.
965 $source->id = $summary->id;
13d21db3 966 $source->link = $link;
f942de3c 967 $source->usesystem = true;
ffda3e39
DW
968
969 $reference = json_encode($source);
970
971 return $reference;
972 }
973
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);
f942de3c
DW
985 if (empty($source->usesystem)) {
986 return '';
987 }
ffda3e39
DW
988 $systemauth = \core\oauth2\api::get_system_oauth_client($this->issuer);
989
990 if ($systemauth === false) {
991 return '';
992 }
e518ea79 993 $systemservice = new repository_onedrive\rest($systemauth);
ffda3e39
DW
994 $info = $this->get_file_summary($systemservice, $source->id);
995
996 $owner = '';
997 if (!empty($info->createdByUser->displayName)) {
998 $owner = $info->createdByUser->displayName;
999 }
1000 if ($owner) {
e518ea79 1001 return get_string('owner', 'repository_onedrive', $owner);
ffda3e39
DW
1002 } else {
1003 return $info->name;
1004 }
1005 }
1006
e7688f55
DW
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;
1014
1015 $skydrive = $DB->get_record('repository', ['type' => 'skydrive'], 'id', IGNORE_MISSING);
1016 $onedrive = $DB->get_record('repository', ['type' => 'onedrive'], 'id', IGNORE_MISSING);
1017
1018 if (empty($skydrive) || empty($onedrive)) {
1019 return false;
1020 }
1021
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 }
1037
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 }
1044
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;
1052
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');
1058
1059 $skydriveinstances = repository::get_instances(['type' => 'skydrive']);
1060 $skydriveinstance = reset($skydriveinstances);
1061 $onedriveinstances = repository::get_instances(['type' => 'onedrive']);
1062 $onedriveinstance = reset($onedriveinstances);
1063
1064 // Update all file references.
1065 $DB->set_field('files_reference', 'repositoryid', $onedriveinstance->id, ['repositoryid' => $skydriveinstance->id]);
1066
1067 // Delete and disable the skydrive repo.
1068 $skydrivetype->delete();
1069 core_plugin_manager::reset_caches();
1070
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 }
1077
ffda3e39
DW
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') {
e7688f55
DW
1085 global $OUTPUT;
1086
ffda3e39
DW
1087 $url = new moodle_url('/admin/tool/oauth2/issuers.php');
1088 $url = $url->out();
1089
e518ea79 1090 $mform->addElement('static', null, '', get_string('oauth2serviceslink', 'repository_onedrive', $url));
ffda3e39 1091
e7688f55
DW
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 }
1099
ffda3e39
DW
1100 parent::type_config_form($mform);
1101 $options = [];
1102 $issuers = \core\oauth2\api::get_all_issuers();
1103
1104 foreach ($issuers as $issuer) {
1105 $options[$issuer->get('id')] = s($issuer->get('name'));
1106 }
1107
1108 $strrequired = get_string('required');
1109
e518ea79
DW
1110 $mform->addElement('select', 'issuerid', get_string('issuer', 'repository_onedrive'), $options);
1111 $mform->addHelpButton('issuerid', 'issuer', 'repository_onedrive');
ffda3e39
DW
1112 $mform->addRule('issuerid', $strrequired, 'required', null, 'client');
1113
e518ea79 1114 $mform->addElement('static', null, '', get_string('fileoptions', 'repository_onedrive'));
ffda3e39 1115 $choices = [
e518ea79
DW
1116 'internal' => get_string('internal', 'repository_onedrive'),
1117 'external' => get_string('external', 'repository_onedrive'),
1118 'both' => get_string('both', 'repository_onedrive')
ffda3e39 1119 ];
e518ea79 1120 $mform->addElement('select', 'supportedreturntypes', get_string('supportedreturntypes', 'repository_onedrive'), $choices);
ffda3e39
DW
1121
1122 $choices = [
e518ea79
DW
1123 FILE_INTERNAL => get_string('internal', 'repository_onedrive'),
1124 FILE_CONTROLLED_LINK => get_string('external', 'repository_onedrive'),
ffda3e39 1125 ];
e518ea79 1126 $mform->addElement('select', 'defaultreturntype', get_string('defaultreturntype', 'repository_onedrive'), $choices);
e7688f55 1127
ffda3e39
DW
1128 }
1129}
1130
1131/**
1132 * Callback to get the required scopes for system account.
1133 *
13b449f4 1134 * @param \core\oauth2\issuer $issuer
ffda3e39
DW
1135 * @return string
1136 */
e518ea79
DW
1137function repository_onedrive_oauth2_system_scopes(\core\oauth2\issuer $issuer) {
1138 if ($issuer->get('id') == get_config('onedrive', 'issuerid')) {
1139 return repository_onedrive::SCOPES;
74462841 1140 }
ffda3e39 1141 return '';
74462841 1142}