Files
meetletacbfree/classes/registration/manager.php
T

617 lines
22 KiB
PHP

<?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';
}
}