d15544340f9664be01e459851d5af49123ad73df
[moodle.git] / badges / classes / backpack_api.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  * 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  */
25 namespace core_badges;
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->libdir . '/filelib.php');
31 use cache;
32 use coding_exception;
33 use core_badges\external\assertion_exporter;
34 use core_badges\external\collection_exporter;
35 use core_badges\external\issuer_exporter;
36 use core_badges\external\badgeclass_exporter;
37 use curl;
38 use stdClass;
39 use context_system;
41 define('BADGE_ACCESS_TOKEN', 'access');
42 define('BADGE_USER_ID_TOKEN', 'user_id');
43 define('BADGE_BACKPACK_ID_TOKEN', 'backpack_id');
44 define('BADGE_REFRESH_TOKEN', 'refresh');
45 define('BADGE_EXPIRES_TOKEN', 'expires');
47 /**
48  * Class for communicating with backpacks.
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
53  */
54 class backpack_api {
56     /** @var string The email address of the issuer or the backpack owner. */
57     private $email;
59     /** @var string The base url used for api requests to this backpack. */
60     private $backpackapiurl;
62     /** @var integer The backpack api version to use. */
63     private $backpackapiversion;
65     /** @var string The password to authenticate requests. */
66     private $password;
68     /** @var boolean User or site api requests. */
69     private $isuserbackpack;
71     /** @var integer The id of the backpack we are talking to. */
72     private $backpackid;
74     /** @var \backpack_api_mapping[] List of apis for the user or site using api version 1 or 2. */
75     private $mappings = [];
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();
90         $this->backpackapiurl = $sitebackpack->backpackapiurl;
91         $this->backpackapiversion = $sitebackpack->apiversion;
92         $this->password = $sitebackpack->password;
93         $this->email = !empty($CFG->badges_defaultissuercontact) ? $CFG->badges_defaultissuercontact : '';
94         $this->isuserbackpack = false;
95         $this->backpackid = $sitebackpack->id;
96         if (!empty($userbackpack)) {
97             if ($userbackpack->externalbackpackid != $sitebackpack->id) {
98                 throw new coding_exception('Incorrect backpack');
99             }
100             $this->isuserbackpack = true;
101             $this->password = $userbackpack->password;
102             $this->email = $userbackpack->email;
103         }
105         $this->define_mappings();
106         // Clear the last authentication error.
107         backpack_api_mapping::set_authentication_error('');
108     }
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                 ];
139                 $mapping[] = [
140                     'assertion',                                // Action.
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',
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                 ];
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                 ];
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.
222                     '[URL]/badgeclasses/[PARAM2]/assertions',   // URL
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     }
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;
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         }
338         throw new coding_exception('Unknown request');
339     }
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;
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     }
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     }
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     }
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         }
406         return $this->curl_request('assertion', null, $entityid);
407     }
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         }
422         return $this->curl_request('assertions', null, $entityid, $data);
423     }
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         }
438         return $this->curl_request('importbadge', null, null, $data);
439     }
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;
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');
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     }
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         }
488         return $this->curl_request('badgeclasses', null, $entityid, $data);
489     }
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         }
503         return $this->curl_request('issuers', null, null, $data);
504     }
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;
514         $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
515         unset($SESSION->$useridkey);
517         $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
518         unset($SESSION->$expireskey);
519     }
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;
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     }
558     /**
559      * Get all collections in this backpack.
560      *
561      * @return stdClass[] The collections.
562      */
563     public function get_collections() {
564         global $PAGE;
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     }
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;
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     }
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      */
605     public function disconnect_backpack($userid, $backpackid, $externalbackupid) {
606         global $DB, $USER;
608         if (\core\session\manager::is_loggedinas() || $userid != $USER->id) {
609             // Can't change someone elses backpack settings.
610             return false;
611         }
613         $badgescache = cache::make('core', 'externalbadges');
615         $DB->delete_records('badge_external', array('backpackid' => $backpackid));
616         $DB->delete_records('badge_backpack', array('userid' => $userid));
617         $DB->delete_records('badge_external_backpack', array('id' => $externalbackupid));
618         $badgescache->delete($userid);
619         return true;
620     }
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     }
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     }
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) {
653         global $PAGE;
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 = [];
674                     $context = context_system::instance();
675                     $output = $PAGE->get_renderer('core', 'badges');
676                     foreach ($badges->assertions as $assertion) {
677                         $remoteassertion = $this->get_assertion($assertion);
678                         // Remote badge was fetched nested in the assertion.
679                         $remotebadge = $remoteassertion->badgeclass;
680                         if (!$remotebadge) {
681                             continue;
682                         }
683                         $apidata = badgeclass_exporter::map_external_data($remotebadge, $this->backpackapiversion);
684                         $exporterinstance = new badgeclass_exporter($apidata, ['context' => $context]);
685                         $remotebadge = $exporterinstance->export($output);
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);
692                         $badgeclone = clone $remotebadge;
693                         $badgeclone->issuer = $remoteissuer;
694                         $remoteassertion->badge = $badgeclone;
695                         $remotebadge->assertion = $remoteassertion;
696                         $publicassertions[] = $remotebadge;
697                     }
698                     $badges = $publicassertions;
699                 }
700                 return $badges;
701             }
702         }
703     }