Files
moodle/badges/classes/backpack_api.php
Sara Arjona c5a878de43 MDL-86176 badges: Adjust badges due to Canvas Credentials changes
This change addresses necessary adjustments to how OBV2.0 badges are
sent to backpacks, specifically to ensure compatibility and correct
functionality following recent updates to Canvas Credentials' conditions.

It ensures our badge integration continues to operate smoothly despite
these external changes.
2025-08-06 15:08:00 +02:00

745 lines
31 KiB
PHP

<?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 <[email protected]>
*/
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 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();
}
/**
* List all errors occurred during the requests to the backpack.
*
* @return array The list of errors.
*/
public function get_errors(): array {
$errors = [];
foreach ($this->mappings as $mapping) {
$errors = array_merge($errors, $mapping->get_errors());
}
return $errors;
}
/**
* 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;
}
}
}
}