Adobe Connect Bridge Marketplace edition — initial release
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* HTTP client for MeetLet registration API.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Talks to MeetLet /api/v1 licensing endpoints (free-tier registration).
|
||||
*/
|
||||
class api_client {
|
||||
/** @var string */
|
||||
protected $baseurl;
|
||||
|
||||
public function __construct(?string $baseurl = null) {
|
||||
$url = $baseurl ?? manager::api_base_url();
|
||||
$this->baseurl = rtrim(trim($url), '/');
|
||||
}
|
||||
|
||||
public function activate(array $body): array {
|
||||
return $this->request('/api/v1/activations', $body);
|
||||
}
|
||||
|
||||
public function validate(array $body): array {
|
||||
return $this->request('/api/v1/validations', $body);
|
||||
}
|
||||
|
||||
public function refresh(array $body): array {
|
||||
return $this->request('/api/v1/authorizations/refresh', $body);
|
||||
}
|
||||
|
||||
public function deactivate(string $installationid, array $body): array {
|
||||
$path = '/api/v1/activations/' . rawurlencode($installationid) . '/deactivate';
|
||||
return $this->request($path, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array data from API envelope
|
||||
*/
|
||||
protected function request(string $path, array $body): array {
|
||||
global $CFG;
|
||||
require_once($CFG->libdir . '/filelib.php');
|
||||
|
||||
$url = $this->baseurl . $path;
|
||||
$json = json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new registration_exception(error_mapper::LOCAL_NETWORK_ERROR, 'Could not encode request JSON');
|
||||
}
|
||||
|
||||
$curl = new \curl(['ignoresecurity' => true]);
|
||||
$raw = $curl->post($url, $json, [
|
||||
'CURLOPT_HTTPHEADER' => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
],
|
||||
'CURLOPT_TIMEOUT' => 30,
|
||||
]);
|
||||
|
||||
$info = $curl->get_info();
|
||||
$httpcode = (int)($info['http_code'] ?? 0);
|
||||
|
||||
if ($curl->get_errno()) {
|
||||
throw new registration_exception(
|
||||
error_mapper::LOCAL_NETWORK_ERROR,
|
||||
$curl->error . ' (url=' . $url . ')'
|
||||
);
|
||||
}
|
||||
|
||||
$raw = is_string($raw) ? trim($raw) : '';
|
||||
if ($raw === '') {
|
||||
throw new registration_exception(
|
||||
error_mapper::LOCAL_NETWORK_ERROR,
|
||||
'Empty response HTTP ' . $httpcode . ' from ' . $url
|
||||
);
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$snippet = \core_text::substr(preg_replace('/\s+/', ' ', $raw), 0, 180);
|
||||
throw new registration_exception(
|
||||
error_mapper::LOCAL_NETWORK_ERROR,
|
||||
'Invalid JSON (HTTP ' . $httpcode . '): ' . $snippet
|
||||
);
|
||||
}
|
||||
|
||||
$requestid = isset($decoded['request_id']) ? (string)$decoded['request_id'] : null;
|
||||
|
||||
if (empty($decoded['success'])) {
|
||||
$err = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
|
||||
$code = (string)($err['code'] ?? 'UNKNOWN');
|
||||
$message = (string)($err['message'] ?? 'Registration request failed');
|
||||
$details = isset($err['details']) && is_array($err['details']) ? $err['details'] : null;
|
||||
throw new registration_exception($code, $message, $details, $requestid);
|
||||
}
|
||||
|
||||
$data = $decoded['data'] ?? [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
if ($requestid !== null && $requestid !== '') {
|
||||
$data['_request_id'] = $requestid;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* Ed25519 + canonical JSON helpers (compatible with @meetlet/crypto).
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Crypto primitives for MeetLet registration protocol.
|
||||
*/
|
||||
class crypto {
|
||||
/** MeetLet platform public key (SPKI PEM) — verify runtime authorizations only. */
|
||||
public const PLATFORM_PUBLIC_KEY_PEM = <<<'PEM'
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAYyZTlJQzdGHyZ2aAZ45kJIOx4Rq1Opyy6OPpgRFoEtU=
|
||||
-----END PUBLIC KEY-----
|
||||
PEM;
|
||||
|
||||
/**
|
||||
* Canonical JSON: sorted object keys, no whitespace.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public static function canonicalize($value): string {
|
||||
return json_encode(self::sort_value($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return mixed
|
||||
*/
|
||||
protected static function sort_value($value) {
|
||||
if ($value instanceof \stdClass) {
|
||||
$asarray = get_object_vars($value);
|
||||
if ($asarray === []) {
|
||||
return new \stdClass();
|
||||
}
|
||||
ksort($asarray);
|
||||
$out = new \stdClass();
|
||||
foreach ($asarray as $k => $v) {
|
||||
$out->{$k} = self::sort_value($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
if (self::is_list($value)) {
|
||||
return array_map([self::class, 'sort_value'], $value);
|
||||
}
|
||||
ksort($value);
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$out[$k] = self::sort_value($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $arr
|
||||
*/
|
||||
protected static function is_list(array $arr): bool {
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
public static function base64url_encode(string $bin): string {
|
||||
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function base64url_decode(string $b64): string {
|
||||
$pad = 4 - (strlen($b64) % 4);
|
||||
if ($pad < 4) {
|
||||
$b64 .= str_repeat('=', $pad);
|
||||
}
|
||||
$raw = base64_decode(strtr($b64, '-_', '+/'), true);
|
||||
if ($raw === false) {
|
||||
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{private_pem:string,public_pem:string,secret:string,public:string}
|
||||
*/
|
||||
public static function generate_installation_keypair(): array {
|
||||
if (!function_exists('sodium_crypto_sign_keypair')) {
|
||||
throw new \moodle_exception('registration_sodium_required', 'meetletacbmarketplace');
|
||||
}
|
||||
$kp = sodium_crypto_sign_keypair();
|
||||
$secret = sodium_crypto_sign_secretkey($kp);
|
||||
$public = sodium_crypto_sign_publickey($kp);
|
||||
$seed = substr($secret, 0, 32);
|
||||
return [
|
||||
'private_pem' => self::seed_to_pkcs8_pem($seed),
|
||||
'public_pem' => self::raw_public_to_spki_pem($public),
|
||||
'secret' => $secret,
|
||||
'public' => $public,
|
||||
];
|
||||
}
|
||||
|
||||
public static function seed_to_pkcs8_pem(string $seed32): string {
|
||||
$der = hex2bin('302e020100300506032b657004220420') . $seed32;
|
||||
return "-----BEGIN PRIVATE KEY-----\n" .
|
||||
chunk_split(base64_encode($der), 64, "\n") .
|
||||
"-----END PRIVATE KEY-----\n";
|
||||
}
|
||||
|
||||
public static function raw_public_to_spki_pem(string $public32): string {
|
||||
$der = hex2bin('302a300506032b6570032100') . $public32;
|
||||
return "-----BEGIN PUBLIC KEY-----\n" .
|
||||
chunk_split(base64_encode($der), 64, "\n") .
|
||||
"-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
public static function spki_pem_to_raw(string $pem): string {
|
||||
$pem = trim($pem);
|
||||
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
|
||||
$b64 = preg_replace('/\s+/', '', $b64);
|
||||
$der = base64_decode($b64, true);
|
||||
if ($der === false || strlen($der) < 32) {
|
||||
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
|
||||
}
|
||||
return substr($der, -32);
|
||||
}
|
||||
|
||||
public static function pkcs8_pem_to_secret(string $pem): string {
|
||||
$pem = trim($pem);
|
||||
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
|
||||
$b64 = preg_replace('/\s+/', '', $b64);
|
||||
$der = base64_decode($b64, true);
|
||||
if ($der === false || strlen($der) < 48) {
|
||||
throw new \moodle_exception('registration_crypto_error', 'meetletacbmarketplace');
|
||||
}
|
||||
$seed = substr($der, -32);
|
||||
$kp = sodium_crypto_sign_seed_keypair($seed);
|
||||
return sodium_crypto_sign_secretkey($kp);
|
||||
}
|
||||
|
||||
public static function sign(string $privatepem, $payload): string {
|
||||
$secret = self::pkcs8_pem_to_secret($privatepem);
|
||||
$msg = self::canonicalize($payload);
|
||||
$sig = sodium_crypto_sign_detached($msg, $secret);
|
||||
return self::base64url_encode($sig);
|
||||
}
|
||||
|
||||
public static function verify(string $publicpem, $payload, string $signatureb64url): bool {
|
||||
try {
|
||||
$public = self::spki_pem_to_raw($publicpem);
|
||||
$msg = self::canonicalize($payload);
|
||||
$sig = self::base64url_decode($signatureb64url);
|
||||
return sodium_crypto_sign_verify_detached($sig, $msg, $public);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function random_nonce(): string {
|
||||
return self::base64url_encode(random_bytes(16));
|
||||
}
|
||||
|
||||
public static function random_installation_id(): string {
|
||||
return 'mdl-' . bin2hex(random_bytes(16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* Map registration API / local error codes → product status and UX behaviour.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Branch on stable error.code only (never on message text).
|
||||
*/
|
||||
class error_mapper {
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_GRACE = 'grace';
|
||||
public const STATUS_OFFLINE_GRACE_EXPIRED = 'offline_grace_expired';
|
||||
public const STATUS_INVALID_KEY = 'invalid_key';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
public const STATUS_DOMAIN_MISMATCH = 'domain_mismatch';
|
||||
public const STATUS_ACTIVATION_LIMIT = 'activation_limit';
|
||||
public const STATUS_NETWORK_ERROR = 'network_error';
|
||||
public const STATUS_MISCONFIGURED = 'misconfigured';
|
||||
public const STATUS_UNKNOWN = 'unknown';
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
|
||||
public const LOCAL_NETWORK_ERROR = 'NETWORK_ERROR';
|
||||
public const LOCAL_INVALID_AUTHORIZATION_SIGNATURE = 'INVALID_AUTHORIZATION_SIGNATURE';
|
||||
|
||||
/**
|
||||
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array}
|
||||
*/
|
||||
public static function map(string $code, ?array $details = null, bool $hasgraceauth = false): array {
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
$code = 'UNKNOWN';
|
||||
}
|
||||
|
||||
switch ($code) {
|
||||
case 'INVALID_LICENSE':
|
||||
case 'INSTALLATION_NOT_FOUND':
|
||||
return self::result($code, self::STATUS_INVALID_KEY, true, 'registration_err_invalid_key', $details);
|
||||
|
||||
case 'LICENSE_EXPIRED':
|
||||
return self::result($code, self::STATUS_EXPIRED, true, 'registration_err_expired', $details);
|
||||
|
||||
case 'LICENSE_REVOKED':
|
||||
case 'INSTALLATION_REVOKED':
|
||||
return self::result($code, self::STATUS_REVOKED, true, 'registration_err_revoked', $details);
|
||||
|
||||
case 'LICENSE_SUSPENDED':
|
||||
case 'CUSTOMER_SUSPENDED':
|
||||
case 'PRODUCT_SUSPENDED':
|
||||
return self::result($code, self::STATUS_SUSPENDED, true, 'registration_err_suspended', $details);
|
||||
|
||||
case 'PRODUCT_NOT_FOUND':
|
||||
case 'PRODUCT_NOT_ENTITLED':
|
||||
case 'PRODUCT_MISMATCH':
|
||||
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_product_mismatch', $details);
|
||||
|
||||
case 'DOMAIN_NOT_ALLOWED':
|
||||
return self::result($code, self::STATUS_DOMAIN_MISMATCH, true, 'registration_err_domain', $details);
|
||||
|
||||
case 'ACTIVATION_LIMIT_REACHED':
|
||||
case 'ACTIVATION_LIMIT':
|
||||
return self::result($code, self::STATUS_ACTIVATION_LIMIT, true, 'registration_err_activation_limit', $details);
|
||||
|
||||
case 'INVALID_REQUEST_SIGNATURE':
|
||||
case 'INVALID_SIGNATURE':
|
||||
case 'TIMESTAMP_INVALID':
|
||||
case 'VALIDATION_ERROR':
|
||||
case 'INVALID_REQUEST':
|
||||
return self::result($code, self::STATUS_MISCONFIGURED, false, 'registration_err_misconfigured', $details);
|
||||
|
||||
case self::LOCAL_INVALID_AUTHORIZATION_SIGNATURE:
|
||||
return self::result($code, self::STATUS_MISCONFIGURED, true, 'registration_err_bad_signature', $details);
|
||||
|
||||
case 'REPLAY_DETECTED':
|
||||
case 'RATE_LIMITED':
|
||||
case 'TEMPORARY_LICENSE_SERVICE_UNAVAILABLE':
|
||||
case self::LOCAL_NETWORK_ERROR:
|
||||
if ($hasgraceauth) {
|
||||
return self::result($code, self::STATUS_ACTIVE, false, '', $details);
|
||||
}
|
||||
return self::result($code, self::STATUS_NETWORK_ERROR, false, 'registration_err_network', $details);
|
||||
|
||||
default:
|
||||
return self::result($code, self::STATUS_UNKNOWN, false, 'registration_err_unknown', $details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array,request_id:?string,message:string}
|
||||
*/
|
||||
public static function from_exception(registration_exception $e, bool $hasgraceauth = false): array {
|
||||
$mapped = self::map($e->get_error_code(), $e->get_details(), $hasgraceauth);
|
||||
$mapped['request_id'] = $e->get_request_id();
|
||||
$mapped['message'] = self::user_message($mapped['message_key'], $e->get_api_message());
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
public static function user_message(string $messagekey, string $fallback = ''): string {
|
||||
if ($messagekey !== '' && get_string_manager()->string_exists($messagekey, 'meetletacbmarketplace')) {
|
||||
return get_string($messagekey, 'meetletacbmarketplace');
|
||||
}
|
||||
return $fallback !== '' ? $fallback : $messagekey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|null $details
|
||||
* @return array{code:string,status:string,clear_auth:bool,message_key:string,details:?array}
|
||||
*/
|
||||
protected static function result(
|
||||
string $code,
|
||||
string $status,
|
||||
bool $clearauth,
|
||||
string $messagekey,
|
||||
?array $details
|
||||
): array {
|
||||
return [
|
||||
'code' => $code,
|
||||
'status' => $status,
|
||||
'clear_auth' => $clearauth,
|
||||
'message_key' => $messagekey,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* Free online activation for MeetLet Marketplace edition.
|
||||
*
|
||||
* Hard-locks all plugin features until the administrator completes voluntary
|
||||
* online activation. After activation, every Marketplace feature unlocks.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Activate / validate / deactivate free-tier site registration and gate features.
|
||||
*/
|
||||
class manager {
|
||||
/** Free product slug on the MeetLet license platform. */
|
||||
public const PRODUCT_SLUG = 'free-adobe-connect-bridge';
|
||||
|
||||
public const CONFIG_API_URL = 'registration_api_url';
|
||||
|
||||
public const DEFAULT_API_URL = 'https://license.webatsign.com';
|
||||
|
||||
public static function api_base_url(): string {
|
||||
$url = trim((string)get_config('meetletacbmarketplace', self::CONFIG_API_URL));
|
||||
$url = rtrim($url, '/');
|
||||
return $url !== '' ? $url : self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site administrator has completed free-tier registration.
|
||||
*/
|
||||
public static function is_registered(): bool {
|
||||
$row = store::get_record();
|
||||
if (!$row || ($row->status ?? '') !== error_mapper::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
return self::get_verified_authorization() !== null;
|
||||
}
|
||||
|
||||
public static function is_usable(): bool {
|
||||
return self::is_registered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moodle tabbed admin settings URL for a specific tab.
|
||||
*/
|
||||
public static function settings_tab_url(string $tab): \moodle_url {
|
||||
return new \moodle_url('/admin/settings.php', [
|
||||
'section' => 'modsettingmeetletacbmarketplace',
|
||||
], $tab);
|
||||
}
|
||||
|
||||
public static function settings_url(): \moodle_url {
|
||||
return self::settings_tab_url('meetletacbmarketplace_registration');
|
||||
}
|
||||
|
||||
public static function privacy_tab_url(): \moodle_url {
|
||||
return self::settings_tab_url('meetletacbmarketplace_privacy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw registration_required with continue link to Online activation tab.
|
||||
*/
|
||||
public static function require_registered(): void {
|
||||
if (self::is_registered()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (store::get_authorization() !== null) {
|
||||
self::recheck(true);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through.
|
||||
}
|
||||
if (self::is_registered()) {
|
||||
return;
|
||||
}
|
||||
throw new \moodle_exception('registration_required', 'meetletacbmarketplace', self::settings_url());
|
||||
}
|
||||
|
||||
/** @deprecated Use require_registered() */
|
||||
public static function require_active(): void {
|
||||
self::require_registered();
|
||||
}
|
||||
|
||||
public static function admin_gate_notice_html(): string {
|
||||
if (self::is_registered()) {
|
||||
return '';
|
||||
}
|
||||
$url = self::settings_url();
|
||||
return \html_writer::div(
|
||||
\html_writer::tag('strong', get_string('registration_gate_title', 'meetletacbmarketplace')) .
|
||||
' ' . get_string('registration_gate_body', 'meetletacbmarketplace') . ' ' .
|
||||
\html_writer::link($url, get_string('registration_gate_link', 'meetletacbmarketplace')),
|
||||
'alert alert-warning meetlet-registration-gate mb-3'
|
||||
);
|
||||
}
|
||||
|
||||
public static function admin_hero_banner_html(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function activate_free(): array {
|
||||
return self::activate();
|
||||
}
|
||||
|
||||
public static function activate(): array {
|
||||
if (self::is_registered()) {
|
||||
throw new \moodle_exception('registration_already_active', 'meetletacbmarketplace');
|
||||
}
|
||||
$row = store::get_record();
|
||||
$stale = $row && in_array($row->status ?? '', [
|
||||
error_mapper::STATUS_REVOKED,
|
||||
error_mapper::STATUS_INVALID_KEY,
|
||||
error_mapper::STATUS_EXPIRED,
|
||||
error_mapper::STATUS_OFFLINE_GRACE_EXPIRED,
|
||||
error_mapper::STATUS_SUSPENDED,
|
||||
error_mapper::STATUS_DOMAIN_MISMATCH,
|
||||
error_mapper::STATUS_ACTIVATION_LIMIT,
|
||||
], true);
|
||||
if ($stale) {
|
||||
store::rotate_identity();
|
||||
}
|
||||
return self::activate_with_current_identity(true);
|
||||
}
|
||||
|
||||
public static function validate(bool $force = true): array {
|
||||
return self::validate_or_refresh($force);
|
||||
}
|
||||
|
||||
public static function recheck(bool $force = true): array {
|
||||
return self::validate_or_refresh($force);
|
||||
}
|
||||
|
||||
public static function deactivate(): void {
|
||||
$identity = store::ensure_identity();
|
||||
$signed = self::signed_request($identity, ['action' => 'deactivate']);
|
||||
try {
|
||||
(new api_client())->deactivate($identity->installation_id, $signed);
|
||||
} catch (\Throwable $e) {
|
||||
// Still clear local state.
|
||||
}
|
||||
store::update([
|
||||
'auth_json' => null,
|
||||
'license_uuid' => null,
|
||||
'status' => 'inactive',
|
||||
'last_error' => null,
|
||||
]);
|
||||
store::rotate_identity();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function status_summary(): array {
|
||||
self::enforce_local_expiry();
|
||||
$row = store::get_record();
|
||||
$storedstatus = (string)($row->status ?? 'inactive');
|
||||
$hardblock = in_array($storedstatus, [
|
||||
error_mapper::STATUS_SUSPENDED,
|
||||
error_mapper::STATUS_REVOKED,
|
||||
error_mapper::STATUS_EXPIRED,
|
||||
error_mapper::STATUS_INVALID_KEY,
|
||||
error_mapper::STATUS_DOMAIN_MISMATCH,
|
||||
error_mapper::STATUS_ACTIVATION_LIMIT,
|
||||
], true);
|
||||
$lasterror = store::get_last_error();
|
||||
$auth = $hardblock ? null : self::get_verified_authorization();
|
||||
$payload = is_array($auth['payload'] ?? null) ? $auth['payload'] : null;
|
||||
$status = $storedstatus;
|
||||
if ($payload !== null && $auth !== null) {
|
||||
if ($status !== error_mapper::STATUS_ACTIVE) {
|
||||
store::update([
|
||||
'status' => error_mapper::STATUS_ACTIVE,
|
||||
'last_error' => null,
|
||||
]);
|
||||
}
|
||||
$status = error_mapper::STATUS_ACTIVE;
|
||||
} else if ($auth === null && $status === error_mapper::STATUS_ACTIVE) {
|
||||
$status = error_mapper::STATUS_INACTIVE;
|
||||
}
|
||||
$active = $auth !== null && !$hardblock;
|
||||
|
||||
return [
|
||||
'active' => $active,
|
||||
'registered' => $active,
|
||||
'status' => $status,
|
||||
'status_label' => self::status_label($active ? error_mapper::STATUS_ACTIVE : $status),
|
||||
'product' => $active ? self::PRODUCT_SLUG : '',
|
||||
'product_label' => $active ? get_string('registration_product_free', 'meetletacbmarketplace') : '',
|
||||
'can_deactivate' => self::can_deactivate($row),
|
||||
'auth_expires_at' => self::parse_unix_timestamp($payload['expires_at'] ?? null),
|
||||
'features' => is_array($payload['features'] ?? null) ? $payload['features'] : [],
|
||||
'installation_id' => $row->installation_id ?? '',
|
||||
'domain' => self::site_domain(),
|
||||
'api_url' => self::api_base_url(),
|
||||
'last_validated' => !empty($row->last_validated) ? (int)$row->last_validated : null,
|
||||
'error' => $active ? null : self::status_error_message($status, $lasterror),
|
||||
'last_error' => $lasterror,
|
||||
'license_uuid' => $row->license_uuid ?? null,
|
||||
'site_company' => self::site_company(),
|
||||
'site_admin_email' => self::site_admin_email(),
|
||||
'moodle_release' => self::moodle_release(),
|
||||
'plugin_release' => self::plugin_release(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function heartbeat(): void {
|
||||
$row = store::get_record();
|
||||
if (!$row || empty($row->auth_json)) {
|
||||
mtrace('MeetLet registration: not registered — skip heartbeat.');
|
||||
return;
|
||||
}
|
||||
if (self::enforce_local_expiry()) {
|
||||
mtrace('MeetLet registration: authorization expired — cleared auth.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
self::validate_or_refresh(true);
|
||||
mtrace('MeetLet registration: heartbeat OK.');
|
||||
} catch (\Throwable $e) {
|
||||
mtrace('MeetLet registration heartbeat failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function site_domain(): string {
|
||||
global $CFG;
|
||||
$host = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
|
||||
return self::normalize_domain(is_string($host) ? $host : 'localhost');
|
||||
}
|
||||
|
||||
public static function normalize_domain(string $input): string {
|
||||
$host = trim(strtolower($input));
|
||||
if ($host === '') {
|
||||
return 'localhost';
|
||||
}
|
||||
if (preg_match('~(://|/|\?|#)~', $host)) {
|
||||
$parsed = parse_url((strpos($host, '://') === false ? 'https://' : '') . $host, PHP_URL_HOST);
|
||||
$host = is_string($parsed) ? strtolower($parsed) : $host;
|
||||
}
|
||||
$host = rtrim($host, '.');
|
||||
if (strpos($host, ':') !== false) {
|
||||
$host = explode(':', $host, 2)[0];
|
||||
}
|
||||
if (str_starts_with($host, 'www.')) {
|
||||
$host = substr($host, 4);
|
||||
}
|
||||
if ($host !== '' && function_exists('idn_to_ascii') && preg_match('/[^\x20-\x7f]/', $host)) {
|
||||
$ascii = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
|
||||
if (is_string($ascii) && $ascii !== '') {
|
||||
$host = strtolower($ascii);
|
||||
}
|
||||
}
|
||||
return $host !== '' ? $host : 'localhost';
|
||||
}
|
||||
|
||||
public static function status_label(string $status): string {
|
||||
$key = 'registration_status_' . $status;
|
||||
if (get_string_manager()->string_exists($key, 'meetletacbmarketplace')) {
|
||||
return get_string($key, 'meetletacbmarketplace');
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
public static function parse_unix_timestamp($value): ?int {
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
$n = (int) $value;
|
||||
} else if (is_string($value) && is_numeric($value)) {
|
||||
$n = (int) $value;
|
||||
} else if (is_string($value)) {
|
||||
$parsed = strtotime($value);
|
||||
return ($parsed !== false && $parsed > 0) ? $parsed : null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if ($n > 9999999999) {
|
||||
$n = (int) floor($n / 1000);
|
||||
}
|
||||
return $n > 0 ? $n : null;
|
||||
}
|
||||
|
||||
public static function enforce_local_expiry(): bool {
|
||||
$auth = store::get_authorization();
|
||||
if ($auth === null) {
|
||||
return false;
|
||||
}
|
||||
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
|
||||
return false;
|
||||
}
|
||||
$expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null);
|
||||
if ($expires !== null && time() >= $expires) {
|
||||
self::invalidate_local([
|
||||
'code' => 'LICENSE_EXPIRED',
|
||||
'message' => get_string('registration_err_expired', 'meetletacbmarketplace'),
|
||||
'at' => time(),
|
||||
], error_mapper::STATUS_EXPIRED);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payload:array,signature:string}|null
|
||||
*/
|
||||
public static function get_verified_authorization(): ?array {
|
||||
$row = store::get_record();
|
||||
if ($row && in_array($row->status ?? '', [
|
||||
error_mapper::STATUS_SUSPENDED,
|
||||
error_mapper::STATUS_REVOKED,
|
||||
error_mapper::STATUS_EXPIRED,
|
||||
error_mapper::STATUS_INVALID_KEY,
|
||||
error_mapper::STATUS_DOMAIN_MISMATCH,
|
||||
error_mapper::STATUS_ACTIVATION_LIMIT,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
$auth = store::get_authorization();
|
||||
if ($auth === null) {
|
||||
return null;
|
||||
}
|
||||
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
|
||||
return null;
|
||||
}
|
||||
$expires = self::parse_unix_timestamp($auth['payload']['expires_at'] ?? null);
|
||||
if ($expires !== null && time() >= $expires) {
|
||||
return null;
|
||||
}
|
||||
return $auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \stdClass|null $row
|
||||
*/
|
||||
public static function can_deactivate(?\stdClass $row = null): bool {
|
||||
$row = $row ?? store::get_record();
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($row->auth_json)) {
|
||||
return true;
|
||||
}
|
||||
return in_array($row->status ?? '', [
|
||||
error_mapper::STATUS_ACTIVE,
|
||||
error_mapper::STATUS_GRACE,
|
||||
error_mapper::STATUS_EXPIRED,
|
||||
error_mapper::STATUS_OFFLINE_GRACE_EXPIRED,
|
||||
error_mapper::STATUS_SUSPENDED,
|
||||
error_mapper::STATUS_REVOKED,
|
||||
error_mapper::STATUS_INVALID_KEY,
|
||||
error_mapper::STATUS_DOMAIN_MISMATCH,
|
||||
error_mapper::STATUS_ACTIVATION_LIMIT,
|
||||
], true);
|
||||
}
|
||||
|
||||
protected static function validate_or_refresh(bool $force = false): array {
|
||||
self::enforce_local_expiry();
|
||||
$identity = store::ensure_identity();
|
||||
$auth = store::get_authorization();
|
||||
$now = time();
|
||||
if (!$force && $auth && !empty($auth['payload']['expires_at'])) {
|
||||
$expires = self::parse_unix_timestamp($auth['payload']['expires_at']);
|
||||
if ($expires !== null && $now < ($expires - 3600) && self::get_verified_authorization() !== null) {
|
||||
return $auth;
|
||||
}
|
||||
}
|
||||
|
||||
$api = new api_client();
|
||||
if (!$force && $auth) {
|
||||
try {
|
||||
$signedrefresh = self::signed_request($identity, new \stdClass());
|
||||
$data = $api->refresh($signedrefresh);
|
||||
return self::persist_authorization($data, $identity);
|
||||
} catch (\Throwable $e) {
|
||||
$mapped = self::apply_mapped_error($e, false);
|
||||
if (!empty($mapped['clear_auth'])) {
|
||||
throw registration_exception::from_throwable($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$body = [
|
||||
'product' => self::PRODUCT_SLUG,
|
||||
'domain' => self::site_domain(),
|
||||
'reported_host' => self::site_domain(),
|
||||
'current_version' => self::plugin_release(),
|
||||
];
|
||||
$signed = self::signed_request($identity, $body);
|
||||
try {
|
||||
$data = $api->validate($signed);
|
||||
} catch (\Throwable $e) {
|
||||
self::apply_mapped_error($e);
|
||||
if (self::get_verified_authorization() !== null) {
|
||||
store::update(['last_validated' => $now]);
|
||||
$cached = self::get_verified_authorization();
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
throw registration_exception::from_throwable($e);
|
||||
}
|
||||
return self::persist_authorization($data, $identity);
|
||||
}
|
||||
|
||||
protected static function activate_with_current_identity(bool $allowrotate): array {
|
||||
$identity = store::ensure_identity();
|
||||
$body = [
|
||||
'product' => self::PRODUCT_SLUG,
|
||||
'installation_id' => $identity->installation_id,
|
||||
'installation_public_key' => $identity->public_key_pem,
|
||||
'domain' => self::site_domain(),
|
||||
'environment' => self::environment(),
|
||||
'platform' => 'moodle',
|
||||
'platform_version' => self::moodle_release(),
|
||||
'runtime' => 'php',
|
||||
'runtime_version' => PHP_VERSION,
|
||||
'metadata' => [
|
||||
'plugin' => 'mod_meetletacbmarketplace',
|
||||
'plugin_release' => self::plugin_release(),
|
||||
],
|
||||
];
|
||||
$company = self::site_company();
|
||||
$email = self::site_admin_email();
|
||||
if ($company !== '') {
|
||||
$body['metadata']['company'] = $company;
|
||||
}
|
||||
if ($email !== '') {
|
||||
$body['metadata']['email'] = $email;
|
||||
$body['metadata']['admin_email'] = $email;
|
||||
}
|
||||
try {
|
||||
$data = (new api_client())->activate($body);
|
||||
} catch (\Throwable $e) {
|
||||
$ex = registration_exception::from_throwable($e);
|
||||
$code = $ex->get_error_code();
|
||||
if ($allowrotate && in_array($code, ['INVALID_LICENSE', 'INSTALLATION_REVOKED'], true)) {
|
||||
store::rotate_identity();
|
||||
return self::activate_with_current_identity(false);
|
||||
}
|
||||
self::apply_mapped_error($ex);
|
||||
throw $ex;
|
||||
}
|
||||
return self::persist_authorization($data, store::ensure_identity());
|
||||
}
|
||||
|
||||
protected static function persist_authorization(array $data, \stdClass $identity): array {
|
||||
$auth = $data['authorization'] ?? null;
|
||||
if (!is_array($auth) || empty($auth['payload']) || empty($auth['signature'])) {
|
||||
$ex = new registration_exception('INVALID_REQUEST', 'Missing authorization in API response');
|
||||
self::apply_mapped_error($ex);
|
||||
throw $ex;
|
||||
}
|
||||
if (!crypto::verify(crypto::PLATFORM_PUBLIC_KEY_PEM, $auth['payload'], $auth['signature'])) {
|
||||
$ex = new registration_exception(
|
||||
error_mapper::LOCAL_INVALID_AUTHORIZATION_SIGNATURE,
|
||||
'Authorization signature verification failed'
|
||||
);
|
||||
self::apply_mapped_error($ex);
|
||||
throw $ex;
|
||||
}
|
||||
$licenseuuid = $data['license_id'] ?? ($auth['payload']['license_id'] ?? null);
|
||||
store::update([
|
||||
'auth_json' => json_encode($auth, JSON_UNESCAPED_SLASHES),
|
||||
'license_uuid' => $licenseuuid,
|
||||
'status' => error_mapper::STATUS_ACTIVE,
|
||||
'last_error' => null,
|
||||
'last_validated' => time(),
|
||||
]);
|
||||
return $auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|string|null $error
|
||||
*/
|
||||
public static function invalidate_local($error, string $status = 'inactive'): void {
|
||||
$fields = [
|
||||
'auth_json' => null,
|
||||
'status' => $status,
|
||||
'last_validated' => time(),
|
||||
];
|
||||
if (is_array($error)) {
|
||||
$fields['last_error'] = json_encode([
|
||||
'code' => (string)($error['code'] ?? 'UNKNOWN'),
|
||||
'message' => (string)($error['message'] ?? ''),
|
||||
'details' => $error['details'] ?? null,
|
||||
'request_id' => $error['request_id'] ?? null,
|
||||
'at' => (int)($error['at'] ?? time()),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
$fields['last_error'] = $error;
|
||||
}
|
||||
store::update($fields);
|
||||
}
|
||||
|
||||
protected static function apply_mapped_error(\Throwable $e, bool $persist = true): array {
|
||||
$hasgrace = self::get_verified_authorization() !== null;
|
||||
$ex = registration_exception::from_throwable($e);
|
||||
$mapped = error_mapper::from_exception($ex, $hasgrace);
|
||||
if (!$persist) {
|
||||
return $mapped;
|
||||
}
|
||||
$lasterror = $ex->to_last_error();
|
||||
$lasterror['message'] = $mapped['message'] ?? $lasterror['message'];
|
||||
if (!empty($mapped['request_id'])) {
|
||||
$lasterror['request_id'] = $mapped['request_id'];
|
||||
}
|
||||
if (!empty($mapped['clear_auth'])) {
|
||||
self::invalidate_local($lasterror, $mapped['status']);
|
||||
return $mapped;
|
||||
}
|
||||
store::update([
|
||||
'status' => $mapped['status'],
|
||||
'last_error' => json_encode($lasterror, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'last_validated' => time(),
|
||||
]);
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
protected static function signed_request(\stdClass $identity, $body): array {
|
||||
$timestamp = time();
|
||||
$nonce = crypto::random_nonce();
|
||||
$bodyforcanon = (is_array($body) && $body === []) ? new \stdClass() : $body;
|
||||
$parts = [
|
||||
'installation_id' => $identity->installation_id,
|
||||
'timestamp' => $timestamp,
|
||||
'nonce' => $nonce,
|
||||
'body' => $bodyforcanon,
|
||||
];
|
||||
$signature = crypto::sign($identity->private_key_pem, $parts);
|
||||
$out = [
|
||||
'installation_id' => $identity->installation_id,
|
||||
'timestamp' => $timestamp,
|
||||
'nonce' => $nonce,
|
||||
'signature' => $signature,
|
||||
];
|
||||
if (is_array($body)) {
|
||||
foreach ($body as $k => $v) {
|
||||
$out[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|null $lasterror
|
||||
*/
|
||||
protected static function status_error_message(string $status, ?array $lasterror): ?string {
|
||||
if ($status === error_mapper::STATUS_EXPIRED) {
|
||||
return get_string('registration_err_expired', 'meetletacbmarketplace');
|
||||
}
|
||||
return $lasterror['message'] ?? null;
|
||||
}
|
||||
|
||||
protected static function site_company(): string {
|
||||
global $SITE;
|
||||
$name = trim(strip_tags((string)($SITE->fullname ?? '')));
|
||||
if ($name === '') {
|
||||
$name = trim(strip_tags((string)($SITE->shortname ?? '')));
|
||||
}
|
||||
return $name !== '' ? $name : self::site_domain();
|
||||
}
|
||||
|
||||
protected static function site_admin_email(): string {
|
||||
global $CFG, $USER;
|
||||
$candidates = [trim((string)($CFG->supportemail ?? ''))];
|
||||
$admin = get_admin();
|
||||
if ($admin && !empty($admin->email)) {
|
||||
$candidates[] = trim((string)$admin->email);
|
||||
}
|
||||
if (!empty($USER->email)) {
|
||||
$candidates[] = trim((string)$USER->email);
|
||||
}
|
||||
foreach ($candidates as $email) {
|
||||
if ($email !== '' && validate_email($email)) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
protected static function environment(): string {
|
||||
global $CFG;
|
||||
if (!empty($CFG->debugdeveloper)) {
|
||||
return 'development';
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
|
||||
protected static function moodle_release(): string {
|
||||
global $CFG;
|
||||
return isset($CFG->release) ? (string)$CFG->release : '';
|
||||
}
|
||||
|
||||
public static function plugin_release(): string {
|
||||
global $CFG;
|
||||
$plugin = new \stdClass();
|
||||
$versionfile = $CFG->dirroot . '/mod/meetletacbmarketplace/version.php';
|
||||
if (is_readable($versionfile)) {
|
||||
include($versionfile);
|
||||
}
|
||||
if (!empty($plugin->release)) {
|
||||
return (string)$plugin->release;
|
||||
}
|
||||
$ver = get_config('meetletacbmarketplace', 'version');
|
||||
return $ver !== false ? (string)$ver : '0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* Structured registration API / local client exception.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Carries stable error.code from the platform envelope (or a local SDK code).
|
||||
*/
|
||||
class registration_exception extends \moodle_exception {
|
||||
/** @var string */
|
||||
protected $apicode;
|
||||
|
||||
/** @var string */
|
||||
protected $apimessage;
|
||||
|
||||
/** @var array|null */
|
||||
protected $details;
|
||||
|
||||
/** @var string|null */
|
||||
protected $requestid;
|
||||
|
||||
public function __construct(string $code, string $message = '', ?array $details = null, ?string $requestid = null) {
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
$code = 'UNKNOWN';
|
||||
}
|
||||
$this->apicode = $code;
|
||||
$this->apimessage = trim($message) !== '' ? trim($message) : $code;
|
||||
$this->details = is_array($details) ? $details : null;
|
||||
$this->requestid = $requestid !== null && $requestid !== '' ? $requestid : null;
|
||||
parent::__construct(
|
||||
'registration_api_error',
|
||||
'meetletacbmarketplace',
|
||||
'',
|
||||
$this->apicode . ': ' . $this->apimessage
|
||||
);
|
||||
}
|
||||
|
||||
public function get_error_code(): string {
|
||||
return $this->apicode;
|
||||
}
|
||||
|
||||
public function get_api_message(): string {
|
||||
return $this->apimessage;
|
||||
}
|
||||
|
||||
public function get_details(): ?array {
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
public function get_request_id(): ?string {
|
||||
return $this->requestid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{code:string,message:string,details:?array,request_id:?string,at:int}
|
||||
*/
|
||||
public function to_last_error(): array {
|
||||
return [
|
||||
'code' => $this->apicode,
|
||||
'message' => $this->apimessage,
|
||||
'details' => $this->details,
|
||||
'request_id' => $this->requestid,
|
||||
'at' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function from_throwable(\Throwable $e): self {
|
||||
if ($e instanceof self) {
|
||||
return $e;
|
||||
}
|
||||
$msg = $e->getMessage();
|
||||
if (preg_match('/^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$/s', $msg, $m)) {
|
||||
return new self($m[1], $m[2]);
|
||||
}
|
||||
return new self('UNKNOWN', $msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* Persist MeetLet installation identity + cached runtime authorization.
|
||||
*
|
||||
* @package mod_meetletacbmarketplace
|
||||
* @copyright 2026 MeetLet
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace mod_meetletacbmarketplace\registration;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Single-row registration store in meetlet_marketplace_license.
|
||||
*/
|
||||
class store {
|
||||
public const TABLE = 'meetlet_marketplace_license';
|
||||
|
||||
/**
|
||||
* @return \stdClass|null
|
||||
*/
|
||||
public static function get_record() {
|
||||
global $DB;
|
||||
if (!$DB->get_manager()->table_exists(self::TABLE)) {
|
||||
return null;
|
||||
}
|
||||
$rows = $DB->get_records(self::TABLE, null, 'id ASC', '*', 0, 1);
|
||||
return $rows ? reset($rows) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \stdClass
|
||||
*/
|
||||
public static function ensure_identity(): \stdClass {
|
||||
global $DB;
|
||||
$row = self::get_record();
|
||||
if ($row && !empty($row->installation_id) && !empty($row->private_key_pem)) {
|
||||
return $row;
|
||||
}
|
||||
$kp = crypto::generate_installation_keypair();
|
||||
$now = time();
|
||||
if ($row) {
|
||||
$row->installation_id = $row->installation_id ?: crypto::random_installation_id();
|
||||
$row->private_key_pem = $kp['private_pem'];
|
||||
$row->public_key_pem = $kp['public_pem'];
|
||||
$row->timemodified = $now;
|
||||
$DB->update_record(self::TABLE, $row);
|
||||
return $row;
|
||||
}
|
||||
$rec = (object)[
|
||||
'installation_id' => crypto::random_installation_id(),
|
||||
'private_key_pem' => $kp['private_pem'],
|
||||
'public_key_pem' => $kp['public_pem'],
|
||||
'auth_json' => null,
|
||||
'license_uuid' => null,
|
||||
'status' => 'inactive',
|
||||
'last_error' => null,
|
||||
'last_validated' => 0,
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
];
|
||||
$rec->id = $DB->insert_record(self::TABLE, $rec);
|
||||
return $rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $fields
|
||||
*/
|
||||
public static function update(array $fields): \stdClass {
|
||||
global $DB;
|
||||
$row = self::ensure_identity();
|
||||
foreach ($fields as $k => $v) {
|
||||
$row->{$k} = $v;
|
||||
}
|
||||
$row->timemodified = time();
|
||||
$DB->update_record(self::TABLE, $row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payload:array,signature:string}|null
|
||||
*/
|
||||
public static function get_authorization(): ?array {
|
||||
$row = self::get_record();
|
||||
if (!$row || empty($row->auth_json)) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($row->auth_json, true);
|
||||
if (!is_array($decoded) || empty($decoded['payload']) || empty($decoded['signature'])) {
|
||||
return null;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{code:string,message:string,details?:?array,request_id?:?string,at:int}|null
|
||||
*/
|
||||
public static function get_last_error(): ?array {
|
||||
$row = self::get_record();
|
||||
if (!$row || empty($row->last_error)) {
|
||||
return null;
|
||||
}
|
||||
$raw = (string)$row->last_error;
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded) && !empty($decoded['code'])) {
|
||||
return [
|
||||
'code' => (string)$decoded['code'],
|
||||
'message' => (string)($decoded['message'] ?? $decoded['code']),
|
||||
'details' => isset($decoded['details']) && is_array($decoded['details']) ? $decoded['details'] : null,
|
||||
'request_id' => isset($decoded['request_id']) ? (string)$decoded['request_id'] : null,
|
||||
'at' => (int)($decoded['at'] ?? ($row->timemodified ?? 0)),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'code' => 'UNKNOWN',
|
||||
'message' => $raw,
|
||||
'details' => null,
|
||||
'request_id' => null,
|
||||
'at' => (int)($row->timemodified ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public static function rotate_identity(): \stdClass {
|
||||
$kp = crypto::generate_installation_keypair();
|
||||
return self::update([
|
||||
'installation_id' => crypto::random_installation_id(),
|
||||
'private_key_pem' => $kp['private_pem'],
|
||||
'public_key_pem' => $kp['public_pem'],
|
||||
'auth_json' => null,
|
||||
'license_uuid' => null,
|
||||
'status' => 'inactive',
|
||||
'last_error' => null,
|
||||
'last_validated' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user