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 */ 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'; } }