Adobe Connect Bridge free edition (meetletacbfree) v1.1.3

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 23:59:56 +03:30
commit 91334a46b5
93 changed files with 21023 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* HTTP client for MeetLet registration API.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\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;
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Ed25519 + canonical JSON helpers (compatible with @meetlet/crypto).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\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', 'meetletacbfree');
}
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', 'meetletacbfree');
}
$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', 'meetletacbfree');
}
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', 'meetletacbfree');
}
$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));
}
}
+135
View File
@@ -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_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\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, 'meetletacbfree')) {
return get_string($messagekey, 'meetletacbfree');
}
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,
];
}
}
+616
View File
@@ -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_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\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('meetletacbfree', 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' => 'modsettingmeetletacbfree',
], $tab);
}
public static function settings_url(): \moodle_url {
return self::settings_tab_url('meetletacbfree_registration');
}
public static function privacy_tab_url(): \moodle_url {
return self::settings_tab_url('meetletacbfree_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', 'meetletacbfree', 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', 'meetletacbfree')) .
' ' . get_string('registration_gate_body', 'meetletacbfree') . ' ' .
\html_writer::link($url, get_string('registration_gate_link', 'meetletacbfree')),
'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', 'meetletacbfree');
}
$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', 'meetletacbfree') : '',
'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, 'meetletacbfree')) {
return get_string($key, 'meetletacbfree');
}
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', 'meetletacbfree'),
'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_meetletacbfree',
'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', 'meetletacbfree');
}
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/meetletacbfree/version.php';
if (is_readable($versionfile)) {
include($versionfile);
}
if (!empty($plugin->release)) {
return (string)$plugin->release;
}
$ver = get_config('meetletacbfree', '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_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\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',
'meetletacbfree',
'',
$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);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
// This file is part of Moodle - http://moodle.org/
/**
* Persist MeetLet installation identity + cached runtime authorization.
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\registration;
defined('MOODLE_INTERNAL') || die();
/**
* Single-row registration store in meetletacbfree_license.
*/
class store {
public const TABLE = 'meetletacbfree_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,
]);
}
}