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