<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Communicate with backpacks.
*
* @copyright 2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @author Yuliya Bozhko <yuliya.bozhko@totaralms.com>
*/
namespace core_badges;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/filelib.php');
use cache;
use coding_exception;
use core_badges\external\assertion_exporter;
use core_badges\external\collection_exporter;
use core_badges\external\issuer_exporter;
use core_badges\external\badgeclass_exporter;
use curl;
use stdClass;
use context_system;
define('BADGE_ACCESS_TOKEN', 'access');
define('BADGE_USER_ID_TOKEN', 'user_id');
define('BADGE_BACKPACK_ID_TOKEN', 'backpack_id');
define('BADGE_REFRESH_TOKEN', 'refresh');
define('BADGE_EXPIRES_TOKEN', 'expires');
/**
* Class for communicating with backpacks.
*
* @package core_badges
* @copyright 2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backpack_api {
/** @var string The email address of the issuer or the backpack owner. */
private $email;
/** @var string The base url used for api requests to this backpack. */
private $backpackapiurl;
/** @var integer The backpack api version to use. */
private $backpackapiversion;
/** @var string The password to authenticate requests. */
private $password;
/** @var boolean User or site api requests. */
private $isuserbackpack;
/** @var integer The id of the backpack we are talking to. */
private $backpackid;
/** @var \backpack_api_mapping[] List of apis for the user or site using api version 1 or 2. */
private $mappings = [];
/**
* Create a wrapper to communicate with the backpack.
*
* The resulting class can only do either site backpack communication or
* user backpack communication.
*
* @param stdClass $sitebackpack The site backpack record
* @param mixed $userbackpack Optional - if passed it represents the users backpack.
*/
public function __construct($sitebackpack, $userbackpack = false) {
global $CFG;
$admin = get_admin();
$this->backpackapiurl = $sitebackpack->backpackapiurl;
$this->backpackapiversion = $sitebackpack->apiversion;
$this->password = $sitebackpack->password;
$this->email = $sitebackpack->backpackemail;
$this->isuserbackpack = false;
$this->backpackid = $sitebackpack->id;
if (!empty($userbackpack)) {
$this->isuserbackpack = true;
$this->password = $userbackpack->password;
$this->email = $userbackpack->email;
}
$this->define_mappings();
// Clear the last authentication error.
backpack_api_mapping::set_authentication_error('');
}
/**
* Define the mappings supported by this usage and api version.
*/
private function define_mappings() {
if ($this->backpackapiversion == OPEN_BADGES_V2) {
if ($this->isuserbackpack) {
$mapping = [];
$mapping[] = [
'collections', // Action.
'[URL]/backpack/collections', // URL
[], // Post params.
'', // Request exporter.
'core_badges\external\collection_exporter', // Response exporter.
true, // Multiple.
'get', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'user', // Action.
'[SCHEME]://[HOST]/o/token', // URL
['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
'', // Request exporter.
'oauth_token_response', // Response exporter.
false, // Multiple.
'post', // Method.
false, // JSON Encoded.
false, // Auth required.
];
$mapping[] = [
'assertion', // Action.
// Badgr.io does not return the public information about a badge
// if the issuer is associated with another user. We need to pass
// the expand parameters which are not in any specification to get
// additional information about the assertion in a single request.
'[URL]/backpack/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
[], // Post params.
'', // Request exporter.
'core_badges\external\assertion_exporter', // Response exporter.
false, // Multiple.
'get', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'importbadge', // Action.
// Badgr.io does not return the public information about a badge
// if the issuer is associated with another user. We need to pass
// the expand parameters which are not in any specification to get
// additional information about the assertion in a single request.
'[URL]/backpack/import',
['url' => '[PARAM]'], // Post params.
'', // Request exporter.
'core_badges\external\assertion_exporter', // Response exporter.
false, // Multiple.
'post', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'badges', // Action.
'[URL]/backpack/collections/[PARAM1]', // URL
[], // Post params.
'', // Request exporter.
'core_badges\external\collection_exporter', // Response exporter.
true, // Multiple.
'get', // Method.
true, // JSON Encoded.
true // Auth required.
];
foreach ($mapping as $map) {
$map[] = true; // User api function.
$map[] = OPEN_BADGES_V2; // V2 function.
$this->mappings[] = new backpack_api_mapping(...$map);
}
} else {
$mapping = [];
$mapping[] = [
'user', // Action.
'[SCHEME]://[HOST]/o/token', // URL
['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
'', // Request exporter.
'oauth_token_response', // Response exporter.
false, // Multiple.
'post', // Method.
false, // JSON Encoded.
false // Auth required.
];
$mapping[] = [
'issuers', // Action.
'[URL]/issuers', // URL
'[PARAM]', // Post params.
'core_badges\external\issuer_exporter', // Request exporter.
'core_badges\external\issuer_exporter', // Response exporter.
false, // Multiple.
'post', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'badgeclasses', // Action.
'[URL]/issuers/[PARAM2]/badgeclasses', // URL
'[PARAM]', // Post params.
'core_badges\external\badgeclass_exporter', // Request exporter.
'core_badges\external\badgeclass_exporter', // Response exporter.
false, // Multiple.
'post', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'assertions', // Action.
'[URL]/badgeclasses/[PARAM2]/assertions', // URL
'[PARAM]', // Post params.
'core_badges\external\assertion_exporter', // Request exporter.
'core_badges\external\assertion_exporter', // Response exporter.
false, // Multiple.
'post', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'updateassertion', // Action.
'[URL]/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
'[PARAM]', // Post params.
'core_badges\external\assertion_exporter', // Request exporter.
'core_badges\external\assertion_exporter', // Response exporter.
false, // Multiple.
'put', // Method.
true, // JSON Encoded.
true // Auth required.
];
foreach ($mapping as $map) {
$map[] = false; // Site api function.
$map[] = OPEN_BADGES_V2; // V2 function.
$this->mappings[] = new backpack_api_mapping(...$map);
}
}
} else {
if ($this->isuserbackpack) {
$mapping = [];
$mapping[] = [
'user', // Action.
'[URL]/displayer/convert/email', // URL
['email' => '[EMAIL]'], // Post params.
'', // Request exporter.
'convert_email_response', // Response exporter.
false, // Multiple.
'post', // Method.
false, // JSON Encoded.
false // Auth required.
];
$mapping[] = [
'groups', // Action.
'[URL]/displayer/[PARAM1]/groups.json', // URL
[], // Post params.
'', // Request exporter.
'', // Response exporter.
false, // Multiple.
'get', // Method.
true, // JSON Encoded.
true // Auth required.
];
$mapping[] = [
'badges', // Action.
'[URL]/displayer/[PARAM2]/group/[PARAM1].json', // URL
[], // Post params.
'', // Request exporter.
'', // Response exporter.
false, // Multiple.
'get', // Method.
true, // JSON Encoded.
true // Auth required.
];
foreach ($mapping as $map) {
$map[] = true; // User api function.
$map[] = OPEN_BADGES_V1; // V1 function.
$this->mappings[] = new backpack_api_mapping(...$map);
}
} else {
$mapping = [];
$mapping[] = [
'user', // Action.
'[URL]/displayer/convert/email', // URL
['email' => '[EMAIL]'], // Post params.
'', // Request exporter.
'convert_email_response', // Response exporter.
false, // Multiple.
'post', // Method.
false, // JSON Encoded.
false // Auth required.
];
foreach ($mapping as $map) {
$map[] = false; // Site api function.
$map[] = OPEN_BADGES_V1; // V1 function.
$this->mappings[] = new backpack_api_mapping(...$map);
}
}
}
}
/**
* Make an api request
*
* @param string $action The api function.
* @param string $collection An api parameter
* @param string $entityid An api parameter
* @param string $postdata The body of the api request.
* @return mixed
*/
private function curl_request($action, $collection = null, $entityid = null, $postdata = null) {
global $CFG, $SESSION;
$curl = new curl();
$authrequired = false;
if ($this->backpackapiversion == OPEN_BADGES_V1) {
$useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
if (isset($SESSION->$useridkey)) {
if ($collection == null) {
$collection = $SESSION->$useridkey;
} else {
$entityid = $SESSION->$useridkey;
}
}
}
foreach ($this->mappings as $mapping) {
if ($mapping->is_match($action)) {
return $mapping->request(
$this->backpackapiurl,
$collection,
$entityid,
$this->email,
$this->password,
$postdata,
$this->backpackid
);
}
}
throw new coding_exception('Unknown request');
}
/**
* Get the id to use for requests with this api.
*
* @return integer
*/
private function get_auth_user_id() {
global $USER;
if ($this->isuserbackpack) {
return $USER->id;
} else {
// The access tokens for the system backpack are shared.
return -1;
}
}
/**
* Get the name of the key to store this access token type.
*
* @param string $type
* @return string
*/
private function get_token_key($type) {
// This should be removed when everything has a mapping.
$prefix = 'badges_';
if ($this->isuserbackpack) {
$prefix .= 'user_backpack_';
} else {
$prefix .= 'site_backpack_';
}
$prefix .= $type . '_token';
return $prefix;
}
/**
* Normalise the return from a missing user request.
*
* @param string $status
* @return mixed
*/
private function check_status($status) {
// V1 ONLY.
switch($status) {
case "missing":
$response = array(
'status' => $status,
'message' => get_string('error:nosuchuser', 'badges')
);
return $response;
}
return false;
}
/**
* Make an api request to get an assertion
*
* @param string $entityid The id of the assertion.
* @return mixed
*/
public function get_assertion($entityid) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('assertion', null, $entityid);
}
/**
* Create a badgeclass assertion.
*
* @param string $entityid The id of the badge class.
* @param string $data The structure of the badge class assertion.
* @return mixed
*/
public function put_badgeclass_assertion($entityid, $data) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('assertions', null, $entityid, $data);
}
/**
* Update a badgeclass assertion.
*
* @param string $entityid The id of the badge class.
* @param array $data The structure of the badge class assertion.
* @return mixed
*/
public function update_assertion(string $entityid, array $data) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('updateassertion', null, $entityid, $data);
}
/**
* Import a badge assertion into a backpack. This is used to handle cross domain backpacks.
*
* @param string $data The structure of the badge class assertion.
* @return mixed
* @throws coding_exception
*/
public function import_badge_assertion(string $data) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('importbadge', null, null, $data);
}
/**
* Select collections from a backpack.
*
* @param string $backpackid The id of the backpack
* @param stdClass[] $collections List of collections with collectionid or entityid.
* @return boolean
*/
public function set_backpack_collections($backpackid, $collections) {
global $DB, $USER;
// Delete any previously selected collections.
$sqlparams = array('backpack' => $backpackid);
$select = 'backpackid = :backpack ';
$DB->delete_records_select('badge_external', $select, $sqlparams);
$badgescache = cache::make('core', 'externalbadges');
// Insert selected collections if they are not in database yet.
foreach ($collections as $collection) {
$obj = new stdClass();
$obj->backpackid = $backpackid;
if ($this->backpackapiversion == OPEN_BADGES_V1) {
$obj->collectionid = (int) $collection;
} else {
$obj->entityid = $collection;
$obj->collectionid = -1;
}
if (!$DB->record_exists('badge_external', (array) $obj)) {
$DB->insert_record('badge_external', $obj);
}
}
$badgescache->delete($USER->id);
return true;
}
/**
* Create a badgeclass
*
* @param string $entityid The id of the entity.
* @param string $data The structure of the badge class.
* @return mixed
*/
public function put_badgeclass($entityid, $data) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('badgeclasses', null, $entityid, $data);
}
/**
* Create an issuer
*
* @param string $data The structure of the issuer.
* @return mixed
*/
public function put_issuer($data) {
// V2 Only.
if ($this->backpackapiversion == OPEN_BADGES_V1) {
throw new coding_exception('Not supported in this backpack API');
}
return $this->curl_request('issuers', null, null, $data);
}
/**
* Delete any user access tokens in the session so we will attempt to get new ones.
*
* @return void
*/
public function clear_system_user_session() {
global $SESSION;
$useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
unset($SESSION->$useridkey);
$expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
unset($SESSION->$expireskey);
}
/**
* Authenticate using the stored email and password and save the valid access tokens.
*
< * @return integer The id of the authenticated user.
> * @return mixed The id of the authenticated user as returned by the backpack. Can have
> * different formats - numeric, empty, object with 'error' property, etc.
*/
public function authenticate() {
global $SESSION;
$backpackidkey = $this->get_token_key(BADGE_BACKPACK_ID_TOKEN);
$backpackid = isset($SESSION->$backpackidkey) ? $SESSION->$backpackidkey : 0;
// If the backpack is changed we need to expire sessions.
if ($backpackid == $this->backpackid) {
if ($this->backpackapiversion == OPEN_BADGES_V2) {
$useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
$authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
if ($authuserid == $this->get_auth_user_id()) {
$expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
if (isset($SESSION->$expireskey)) {
$expires = $SESSION->$expireskey;
if ($expires > time()) {
// We have a current access token for this user
// that has not expired.
return -1;
}
}
}
} else {
$useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
$authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
if (!empty($authuserid)) {
return $authuserid;
}
}
}
return $this->curl_request('user', $this->email);
}
/**
* Get all collections in this backpack.
*
* @return stdClass[] The collections.
*/
public function get_collections() {
global $PAGE;
if ($this->authenticate()) {
if ($this->backpackapiversion == OPEN_BADGES_V1) {
$result = $this->curl_request('groups');
if (isset($result->groups)) {
$result = $result->groups;
}
} else {
$result = $this->curl_request('collections');
}
if ($result) {
return $result;
}
}
return [];
}
/**
* Get one collection by id.
*
* @param integer $collectionid
* @return stdClass The collection.
*/
public function get_collection_record($collectionid) {
global $DB;
if ($this->backpackapiversion == OPEN_BADGES_V1) {
return $DB->get_fieldset_select('badge_external', 'collectionid', 'backpackid = :bid', array('bid' => $collectionid));
} else {
return $DB->get_fieldset_select('badge_external', 'entityid', 'backpackid = :bid', array('bid' => $collectionid));
}
}
/**
* Disconnect the backpack from this user.
*
* @param integer $userid The user in Moodle
* @param integer $backpackid The backpack to disconnect
* @return boolean
*/
public function disconnect_backpack($userid, $backpackid) {
global $DB, $USER;
if (\core\session\manager::is_loggedinas() || $userid != $USER->id) {
// Can't change someone elses backpack settings.
return false;
}
$badgescache = cache::make('core', 'externalbadges');
$DB->delete_records('badge_external', array('backpackid' => $backpackid));
$DB->delete_records('badge_backpack', array('userid' => $userid));
$badgescache->delete($userid);
$this->clear_system_user_session();
return true;
}
/**
* Handle the response from getting a collection to map to an id.
*
* @param stdClass $data The response data.
* @return string The collection id.
*/
public function get_collection_id_from_response($data) {
if ($this->backpackapiversion == OPEN_BADGES_V1) {
return $data->groupId;
} else {
return $data->entityId;
}
}
/**
* Get the last error message returned during an authentication request.
*
* @return string
*/
public function get_authentication_error() {
return backpack_api_mapping::get_authentication_error();
}
/**
* Get the list of badges in a collection.
*
* @param stdClass $collection The collection to deal with.
* @param boolean $expanded Fetch all the sub entities.
* @return stdClass[]
*/
public function get_badges($collection, $expanded = false) {
global $PAGE;
if ($this->authenticate()) {
if ($this->backpackapiversion == OPEN_BADGES_V1) {
if (empty($collection->collectionid)) {
return [];
}
$result = $this->curl_request('badges', $collection->collectionid);
return $result->badges;
} else {
if (empty($collection->entityid)) {
return [];
}
// Now we can make requests.
$badges = $this->curl_request('badges', $collection->entityid);
if (count($badges) == 0) {
return [];
}
$badges = $badges[0];
if ($expanded) {
$publicassertions = [];
$context = context_system::instance();
$output = $PAGE->get_renderer('core', 'badges');
foreach ($badges->assertions as $assertion) {
$remoteassertion = $this->get_assertion($assertion);
// Remote badge was fetched nested in the assertion.
$remotebadge = $remoteassertion->badgeclass;
if (!$remotebadge) {
continue;
}
$apidata = badgeclass_exporter::map_external_data($remotebadge, $this->backpackapiversion);
$exporterinstance = new badgeclass_exporter($apidata, ['context' => $context]);
$remotebadge = $exporterinstance->export($output);
$remoteissuer = $remotebadge->issuer;
$apidata = issuer_exporter::map_external_data($remoteissuer, $this->backpackapiversion);
$exporterinstance = new issuer_exporter($apidata, ['context' => $context]);
$remoteissuer = $exporterinstance->export($output);
$badgeclone = clone $remotebadge;
$badgeclone->issuer = $remoteissuer;
$remoteassertion->badge = $badgeclone;
$remotebadge->assertion = $remoteassertion;
$publicassertions[] = $remotebadge;
}
$badges = $publicassertions;
}
return $badges;
}
}
}
}