91334a46b5
Co-authored-by: Cursor <cursoragent@cursor.com>
617 lines
22 KiB
PHP
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';
|
|
}
|
|
}
|