MDL-65959 core_badges: Allow ability to upload badges cross domain.
[moodle.git] / badges / classes / backpack_api.php
CommitLineData
aae219ac
DW
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 * Communicate with backpacks.
19 *
20 * @copyright 2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22 * @author Yuliya Bozhko <yuliya.bozhko@totaralms.com>
23 */
24
25namespace core_badges;
26
27defined('MOODLE_INTERNAL') || die();
28
29require_once($CFG->libdir . '/filelib.php');
30
31use cache;
32use coding_exception;
33use core_badges\external\assertion_exporter;
34use core_badges\external\collection_exporter;
35use core_badges\external\issuer_exporter;
36use core_badges\external\badgeclass_exporter;
37use curl;
38use stdClass;
58c7d2ce 39use context_system;
aae219ac
DW
40
41define('BADGE_ACCESS_TOKEN', 'access');
42define('BADGE_USER_ID_TOKEN', 'user_id');
43define('BADGE_BACKPACK_ID_TOKEN', 'backpack_id');
44define('BADGE_REFRESH_TOKEN', 'refresh');
45define('BADGE_EXPIRES_TOKEN', 'expires');
46
47/**
48 * Class for communicating with backpacks.
1837b1d5
DW
49 *
50 * @package core_badges
51 * @copyright 2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
52 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
aae219ac
DW
53 */
54class backpack_api {
55
56 /** @var string The email address of the issuer or the backpack owner. */
57 private $email;
58
59 /** @var string The base url used for api requests to this backpack. */
60 private $backpackapiurl;
61
62 /** @var integer The backpack api version to use. */
63 private $backpackapiversion;
64
65 /** @var string The password to authenticate requests. */
66 private $password;
67
68 /** @var boolean User or site api requests. */
69 private $isuserbackpack;
70
71 /** @var integer The id of the backpack we are talking to. */
72 private $backpackid;
73
74 /** @var \backpack_api_mapping[] List of apis for the user or site using api version 1 or 2. */
75 private $mappings = [];
76
77 /**
78 * Create a wrapper to communicate with the backpack.
79 *
80 * The resulting class can only do either site backpack communication or
81 * user backpack communication.
82 *
83 * @param stdClass $sitebackpack The site backpack record
84 * @param mixed $userbackpack Optional - if passed it represents the users backpack.
85 */
86 public function __construct($sitebackpack, $userbackpack = false) {
87 global $CFG;
88 $admin = get_admin();
89
aae219ac
DW
90 $this->backpackapiurl = $sitebackpack->backpackapiurl;
91 $this->backpackapiversion = $sitebackpack->apiversion;
92 $this->password = $sitebackpack->password;
084a42f9 93 $this->email = !empty($CFG->badges_defaultissuercontact) ? $CFG->badges_defaultissuercontact : '';
aae219ac
DW
94 $this->isuserbackpack = false;
95 $this->backpackid = $sitebackpack->id;
96 if (!empty($userbackpack)) {
7444ba74
DW
97 if ($userbackpack->externalbackpackid != $sitebackpack->id) {
98 throw new coding_exception('Incorrect backpack');
99 }
aae219ac 100 $this->isuserbackpack = true;
aae219ac
DW
101 $this->password = $userbackpack->password;
102 $this->email = $userbackpack->email;
103 }
104
105 $this->define_mappings();
7444ba74
DW
106 // Clear the last authentication error.
107 backpack_api_mapping::set_authentication_error('');
aae219ac
DW
108 }
109
110 /**
111 * Define the mappings supported by this usage and api version.
112 */
113 private function define_mappings() {
114 if ($this->backpackapiversion == OPEN_BADGES_V2) {
115 if ($this->isuserbackpack) {
116 $mapping = [];
117 $mapping[] = [
118 'collections', // Action.
119 '[URL]/backpack/collections', // URL
120 [], // Post params.
121 '', // Request exporter.
122 'core_badges\external\collection_exporter', // Response exporter.
123 true, // Multiple.
124 'get', // Method.
125 true, // JSON Encoded.
126 true // Auth required.
127 ];
128 $mapping[] = [
129 'user', // Action.
130 '[SCHEME]://[HOST]/o/token', // URL
131 ['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
132 '', // Request exporter.
133 'oauth_token_response', // Response exporter.
134 false, // Multiple.
135 'post', // Method.
136 false, // JSON Encoded.
137 false, // Auth required.
138 ];
aae219ac
DW
139 $mapping[] = [
140 'assertion', // Action.
58c7d2ce
DW
141 // Badgr.io does not return the public information about a badge
142 // if the issuer is associated with another user. We need to pass
143 // the expand parameters which are not in any specification to get
144 // additional information about the assertion in a single request.
145 '[URL]/backpack/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
aae219ac
DW
146 [], // Post params.
147 '', // Request exporter.
148 'core_badges\external\assertion_exporter', // Response exporter.
149 false, // Multiple.
150 'get', // Method.
151 true, // JSON Encoded.
152 true // Auth required.
153 ];
b6435e09
P
154 $mapping[] = [
155 'importbadge', // Action.
156 // Badgr.io does not return the public information about a badge
157 // if the issuer is associated with another user. We need to pass
158 // the expand parameters which are not in any specification to get
159 // additional information about the assertion in a single request.
160 '[URL]/backpack/import',
161 ['url' => '[PARAM]'], // Post params.
162 '', // Request exporter.
163 'core_badges\external\assertion_exporter', // Response exporter.
164 false, // Multiple.
165 'post', // Method.
166 true, // JSON Encoded.
167 true // Auth required.
168 ];
aae219ac
DW
169 $mapping[] = [
170 'badges', // Action.
171 '[URL]/backpack/collections/[PARAM1]', // URL
172 [], // Post params.
173 '', // Request exporter.
174 'core_badges\external\collection_exporter', // Response exporter.
175 true, // Multiple.
176 'get', // Method.
177 true, // JSON Encoded.
178 true // Auth required.
179 ];
180 foreach ($mapping as $map) {
181 $map[] = true; // User api function.
182 $map[] = OPEN_BADGES_V2; // V2 function.
183 $this->mappings[] = new backpack_api_mapping(...$map);
184 }
185 } else {
186 $mapping = [];
187 $mapping[] = [
188 'user', // Action.
189 '[SCHEME]://[HOST]/o/token', // URL
190 ['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
191 '', // Request exporter.
192 'oauth_token_response', // Response exporter.
193 false, // Multiple.
194 'post', // Method.
195 false, // JSON Encoded.
196 false // Auth required.
197 ];
198 $mapping[] = [
199 'issuers', // Action.
200 '[URL]/issuers', // URL
201 '[PARAM]', // Post params.
202 'core_badges\external\issuer_exporter', // Request exporter.
203 'core_badges\external\issuer_exporter', // Response exporter.
204 false, // Multiple.
205 'post', // Method.
206 true, // JSON Encoded.
207 true // Auth required.
208 ];
209 $mapping[] = [
210 'badgeclasses', // Action.
211 '[URL]/issuers/[PARAM2]/badgeclasses', // URL
212 '[PARAM]', // Post params.
213 'core_badges\external\badgeclass_exporter', // Request exporter.
214 'core_badges\external\badgeclass_exporter', // Response exporter.
215 false, // Multiple.
216 'post', // Method.
217 true, // JSON Encoded.
218 true // Auth required.
219 ];
220 $mapping[] = [
221 'assertions', // Action.
58c7d2ce 222 '[URL]/badgeclasses/[PARAM2]/assertions', // URL
aae219ac
DW
223 '[PARAM]', // Post params.
224 'core_badges\external\assertion_exporter', // Request exporter.
225 'core_badges\external\assertion_exporter', // Response exporter.
226 false, // Multiple.
227 'post', // Method.
228 true, // JSON Encoded.
229 true // Auth required.
230 ];
231 foreach ($mapping as $map) {
232 $map[] = false; // Site api function.
233 $map[] = OPEN_BADGES_V2; // V2 function.
234 $this->mappings[] = new backpack_api_mapping(...$map);
235 }
236 }
237 } else {
238 if ($this->isuserbackpack) {
239 $mapping = [];
240 $mapping[] = [
241 'user', // Action.
242 '[URL]/displayer/convert/email', // URL
243 ['email' => '[EMAIL]'], // Post params.
244 '', // Request exporter.
245 'convert_email_response', // Response exporter.
246 false, // Multiple.
247 'post', // Method.
248 false, // JSON Encoded.
249 false // Auth required.
250 ];
251 $mapping[] = [
252 'groups', // Action.
253 '[URL]/displayer/[PARAM1]/groups.json', // URL
254 [], // Post params.
255 '', // Request exporter.
256 '', // Response exporter.
257 false, // Multiple.
258 'get', // Method.
259 true, // JSON Encoded.
260 true // Auth required.
261 ];
262 $mapping[] = [
263 'badges', // Action.
264 '[URL]/displayer/[PARAM2]/group/[PARAM1].json', // URL
265 [], // Post params.
266 '', // Request exporter.
267 '', // Response exporter.
268 false, // Multiple.
269 'get', // Method.
270 true, // JSON Encoded.
271 true // Auth required.
272 ];
273 foreach ($mapping as $map) {
274 $map[] = true; // User api function.
275 $map[] = OPEN_BADGES_V1; // V1 function.
276 $this->mappings[] = new backpack_api_mapping(...$map);
277 }
278 } else {
279 $mapping = [];
280 $mapping[] = [
281 'user', // Action.
282 '[URL]/displayer/convert/email', // URL
283 ['email' => '[EMAIL]'], // Post params.
284 '', // Request exporter.
285 'convert_email_response', // Response exporter.
286 false, // Multiple.
287 'post', // Method.
288 false, // JSON Encoded.
289 false // Auth required.
290 ];
291 foreach ($mapping as $map) {
292 $map[] = false; // Site api function.
293 $map[] = OPEN_BADGES_V1; // V1 function.
294 $this->mappings[] = new backpack_api_mapping(...$map);
295 }
296 }
297 }
298 }
299
300 /**
301 * Make an api request
302 *
303 * @param string $action The api function.
304 * @param string $collection An api parameter
305 * @param string $entityid An api parameter
306 * @param string $postdata The body of the api request.
307 * @return mixed
308 */
309 private function curl_request($action, $collection = null, $entityid = null, $postdata = null) {
310 global $CFG, $SESSION;
311
312 $curl = new curl();
313 $authrequired = false;
314 if ($this->backpackapiversion == OPEN_BADGES_V1) {
315 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
316 if (isset($SESSION->$useridkey)) {
317 if ($collection == null) {
318 $collection = $SESSION->$useridkey;
319 } else {
320 $entityid = $SESSION->$useridkey;
321 }
322 }
323 }
324 foreach ($this->mappings as $mapping) {
325 if ($mapping->is_match($action)) {
326 return $mapping->request(
327 $this->backpackapiurl,
328 $collection,
329 $entityid,
330 $this->email,
331 $this->password,
332 $postdata,
333 $this->backpackid
334 );
335 }
336 }
337
338 throw new coding_exception('Unknown request');
339 }
340
341 /**
342 * Get the id to use for requests with this api.
343 *
344 * @return integer
345 */
346 private function get_auth_user_id() {
347 global $USER;
348
349 if ($this->isuserbackpack) {
350 return $USER->id;
351 } else {
352 // The access tokens for the system backpack are shared.
353 return -1;
354 }
355 }
356
357 /**
358 * Get the name of the key to store this access token type.
359 *
360 * @param string $type
361 * @return string
362 */
363 private function get_token_key($type) {
364 // This should be removed when everything has a mapping.
365 $prefix = 'badges_';
366 if ($this->isuserbackpack) {
367 $prefix .= 'user_backpack_';
368 } else {
369 $prefix .= 'site_backpack_';
370 }
371 $prefix .= $type . '_token';
372 return $prefix;
373 }
374
375 /**
376 * Normalise the return from a missing user request.
377 *
378 * @param string $status
379 * @return mixed
380 */
381 private function check_status($status) {
382 // V1 ONLY.
383 switch($status) {
384 case "missing":
385 $response = array(
386 'status' => $status,
387 'message' => get_string('error:nosuchuser', 'badges')
388 );
389 return $response;
390 }
391 return false;
392 }
393
394 /**
395 * Make an api request to get an assertion
396 *
397 * @param string $entityid The id of the assertion.
398 * @return mixed
399 */
400 public function get_assertion($entityid) {
401 // V2 Only.
402 if ($this->backpackapiversion == OPEN_BADGES_V1) {
403 throw new coding_exception('Not supported in this backpack API');
404 }
405
406 return $this->curl_request('assertion', null, $entityid);
407 }
408
aae219ac
DW
409 /**
410 * Create a badgeclass assertion.
411 *
412 * @param string $entityid The id of the badge class.
413 * @param string $data The structure of the badge class assertion.
414 * @return mixed
415 */
416 public function put_badgeclass_assertion($entityid, $data) {
417 // V2 Only.
418 if ($this->backpackapiversion == OPEN_BADGES_V1) {
419 throw new coding_exception('Not supported in this backpack API');
420 }
421
422 return $this->curl_request('assertions', null, $entityid, $data);
423 }
424
b6435e09
P
425 /**
426 * Import a badge assertion into a backpack. This is used to handle cross domain backpacks.
427 *
428 * @param string $data The structure of the badge class assertion.
429 * @return mixed
430 * @throws coding_exception
431 */
432 public function import_badge_assertion(string $data) {
433 // V2 Only.
434 if ($this->backpackapiversion == OPEN_BADGES_V1) {
435 throw new coding_exception('Not supported in this backpack API');
436 }
437
438 return $this->curl_request('importbadge', null, null, $data);
439 }
440
aae219ac
DW
441 /**
442 * Select collections from a backpack.
443 *
444 * @param string $backpackid The id of the backpack
445 * @param stdClass[] $collections List of collections with collectionid or entityid.
446 * @return boolean
447 */
448 public function set_backpack_collections($backpackid, $collections) {
449 global $DB, $USER;
450
451 // Delete any previously selected collections.
452 $sqlparams = array('backpack' => $backpackid);
453 $select = 'backpackid = :backpack ';
454 $DB->delete_records_select('badge_external', $select, $sqlparams);
455 $badgescache = cache::make('core', 'externalbadges');
456
457 // Insert selected collections if they are not in database yet.
458 foreach ($collections as $collection) {
459 $obj = new stdClass();
460 $obj->backpackid = $backpackid;
461 if ($this->backpackapiversion == OPEN_BADGES_V1) {
462 $obj->collectionid = (int) $collection;
463 } else {
464 $obj->entityid = $collection;
465 $obj->collectionid = -1;
466 }
467 if (!$DB->record_exists('badge_external', (array) $obj)) {
468 $DB->insert_record('badge_external', $obj);
469 }
470 }
471 $badgescache->delete($USER->id);
472 return true;
473 }
474
475 /**
476 * Create a badgeclass
477 *
478 * @param string $entityid The id of the entity.
479 * @param string $data The structure of the badge class.
480 * @return mixed
481 */
482 public function put_badgeclass($entityid, $data) {
483 // V2 Only.
484 if ($this->backpackapiversion == OPEN_BADGES_V1) {
485 throw new coding_exception('Not supported in this backpack API');
486 }
487
488 return $this->curl_request('badgeclasses', null, $entityid, $data);
489 }
490
491 /**
492 * Create an issuer
493 *
494 * @param string $data The structure of the issuer.
495 * @return mixed
496 */
497 public function put_issuer($data) {
498 // V2 Only.
499 if ($this->backpackapiversion == OPEN_BADGES_V1) {
500 throw new coding_exception('Not supported in this backpack API');
501 }
502
503 return $this->curl_request('issuers', null, null, $data);
504 }
505
2fe34536
DW
506 /**
507 * Delete any user access tokens in the session so we will attempt to get new ones.
508 *
509 * @return void
510 */
511 public function clear_system_user_session() {
512 global $SESSION;
513
514 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
515 unset($SESSION->$useridkey);
516
517 $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
518 unset($SESSION->$expireskey);
519 }
520
aae219ac
DW
521 /**
522 * Authenticate using the stored email and password and save the valid access tokens.
523 *
524 * @return integer The id of the authenticated user.
525 */
526 public function authenticate() {
527 global $SESSION;
528
529 $backpackidkey = $this->get_token_key(BADGE_BACKPACK_ID_TOKEN);
530 $backpackid = isset($SESSION->$backpackidkey) ? $SESSION->$backpackidkey : 0;
531 // If the backpack is changed we need to expire sessions.
532 if ($backpackid == $this->backpackid) {
533 if ($this->backpackapiversion == OPEN_BADGES_V2) {
534 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
535 $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
536 if ($authuserid == $this->get_auth_user_id()) {
537 $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
538 if (isset($SESSION->$expireskey)) {
539 $expires = $SESSION->$expireskey;
540 if ($expires > time()) {
541 // We have a current access token for this user
542 // that has not expired.
543 return -1;
544 }
545 }
546 }
547 } else {
548 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
549 $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
550 if (!empty($authuserid)) {
551 return $authuserid;
552 }
553 }
554 }
555 return $this->curl_request('user', $this->email);
556 }
557
558 /**
559 * Get all collections in this backpack.
560 *
561 * @return stdClass[] The collections.
562 */
563 public function get_collections() {
564 global $PAGE;
565
566 if ($this->authenticate()) {
567 if ($this->backpackapiversion == OPEN_BADGES_V1) {
568 $result = $this->curl_request('groups');
569 if (isset($result->groups)) {
570 $result = $result->groups;
571 }
572 } else {
573 $result = $this->curl_request('collections');
574 }
575 if ($result) {
576 return $result;
577 }
578 }
579 return [];
580 }
581
582 /**
583 * Get one collection by id.
584 *
585 * @param integer $collectionid
586 * @return stdClass The collection.
587 */
588 public function get_collection_record($collectionid) {
589 global $DB;
590
591 if ($this->backpackapiversion == OPEN_BADGES_V1) {
592 return $DB->get_fieldset_select('badge_external', 'collectionid', 'backpackid = :bid', array('bid' => $collectionid));
593 } else {
594 return $DB->get_fieldset_select('badge_external', 'entityid', 'backpackid = :bid', array('bid' => $collectionid));
595 }
596 }
597
598 /**
599 * Disconnect the backpack from this user.
600 *
601 * @param integer $userid The user in Moodle
602 * @param integer $backpackid The backpack to disconnect
603 * @return boolean
604 */
1c4620bc 605 public function disconnect_backpack($userid, $backpackid, $externalbackupid) {
aae219ac
DW
606 global $DB, $USER;
607
608 if (\core\session\manager::is_loggedinas() || $userid != $USER->id) {
609 // Can't change someone elses backpack settings.
610 return false;
611 }
612
613 $badgescache = cache::make('core', 'externalbadges');
614
615 $DB->delete_records('badge_external', array('backpackid' => $backpackid));
616 $DB->delete_records('badge_backpack', array('userid' => $userid));
1c4620bc 617 $DB->delete_records('badge_external_backpack', array('id' => $externalbackupid));
aae219ac
DW
618 $badgescache->delete($userid);
619 return true;
620 }
621
622 /**
623 * Handle the response from getting a collection to map to an id.
624 *
625 * @param stdClass $data The response data.
626 * @return string The collection id.
627 */
628 public function get_collection_id_from_response($data) {
629 if ($this->backpackapiversion == OPEN_BADGES_V1) {
630 return $data->groupId;
631 } else {
632 return $data->entityId;
633 }
634 }
635
7444ba74
DW
636 /**
637 * Get the last error message returned during an authentication request.
638 *
639 * @return string
640 */
641 public function get_authentication_error() {
642 return backpack_api_mapping::get_authentication_error();
643 }
644
aae219ac
DW
645 /**
646 * Get the list of badges in a collection.
647 *
648 * @param stdClass $collection The collection to deal with.
649 * @param boolean $expanded Fetch all the sub entities.
650 * @return stdClass[]
651 */
652 public function get_badges($collection, $expanded = false) {
58c7d2ce
DW
653 global $PAGE;
654
aae219ac
DW
655 if ($this->authenticate()) {
656 if ($this->backpackapiversion == OPEN_BADGES_V1) {
657 if (empty($collection->collectionid)) {
658 return [];
659 }
660 $result = $this->curl_request('badges', $collection->collectionid);
661 return $result->badges;
662 } else {
663 if (empty($collection->entityid)) {
664 return [];
665 }
666 // Now we can make requests.
667 $badges = $this->curl_request('badges', $collection->entityid);
668 if (count($badges) == 0) {
669 return [];
670 }
671 $badges = $badges[0];
672 if ($expanded) {
673 $publicassertions = [];
58c7d2ce
DW
674 $context = context_system::instance();
675 $output = $PAGE->get_renderer('core', 'badges');
aae219ac
DW
676 foreach ($badges->assertions as $assertion) {
677 $remoteassertion = $this->get_assertion($assertion);
58c7d2ce
DW
678 // Remote badge was fetched nested in the assertion.
679 $remotebadge = $remoteassertion->badgeclass;
aae219ac
DW
680 if (!$remotebadge) {
681 continue;
682 }
58c7d2ce
DW
683 $apidata = badgeclass_exporter::map_external_data($remotebadge, $this->backpackapiversion);
684 $exporterinstance = new badgeclass_exporter($apidata, ['context' => $context]);
685 $remotebadge = $exporterinstance->export($output);
686
687 $remoteissuer = $remotebadge->issuer;
688 $apidata = issuer_exporter::map_external_data($remoteissuer, $this->backpackapiversion);
689 $exporterinstance = new issuer_exporter($apidata, ['context' => $context]);
690 $remoteissuer = $exporterinstance->export($output);
691
aae219ac
DW
692 $badgeclone = clone $remotebadge;
693 $badgeclone->issuer = $remoteissuer;
694 $remoteassertion->badge = $badgeclone;
695 $remotebadge->assertion = $remoteassertion;
aae219ac
DW
696 $publicassertions[] = $remotebadge;
697 }
698 $badges = $publicassertions;
699 }
700 return $badges;
701 }
702 }
703 }
704}