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