MDL-65959 badge: Confirm assertion data is updated before awarding
[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                 $mapping[] = [
229                     'updateassertion',                                // Action.
230                     '[URL]/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
231                     '[PARAM]',                                  // Post params.
232                     'core_badges\external\assertion_exporter', // Request exporter.
233                     'core_badges\external\assertion_exporter', // Response exporter.
234                     false,                                      // Multiple.
235                     'put',                                     // Method.
236                     true,                                       // JSON Encoded.
237                     true                                        // Auth required.
238                 ];
239                 foreach ($mapping as $map) {
240                     $map[] = false; // Site api function.
241                     $map[] = OPEN_BADGES_V2; // V2 function.
242                     $this->mappings[] = new backpack_api_mapping(...$map);
243                 }
244             }
245         } else {
246             if ($this->isuserbackpack) {
247                 $mapping = [];
248                 $mapping[] = [
249                     'user',                                     // Action.
250                     '[URL]/displayer/convert/email',            // URL
251                     ['email' => '[EMAIL]'],                     // Post params.
252                     '',                                         // Request exporter.
253                     'convert_email_response',                   // Response exporter.
254                     false,                                      // Multiple.
255                     'post',                                     // Method.
256                     false,                                      // JSON Encoded.
257                     false                                       // Auth required.
258                 ];
259                 $mapping[] = [
260                     'groups',                                   // Action.
261                     '[URL]/displayer/[PARAM1]/groups.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                 $mapping[] = [
271                     'badges',                                   // Action.
272                     '[URL]/displayer/[PARAM2]/group/[PARAM1].json',     // URL
273                     [],                                         // Post params.
274                     '',                                         // Request exporter.
275                     '',                                         // Response exporter.
276                     false,                                      // Multiple.
277                     'get',                                      // Method.
278                     true,                                       // JSON Encoded.
279                     true                                        // Auth required.
280                 ];
281                 foreach ($mapping as $map) {
282                     $map[] = true; // User api function.
283                     $map[] = OPEN_BADGES_V1; // V1 function.
284                     $this->mappings[] = new backpack_api_mapping(...$map);
285                 }
286             } else {
287                 $mapping = [];
288                 $mapping[] = [
289                     'user',                                     // Action.
290                     '[URL]/displayer/convert/email',            // URL
291                     ['email' => '[EMAIL]'],                     // Post params.
292                     '',                                         // Request exporter.
293                     'convert_email_response',                   // Response exporter.
294                     false,                                      // Multiple.
295                     'post',                                     // Method.
296                     false,                                      // JSON Encoded.
297                     false                                       // Auth required.
298                 ];
299                 foreach ($mapping as $map) {
300                     $map[] = false; // Site api function.
301                     $map[] = OPEN_BADGES_V1; // V1 function.
302                     $this->mappings[] = new backpack_api_mapping(...$map);
303                 }
304             }
305         }
306     }
308     /**
309      * Make an api request
310      *
311      * @param string $action The api function.
312      * @param string $collection An api parameter
313      * @param string $entityid An api parameter
314      * @param string $postdata The body of the api request.
315      * @return mixed
316      */
317     private function curl_request($action, $collection = null, $entityid = null, $postdata = null) {
318         global $CFG, $SESSION;
320         $curl = new curl();
321         $authrequired = false;
322         if ($this->backpackapiversion == OPEN_BADGES_V1) {
323             $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
324             if (isset($SESSION->$useridkey)) {
325                 if ($collection == null) {
326                     $collection = $SESSION->$useridkey;
327                 } else {
328                     $entityid = $SESSION->$useridkey;
329                 }
330             }
331         }
332         foreach ($this->mappings as $mapping) {
333             if ($mapping->is_match($action)) {
334                 return $mapping->request(
335                     $this->backpackapiurl,
336                     $collection,
337                     $entityid,
338                     $this->email,
339                     $this->password,
340                     $postdata,
341                     $this->backpackid
342                 );
343             }
344         }
346         throw new coding_exception('Unknown request');
347     }
349     /**
350      * Get the id to use for requests with this api.
351      *
352      * @return integer
353      */
354     private function get_auth_user_id() {
355         global $USER;
357         if ($this->isuserbackpack) {
358             return $USER->id;
359         } else {
360             // The access tokens for the system backpack are shared.
361             return -1;
362         }
363     }
365     /**
366      * Get the name of the key to store this access token type.
367      *
368      * @param string $type
369      * @return string
370      */
371     private function get_token_key($type) {
372         // This should be removed when everything has a mapping.
373         $prefix = 'badges_';
374         if ($this->isuserbackpack) {
375             $prefix .= 'user_backpack_';
376         } else {
377             $prefix .= 'site_backpack_';
378         }
379         $prefix .= $type . '_token';
380         return $prefix;
381     }
383     /**
384      * Normalise the return from a missing user request.
385      *
386      * @param string $status
387      * @return mixed
388      */
389     private function check_status($status) {
390         // V1 ONLY.
391         switch($status) {
392             case "missing":
393                 $response = array(
394                     'status'  => $status,
395                     'message' => get_string('error:nosuchuser', 'badges')
396                 );
397                 return $response;
398         }
399         return false;
400     }
402     /**
403      * Make an api request to get an assertion
404      *
405      * @param string $entityid The id of the assertion.
406      * @return mixed
407      */
408     public function get_assertion($entityid) {
409         // V2 Only.
410         if ($this->backpackapiversion == OPEN_BADGES_V1) {
411             throw new coding_exception('Not supported in this backpack API');
412         }
414         return $this->curl_request('assertion', null, $entityid);
415     }
417     /**
418      * Create a badgeclass assertion.
419      *
420      * @param string $entityid The id of the badge class.
421      * @param string $data The structure of the badge class assertion.
422      * @return mixed
423      */
424     public function put_badgeclass_assertion($entityid, $data) {
425         // V2 Only.
426         if ($this->backpackapiversion == OPEN_BADGES_V1) {
427             throw new coding_exception('Not supported in this backpack API');
428         }
430         return $this->curl_request('assertions', null, $entityid, $data);
431     }
433     /**
434      * Update a badgeclass assertion.
435      *
436      * @param string $entityid The id of the badge class.
437      * @param array $data The structure of the badge class assertion.
438      * @return mixed
439      */
440     public function update_assertion(string $entityid, array $data) {
441         // V2 Only.
442         if ($this->backpackapiversion == OPEN_BADGES_V1) {
443             throw new coding_exception('Not supported in this backpack API');
444         }
446         return $this->curl_request('updateassertion', null, $entityid, $data);
447     }
449     /**
450      * Import a badge assertion into a backpack. This is used to handle cross domain backpacks.
451      *
452      * @param string $data The structure of the badge class assertion.
453      * @return mixed
454      * @throws coding_exception
455      */
456     public function import_badge_assertion(string $data) {
457         // V2 Only.
458         if ($this->backpackapiversion == OPEN_BADGES_V1) {
459             throw new coding_exception('Not supported in this backpack API');
460         }
462         return $this->curl_request('importbadge', null, null, $data);
463     }
465     /**
466      * Select collections from a backpack.
467      *
468      * @param string $backpackid The id of the backpack
469      * @param stdClass[] $collections List of collections with collectionid or entityid.
470      * @return boolean
471      */
472     public function set_backpack_collections($backpackid, $collections) {
473         global $DB, $USER;
475         // Delete any previously selected collections.
476         $sqlparams = array('backpack' => $backpackid);
477         $select = 'backpackid = :backpack ';
478         $DB->delete_records_select('badge_external', $select, $sqlparams);
479         $badgescache = cache::make('core', 'externalbadges');
481         // Insert selected collections if they are not in database yet.
482         foreach ($collections as $collection) {
483             $obj = new stdClass();
484             $obj->backpackid = $backpackid;
485             if ($this->backpackapiversion == OPEN_BADGES_V1) {
486                 $obj->collectionid = (int) $collection;
487             } else {
488                 $obj->entityid = $collection;
489                 $obj->collectionid = -1;
490             }
491             if (!$DB->record_exists('badge_external', (array) $obj)) {
492                 $DB->insert_record('badge_external', $obj);
493             }
494         }
495         $badgescache->delete($USER->id);
496         return true;
497     }
499     /**
500      * Create a badgeclass
501      *
502      * @param string $entityid The id of the entity.
503      * @param string $data The structure of the badge class.
504      * @return mixed
505      */
506     public function put_badgeclass($entityid, $data) {
507         // V2 Only.
508         if ($this->backpackapiversion == OPEN_BADGES_V1) {
509             throw new coding_exception('Not supported in this backpack API');
510         }
512         return $this->curl_request('badgeclasses', null, $entityid, $data);
513     }
515     /**
516      * Create an issuer
517      *
518      * @param string $data The structure of the issuer.
519      * @return mixed
520      */
521     public function put_issuer($data) {
522         // V2 Only.
523         if ($this->backpackapiversion == OPEN_BADGES_V1) {
524             throw new coding_exception('Not supported in this backpack API');
525         }
527         return $this->curl_request('issuers', null, null, $data);
528     }
530     /**
531      * Delete any user access tokens in the session so we will attempt to get new ones.
532      *
533      * @return void
534      */
535     public function clear_system_user_session() {
536         global $SESSION;
538         $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
539         unset($SESSION->$useridkey);
541         $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
542         unset($SESSION->$expireskey);
543     }
545     /**
546      * Authenticate using the stored email and password and save the valid access tokens.
547      *
548      * @return integer The id of the authenticated user.
549      */
550     public function authenticate() {
551         global $SESSION;
553         $backpackidkey = $this->get_token_key(BADGE_BACKPACK_ID_TOKEN);
554         $backpackid = isset($SESSION->$backpackidkey) ? $SESSION->$backpackidkey : 0;
555         // If the backpack is changed we need to expire sessions.
556         if ($backpackid == $this->backpackid) {
557             if ($this->backpackapiversion == OPEN_BADGES_V2) {
558                 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
559                 $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
560                 if ($authuserid == $this->get_auth_user_id()) {
561                     $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
562                     if (isset($SESSION->$expireskey)) {
563                         $expires = $SESSION->$expireskey;
564                         if ($expires > time()) {
565                             // We have a current access token for this user
566                             // that has not expired.
567                             return -1;
568                         }
569                     }
570                 }
571             } else {
572                 $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
573                 $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
574                 if (!empty($authuserid)) {
575                     return $authuserid;
576                 }
577             }
578         }
579         return $this->curl_request('user', $this->email);
580     }
582     /**
583      * Get all collections in this backpack.
584      *
585      * @return stdClass[] The collections.
586      */
587     public function get_collections() {
588         global $PAGE;
590         if ($this->authenticate()) {
591             if ($this->backpackapiversion == OPEN_BADGES_V1) {
592                 $result = $this->curl_request('groups');
593                 if (isset($result->groups)) {
594                     $result = $result->groups;
595                 }
596             } else {
597                 $result = $this->curl_request('collections');
598             }
599             if ($result) {
600                 return $result;
601             }
602         }
603         return [];
604     }
606     /**
607      * Get one collection by id.
608      *
609      * @param integer $collectionid
610      * @return stdClass The collection.
611      */
612     public function get_collection_record($collectionid) {
613         global $DB;
615         if ($this->backpackapiversion == OPEN_BADGES_V1) {
616             return $DB->get_fieldset_select('badge_external', 'collectionid', 'backpackid = :bid', array('bid' => $collectionid));
617         } else {
618             return $DB->get_fieldset_select('badge_external', 'entityid', 'backpackid = :bid', array('bid' => $collectionid));
619         }
620     }
622     /**
623      * Disconnect the backpack from this user.
624      *
625      * @param integer $userid The user in Moodle
626      * @param integer $backpackid The backpack to disconnect
627      * @return boolean
628      */
629     public function disconnect_backpack($userid, $backpackid) {
630         global $DB, $USER;
632         if (\core\session\manager::is_loggedinas() || $userid != $USER->id) {
633             // Can't change someone elses backpack settings.
634             return false;
635         }
637         $badgescache = cache::make('core', 'externalbadges');
639         $DB->delete_records('badge_external', array('backpackid' => $backpackid));
640         $DB->delete_records('badge_backpack', array('userid' => $userid));
641         $badgescache->delete($userid);
642         return true;
643     }
645     /**
646      * Handle the response from getting a collection to map to an id.
647      *
648      * @param stdClass $data The response data.
649      * @return string The collection id.
650      */
651     public function get_collection_id_from_response($data) {
652         if ($this->backpackapiversion == OPEN_BADGES_V1) {
653             return $data->groupId;
654         } else {
655             return $data->entityId;
656         }
657     }
659     /**
660      * Get the last error message returned during an authentication request.
661      *
662      * @return string
663      */
664     public function get_authentication_error() {
665         return backpack_api_mapping::get_authentication_error();
666     }
668     /**
669      * Get the list of badges in a collection.
670      *
671      * @param stdClass $collection The collection to deal with.
672      * @param boolean $expanded Fetch all the sub entities.
673      * @return stdClass[]
674      */
675     public function get_badges($collection, $expanded = false) {
676         global $PAGE;
678         if ($this->authenticate()) {
679             if ($this->backpackapiversion == OPEN_BADGES_V1) {
680                 if (empty($collection->collectionid)) {
681                     return [];
682                 }
683                 $result = $this->curl_request('badges', $collection->collectionid);
684                 return $result->badges;
685             } else {
686                 if (empty($collection->entityid)) {
687                     return [];
688                 }
689                 // Now we can make requests.
690                 $badges = $this->curl_request('badges', $collection->entityid);
691                 if (count($badges) == 0) {
692                     return [];
693                 }
694                 $badges = $badges[0];
695                 if ($expanded) {
696                     $publicassertions = [];
697                     $context = context_system::instance();
698                     $output = $PAGE->get_renderer('core', 'badges');
699                     foreach ($badges->assertions as $assertion) {
700                         $remoteassertion = $this->get_assertion($assertion);
701                         // Remote badge was fetched nested in the assertion.
702                         $remotebadge = $remoteassertion->badgeclass;
703                         if (!$remotebadge) {
704                             continue;
705                         }
706                         $apidata = badgeclass_exporter::map_external_data($remotebadge, $this->backpackapiversion);
707                         $exporterinstance = new badgeclass_exporter($apidata, ['context' => $context]);
708                         $remotebadge = $exporterinstance->export($output);
710                         $remoteissuer = $remotebadge->issuer;
711                         $apidata = issuer_exporter::map_external_data($remoteissuer, $this->backpackapiversion);
712                         $exporterinstance = new issuer_exporter($apidata, ['context' => $context]);
713                         $remoteissuer = $exporterinstance->export($output);
715                         $badgeclone = clone $remotebadge;
716                         $badgeclone->issuer = $remoteissuer;
717                         $remoteassertion->badge = $badgeclone;
718                         $remotebadge->assertion = $remoteassertion;
719                         $publicassertions[] = $remotebadge;
720                     }
721                     $badges = $publicassertions;
722                 }
723                 return $badges;
724             }
725         }
726     }