From 31c23a2b81ae0ec4fe16a7b639ed903b1b89e07a Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 20:07:23 +0200 Subject: [PATCH 01/57] Move validate.php to symfony controller --- composer.json | 5 +- public/validate.php | 123 ---------------------- routing/routes/routes.php | 25 +++++ routing/services/services.yml | 15 +++ src/Codebooks/LegacyRoutesEnum.php | 19 ++++ src/Codebooks/RoutesEnum.php | 19 ++++ src/Controller/Cas10Controller.php | 159 +++++++++++++++++++++++++++++ src/Controller/Traits/UrlTrait.php | 105 +++++++++++++++++++ 8 files changed, 345 insertions(+), 125 deletions(-) delete mode 100644 public/validate.php create mode 100644 routing/routes/routes.php create mode 100644 routing/services/services.yml create mode 100644 src/Codebooks/LegacyRoutesEnum.php create mode 100644 src/Codebooks/RoutesEnum.php create mode 100644 src/Controller/Cas10Controller.php create mode 100644 src/Controller/Traits/UrlTrait.php diff --git a/composer.json b/composer.json index 6d347e2a..a2a72ecc 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "config": { "preferred-install": { "simplesamlphp/simplesamlphp": "source", - "*": "dist" + "*": "source" }, "allow-plugins": { "composer/package-versions-deprecated": true, @@ -40,7 +40,8 @@ "simplesamlphp/simplesamlphp": "^2.2", "simplesamlphp/xml-cas": "^v1.3", "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5" + "simplesamlphp/xml-soap": "^v1.5", + "symfony/cache": "^6.0|^5.0|^4.3|^3.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/public/validate.php b/public/validate.php deleted file mode 100644 index cb073253..00000000 --- a/public/validate.php +++ /dev/null @@ -1,123 +0,0 @@ -getOptionalValue( - 'ticketstore', - ['class' => 'casserver:FileSystemTicketStore'], - ); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $serviceTicket = $ticketStore->getTicket($_GET['ticket']); - - if (!is_null($serviceTicket) && $ticketFactory->isServiceTicket($serviceTicket)) { - $ticketStore->deleteTicket($_GET['ticket']); - - $usernameField = $casconfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - - if ( - !$ticketFactory->isExpired($serviceTicket) && - sanitize($serviceTicket['service']) == sanitize($_GET['service']) && - (!$forceAuthn || $serviceTicket['forceAuthn']) && - array_key_exists($usernameField, $serviceTicket['attributes']) - ) { - echo $protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]); - } else { - if (!array_key_exists($usernameField, $serviceTicket['attributes'])) { - \SimpleSAML\Logger::error(sprintf( - 'casserver:validate: internal server error. Missing user name attribute: %s', - var_export($usernameField, true), - )); - - echo $protocol->getValidateFailureResponse(); - } else { - if ($ticketFactory->isExpired($serviceTicket)) { - $message = 'Ticket has ' . var_export($_GET['ticket'], true) . ' expired'; - } else { - if (sanitize($serviceTicket['service']) == sanitize($_GET['service'])) { - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($_GET['service'], true); - } else { - $message = 'Ticket was issue from single sign on session'; - } - } - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); - } - } - } else { - if (is_null($serviceTicket)) { - $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' not recognized'; - } else { - $message = 'ticket: ' . var_export($_GET['ticket'], true) . ' is not a service ticket'; - } - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); - } - } catch (\Exception $e) { - \SimpleSAML\Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); - - echo $protocol->getValidateFailureResponse(); - } -} else { - if (!array_key_exists('service', $_GET)) { - $message = 'Missing service parameter: [service]'; - } else { - $message = 'Missing ticket parameter: [ticket]'; - } - - SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(); -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php new file mode 100644 index 00000000..faf4bd32 --- /dev/null +++ b/routing/routes/routes.php @@ -0,0 +1,25 @@ +add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) + ->controller([Cas10Controller::class, 'validate']); + + // Legacy Routes + $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) + ->controller([Cas10Controller::class, 'validate']); +}; diff --git a/routing/services/services.yml b/routing/services/services.yml new file mode 100644 index 00000000..e14851f6 --- /dev/null +++ b/routing/services/services.yml @@ -0,0 +1,15 @@ +--- + +services: + # default configuration for services in *this* file + _defaults: + public: false + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + SimpleSAML\Module\casserver\Controller\: + resource: '../../src/Controller/*' + exclude: + - '../../src/Controller/Traits/*' + public: true + autowire: true \ No newline at end of file diff --git a/src/Codebooks/LegacyRoutesEnum.php b/src/Codebooks/LegacyRoutesEnum.php new file mode 100644 index 00000000..36f6bf31 --- /dev/null +++ b/src/Codebooks/LegacyRoutesEnum.php @@ -0,0 +1,19 @@ +casConfig = Configuration::getConfig('module_casserver.php'); + $this->cas10Protocol = new Cas10($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass ='SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + /** @psalm-suppress InvalidStringClass */ + $this->ticketStore = new $ticketStoreClass($this->casConfig); + } + + /** + * @param Request $request + * + * @return Response + */ + public function validate(Request $request): Response + { + // Check if any of the required query parameters are missing + if(!$request->query->has('service')) { + Logger::debug('casserver: Missing service parameter: [service]'); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } else if(!$request->query->has('ticket')) { + Logger::debug('casserver: Missing service parameter: [ticket]'); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } + + // Check if we are required to force an authentication + $forceAuthn = $request->query->has('renew') && $request->query->get('renew'); + // Get the ticket + $ticket = $request->query->get('ticket'); + // Get the service + $service = $request->query->get('service'); + + try { + // Get the service ticket + $serviceTicket = $this->ticketStore->getTicket($ticket); + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + } catch (\Exception $e) { + Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + $failed = false; + $message = ''; + // No ticket + if ($serviceTicket === null) { + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + // This is not a service ticket + } else if (!$this->ticketFactory->isServiceTicket($serviceTicket)){ + $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $failed = true; + // the ticket has expired + } else if ($this->ticketFactory->isExpired($serviceTicket)) { + $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $failed = true; + } else if ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($service, true); + $failed = true; + } else if ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + Logger::error('casserver:validate: ' . $message, true); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST + ); + } + + // Get the username field + $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); + + // Fail if the username field is not present in the attribute list + if (!\array_key_exists($usernameField, $serviceTicket['attributes'])) { + Logger::error( + 'casserver:validate: internal server error. Missing user name attribute: ' + . var_export($usernameField, true), + ); + + } + + // Successful validation + return new Response( + $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), + Response::HTTP_OK + ); + } +} \ No newline at end of file diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php new file mode 100644 index 00000000..035af129 --- /dev/null +++ b/src/Controller/Traits/UrlTrait.php @@ -0,0 +1,105 @@ + $legal_service_urls]); + $serviceValidator = new ServiceValidator($config); + return $serviceValidator->checkServiceURL($service) !== null; + } + + + /** + * @param string $parameter + * @return string + */ + public function sanitize(string $parameter): string + { + return TicketValidator::sanitize($parameter); + } + + + /** + * Parse the query Parameters from $_GET global and return them in an array. + * + * @param array|null $sessionTicket + * @param Request $request + * + * @return array + */ + public function parseQueryParameters(?array $sessionTicket, Request $request): array + { + $forceAuthn = isset($_GET['renew']) && $_GET['renew']; + $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + + $query = []; + + if ($sessionRenewId && $forceAuthn) { + $query['renewId'] = $sessionRenewId; + } + + if (isset($_REQUEST['service'])) { + $query['service'] = $_REQUEST['service']; + } + + if (isset($_REQUEST['TARGET'])) { + $query['TARGET'] = $_REQUEST['TARGET']; + } + + if (isset($_REQUEST['method'])) { + $query['method'] = $_REQUEST['method']; + } + + if (isset($_REQUEST['renew'])) { + $query['renew'] = $_REQUEST['renew']; + } + + if (isset($_REQUEST['gateway'])) { + $query['gateway'] = $_REQUEST['gateway']; + } + + if (\array_key_exists('language', $_GET)) { + $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; + } + + if (isset($_REQUEST['debugMode'])) { + $query['debugMode'] = $_REQUEST['debugMode']; + } + + return $query; + } + + /** + * @param Request $request + * + * @return array + */ + public function getRequestParams(Request $request): array + { + $params = []; + if ($request->isMethod('GET')) { + $params = $request->query->all(); + } elseif ($request->isMethod('POST')) { + $params = $request->request->all(); + } + + return $params; + } +} \ No newline at end of file From c497868ba6d643ab6c23a8f320e114e578c98924 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 20:19:50 +0200 Subject: [PATCH 02/57] Fix quality errors --- src/Codebooks/RoutesEnum.php | 2 +- src/Controller/Cas10Controller.php | 33 +++--- src/Controller/Traits/UrlTrait.php | 162 ++++++++++++++--------------- 3 files changed, 97 insertions(+), 100 deletions(-) diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 7e96093a..954aa385 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -16,4 +16,4 @@ enum RoutesEnum: string case SamlValidate = 'samlValidate'; case ServiceValidate = 'serviceValidate'; case Validate = 'validate'; -} \ No newline at end of file +} diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index fb174557..6b77586a 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -4,8 +4,6 @@ namespace SimpleSAML\Module\casserver\Controller; -use Exception; -use Module\casserver\Cas\Factories; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; @@ -47,8 +45,8 @@ class Cas10Controller * * @throws Exception */ - public function __construct( - ) { + public function __construct() + { $this->casConfig = Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ @@ -58,7 +56,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass ='SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; /** @psalm-suppress InvalidStringClass */ $this->ticketStore = new $ticketStoreClass($this->casConfig); @@ -72,17 +70,17 @@ public function __construct( public function validate(Request $request): Response { // Check if any of the required query parameters are missing - if(!$request->query->has('service')) { + if (!$request->query->has('service')) { Logger::debug('casserver: Missing service parameter: [service]'); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); - } else if(!$request->query->has('ticket')) { + } elseif (!$request->query->has('ticket')) { Logger::debug('casserver: Missing service parameter: [ticket]'); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); } @@ -102,7 +100,7 @@ public function validate(Request $request): Response Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true)); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_INTERNAL_SERVER_ERROR + Response::HTTP_INTERNAL_SERVER_ERROR, ); } @@ -113,19 +111,19 @@ public function validate(Request $request): Response $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; $failed = true; // This is not a service ticket - } else if (!$this->ticketFactory->isServiceTicket($serviceTicket)){ + } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; $failed = true; // the ticket has expired - } else if ($this->ticketFactory->isExpired($serviceTicket)) { + } elseif ($this->ticketFactory->isExpired($serviceTicket)) { $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; $failed = true; - } else if ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + } elseif ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { $message = 'Mismatching service parameters: expected ' . var_export($serviceTicket['service'], true) . ' but was: ' . var_export($service, true); $failed = true; - } else if ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + } elseif ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { $message = 'Ticket was issued from single sign on session'; $failed = true; } @@ -134,7 +132,7 @@ public function validate(Request $request): Response Logger::error('casserver:validate: ' . $message, true); return new Response( $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST + Response::HTTP_BAD_REQUEST, ); } @@ -147,13 +145,12 @@ public function validate(Request $request): Response 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), ); - } // Successful validation return new Response( $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), - Response::HTTP_OK + Response::HTTP_OK, ); } -} \ No newline at end of file +} diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 035af129..88a7476b 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -10,96 +10,96 @@ trait UrlTrait { - /** - * @deprecated - * @see ServiceValidator - * @param string $service - * @param array $legal_service_urls - * @return bool - */ - public function checkServiceURL(string $service, array $legal_service_urls): bool - { - //delegate to ServiceValidator until all references to this can be cleaned up - $config = Configuration::loadFromArray(['legal_service_urls' => $legal_service_urls]); - $serviceValidator = new ServiceValidator($config); - return $serviceValidator->checkServiceURL($service) !== null; - } - - - /** - * @param string $parameter - * @return string - */ - public function sanitize(string $parameter): string - { - return TicketValidator::sanitize($parameter); - } - - - /** - * Parse the query Parameters from $_GET global and return them in an array. - * - * @param array|null $sessionTicket - * @param Request $request - * - * @return array - */ - public function parseQueryParameters(?array $sessionTicket, Request $request): array - { - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - - $query = []; - - if ($sessionRenewId && $forceAuthn) { - $query['renewId'] = $sessionRenewId; + /** + * @deprecated + * @see ServiceValidator + * @param string $service + * @param array $legal_service_urls + * @return bool + */ + public function checkServiceURL(string $service, array $legal_service_urls): bool + { + //delegate to ServiceValidator until all references to this can be cleaned up + $config = Configuration::loadFromArray(['legal_service_urls' => $legal_service_urls]); + $serviceValidator = new ServiceValidator($config); + return $serviceValidator->checkServiceURL($service) !== null; } - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; - } - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; + /** + * @param string $parameter + * @return string + */ + public function sanitize(string $parameter): string + { + return TicketValidator::sanitize($parameter); } - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; - } + /** + * Parse the query Parameters from $_GET global and return them in an array. + * + * @param array|null $sessionTicket + * @param Request $request + * + * @return array + */ + public function parseQueryParameters(?array $sessionTicket, Request $request): array + { + $forceAuthn = isset($_GET['renew']) && $_GET['renew']; + $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; - } + $query = []; - if (\array_key_exists('language', $_GET)) { - $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; - } + if ($sessionRenewId && $forceAuthn) { + $query['renewId'] = $sessionRenewId; + } - if (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } + if (isset($_REQUEST['service'])) { + $query['service'] = $_REQUEST['service']; + } + + if (isset($_REQUEST['TARGET'])) { + $query['TARGET'] = $_REQUEST['TARGET']; + } - return $query; - } - - /** - * @param Request $request - * - * @return array - */ - public function getRequestParams(Request $request): array - { - $params = []; - if ($request->isMethod('GET')) { - $params = $request->query->all(); - } elseif ($request->isMethod('POST')) { - $params = $request->request->all(); + if (isset($_REQUEST['method'])) { + $query['method'] = $_REQUEST['method']; + } + + if (isset($_REQUEST['renew'])) { + $query['renew'] = $_REQUEST['renew']; + } + + if (isset($_REQUEST['gateway'])) { + $query['gateway'] = $_REQUEST['gateway']; + } + + if (\array_key_exists('language', $_GET)) { + $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; + } + + if (isset($_REQUEST['debugMode'])) { + $query['debugMode'] = $_REQUEST['debugMode']; + } + + return $query; } - return $params; - } -} \ No newline at end of file + /** + * @param Request $request + * + * @return array + */ + public function getRequestParams(Request $request): array + { + $params = []; + if ($request->isMethod('GET')) { + $params = $request->query->all(); + } elseif ($request->isMethod('POST')) { + $params = $request->request->all(); + } + + return $params; + } +} From cc0dbaf04192e2230a5bbbbea33ef52a9d844fbd Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 19 Nov 2024 22:01:01 +0200 Subject: [PATCH 03/57] Loggout Controller --- public/logout.php | 91 --------------------- routing/routes/routes.php | 5 ++ src/Controller/Cas10Controller.php | 1 - src/Controller/LogoutController.php | 120 ++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 92 deletions(-) delete mode 100644 public/logout.php create mode 100644 src/Controller/LogoutController.php diff --git a/public/logout.php b/public/logout.php deleted file mode 100644 index 518c04ec..00000000 --- a/public/logout.php +++ /dev/null @@ -1,91 +0,0 @@ -getOptionalValue('enable_logout', false)) { - $message = 'Logout not allowed'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} - -$skipLogoutPage = $casconfig->getOptionalValue('skip_logout_page', false); - -if ($skipLogoutPage && !array_key_exists('url', $_GET)) { - $message = 'Required URL query parameter [url] not provided. (CAS Server)'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} -/* Load simpleSAMLphp metadata */ - -$as = new \SimpleSAML\Auth\Simple($casconfig->getValue('authsource')); - -$session = \SimpleSAML\Session::getSession(); - -if (!is_null($session)) { - $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketStore->deleteTicket($session->getSessionId()); -} - -$httpUtils = new \SimpleSAML\Utils\HTTP(); - -if ($as->isAuthenticated()) { - \SimpleSAML\Logger::debug('casserver: performing a real logout'); - - if ($casconfig->getOptionalValue('skip_logout_page', false)) { - $as->logout($_GET['url']); - } else { - $as->logout( - $httpUtils->addURLParameters( - \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'), - array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [], - ), - ); - } -} else { - \SimpleSAML\Logger::debug('casserver: no session to log out of, performing redirect'); - - if ($casconfig->getOptionalValue('skip_logout_page', false)) { - $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($_GET['url'], [])); - } else { - $httpUtils->redirectTrustedURL( - $httpUtils->addURLParameters( - \SimpleSAML\Module::getModuleURL('casserver/loggedOut.php'), - array_key_exists('url', $_GET) ? ['url' => $_GET['url']] : [], - ), - ); - } -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index faf4bd32..2a656d7b 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; /** @psalm-suppress InvalidArgument */ @@ -18,8 +19,12 @@ // New Routes $routes->add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(RoutesEnum::Validate->name, RoutesEnum::Logout->value) + ->controller([LogoutController::class, 'logout']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyLogout->value) + ->controller([LogoutController::class, 'logout']); }; diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 6b77586a..397047aa 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -43,7 +43,6 @@ class Cas10Controller * * It initializes the global configuration for the controllers implemented here. * - * @throws Exception */ public function __construct() { diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php new file mode 100644 index 00000000..1e06a882 --- /dev/null +++ b/src/Controller/LogoutController.php @@ -0,0 +1,120 @@ +casConfig = Configuration::getConfig('module_casserver.php'); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + $this->ticketStore = new $ticketStoreClass($this->casConfig); + $this->authSource = new Simple($this->casConfig->getValue('authsource')); + } + + /** + * + * @param string|null $url + * + * @return RedirectResponse|null + */ + public function logout( + #[MapQueryParameter] ?string $url = null, + ): RedirectResponse|null { + if (!$this->casConfig->getOptionalValue('enable_logout', false)) { + $this->handleExceptionThrown('Logout not allowed'); + } + + // Skip Logout Page configuration + $skipLogoutPage = $this->casConfig->getOptionalValue('skip_logout_page', false); + + if ($skipLogoutPage && $url === null) { + $this->handleExceptionThrown('Required URL query parameter [url] not provided. (CAS Server)'); + } + + // Construct the logout redirect url + $logoutRedirectUrl = ($skipLogoutPage || $url === null) ? $url + : $url . '?' . http_build_query(['url' => $url]); + + // Delete the ticket from the session + $session = $this->getSession(); + if ($session !== null) { + $this->ticketStore->deleteTicket($session->getSessionId()); + } + + // Redirect + if (!$this->authSource->isAuthenticated()) { + $this->redirect($logoutRedirectUrl); + } + + // Logout and redirect + $this->authSource->logout($logoutRedirectUrl); + + // We should never get here + return null; + } + + /** + * @param string $message + * + * @return void + */ + protected function handleExceptionThrown(string $message): void + { + Logger::debug('casserver:' . $message); + throw new \RuntimeException($message); + } + + /** + * Get the Session + * + * @return Session|null + */ + protected function getSession(): ?Session + { + return Session::getSession(); + } +} From 8a3aff9fd7b9d2ef18796c6770aa3f0890110ad0 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 17:26:01 +0200 Subject: [PATCH 04/57] LoggedOut Controller --- composer.json | 3 +- public/loggedOut.php | 36 -------------- routing/routes/routes.php | 11 +++-- routing/services/services.yml | 12 ++++- src/Controller/Cas10Controller.php | 9 +--- src/Controller/LoggedOutController.php | 48 +++++++++++++++++++ src/Controller/LogoutController.php | 15 +++++- tests/src/Controller/LogoutControllerTest.php | 38 +++++++++++++++ 8 files changed, 121 insertions(+), 51 deletions(-) delete mode 100644 public/loggedOut.php create mode 100644 src/Controller/LoggedOutController.php create mode 100644 tests/src/Controller/LogoutControllerTest.php diff --git a/composer.json b/composer.json index a2a72ecc..a1be12b0 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,7 @@ "simplesamlphp/simplesamlphp": "^2.2", "simplesamlphp/xml-cas": "^v1.3", "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5", - "symfony/cache": "^6.0|^5.0|^4.3|^3.4" + "simplesamlphp/xml-soap": "^v1.5" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/public/loggedOut.php b/public/loggedOut.php deleted file mode 100644 index 20330c68..00000000 --- a/public/loggedOut.php +++ /dev/null @@ -1,36 +0,0 @@ -data['url'] = $_GET['url']; -} - -$t->send(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 2a656d7b..3034db74 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -6,9 +6,10 @@ declare(strict_types=1); -use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; +use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -19,12 +20,16 @@ // New Routes $routes->add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) ->controller([Cas10Controller::class, 'validate']); - $routes->add(RoutesEnum::Validate->name, RoutesEnum::Logout->value) + $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value) ->controller([LogoutController::class, 'logout']); + $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) + ->controller([LoggedOutController::class, 'main']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) ->controller([Cas10Controller::class, 'validate']); - $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyLogout->value) + $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) ->controller([LogoutController::class, 'logout']); + $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) + ->controller([LoggedOutController::class, 'main']); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index e14851f6..ef66c66e 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -12,4 +12,14 @@ services: exclude: - '../../src/Controller/Traits/*' public: true - autowire: true \ No newline at end of file + tags: ['controller.service_arguments'] + + # Explicit service definitions for CasServer Controllers + SimpleSAML\Module\casserver\Controller\Cas10Controller: + public: true + + SimpleSAML\Module\casserver\Controller\LogoutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedOutController: + public: true \ No newline at end of file diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 397047aa..4037b1aa 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -11,14 +11,9 @@ use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\AsController; -/** - * Controller class for the casserver module. - * - * This class serves the different views available in the module. - * - * @package SimpleSAML\Module\casserver - */ +#[AsController] class Cas10Controller { use UrlTrait; diff --git a/src/Controller/LoggedOutController.php b/src/Controller/LoggedOutController.php new file mode 100644 index 00000000..b645a3fb --- /dev/null +++ b/src/Controller/LoggedOutController.php @@ -0,0 +1,48 @@ +config = $config ?? Configuration::getInstance(); + } + + /** + * Show Log out view. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + public function main(Request $request): Response + { + $t = new Template($this->config, 'casserver:loggedOut.twig'); + if ($request->query->has('url')) { + $t->data['url'] = $request->query->get('url'); + } + return $t; + } +} diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 1e06a882..08fb85a8 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -7,12 +7,16 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +#[AsController] class LogoutController { use UrlTrait; @@ -57,11 +61,13 @@ public function __construct() /** * + * @param Request $request * @param string|null $url * * @return RedirectResponse|null */ public function logout( + Request $request, #[MapQueryParameter] ?string $url = null, ): RedirectResponse|null { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { @@ -76,8 +82,13 @@ public function logout( } // Construct the logout redirect url - $logoutRedirectUrl = ($skipLogoutPage || $url === null) ? $url - : $url . '?' . http_build_query(['url' => $url]); + if ($skipLogoutPage) { + $logoutRedirectUrl = $url; + } else { + $loggedOutUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutRedirectUrl = $url === null ? $loggedOutUrl + : $loggedOutUrl . '?' . http_build_query(['url' => $url]); + } // Delete the ticket from the session $session = $this->getSession(); diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php new file mode 100644 index 00000000..65263623 --- /dev/null +++ b/tests/src/Controller/LogoutControllerTest.php @@ -0,0 +1,38 @@ +controller = new LogoutController(); + } + + public static function requestParameters(): array + { + return [ + 'no redirect url' => [''], + 'with redirect url' => ['http://example.com/redirect'], + ]; + } + + #[DataProvider('requestParameters')] + public function testLogout(string $redirectUrl): void + { + $request = Request::create( + uri: 'https://localhost/casserver/logout', + parameters: ['url' => $redirectUrl], + ); + } +} \ No newline at end of file From 5b9393209d4d87d1bc8ca5148f31349c8af72022 Mon Sep 17 00:00:00 2001 From: Tim van Dijen Date: Wed, 20 Nov 2024 16:49:12 +0100 Subject: [PATCH 05/57] Fix quality issues --- composer.json | 9 ++++++--- src/Controller/Traits/UrlTrait.php | 1 + tests/src/Controller/LogoutControllerTest.php | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index a1be12b0..4f193c17 100644 --- a/composer.json +++ b/composer.json @@ -35,12 +35,15 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", + "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", "simplesamlphp/simplesamlphp": "^2.2", - "simplesamlphp/xml-cas": "^v1.3", - "simplesamlphp/xml-common": "^v1.17", - "simplesamlphp/xml-soap": "^v1.5" + "simplesamlphp/xml-cas": "^1.3", + "simplesamlphp/xml-common": "^1.17", + "simplesamlphp/xml-soap": "^1.5", + "symfony/http-foundation": "^6.4", + "symfony/http-kernel": "^6.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 88a7476b..858edac0 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; +use SimpleSAML\Configuration; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 65263623..0292c31c 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -35,4 +35,4 @@ public function testLogout(string $redirectUrl): void parameters: ['url' => $redirectUrl], ); } -} \ No newline at end of file +} From 938d88698c3c84364ba70b4505e1a1e947aac04c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 18:30:01 +0200 Subject: [PATCH 06/57] composer require checker for dev environments --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4f193c17..6defbaa9 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,8 @@ "simplesamlphp/simplesamlphp-test-framework": "^1.7", "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7", + "maglnet/composer-require-checker": "^4.14" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -61,7 +62,8 @@ "scripts": { "validate": [ "vendor/bin/phpunit --no-coverage --testdox", - "vendor/bin/phpcs -p" + "vendor/bin/phpcs -p", + "vendor/bin/composer-require-checker check composer.json" ], "tests": [ "vendor/bin/phpunit --no-coverage" From acc2e0475c85b4d024c1148910ab802ccf2dcfe3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 19:10:27 +0200 Subject: [PATCH 07/57] fix composer require checker version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6defbaa9..88e37a45 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", - "maglnet/composer-require-checker": "^4.14" + "maglnet/composer-require-checker": "4.7.1" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", From 7287057e370753277b9996c6efcce086b7c64c25 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 20:41:43 +0200 Subject: [PATCH 08/57] Add tests for LogoutController.php part1 --- composer.json | 4 +- src/Controller/LogoutController.php | 34 +++++--- tests/src/Controller/LogoutControllerTest.php | 85 ++++++++++++++++--- 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index 88e37a45..a3fc946c 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", + "ext-pdo": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", @@ -56,9 +57,6 @@ "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", "source": "https://github.com/simplesamlphp/simplesamlphp-module-casserver" }, - "suggest": { - "ext-pdo": "*" - }, "scripts": { "validate": [ "vendor/bin/phpunit --no-coverage --testdox", diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 08fb85a8..2e07d7a4 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\casserver\Controller; use SimpleSAML\Auth\Simple; +use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module; @@ -33,19 +34,28 @@ class LogoutController /** @var Simple */ protected Simple $authSource; + /** @var SspContainer */ + protected SspContainer $container; + // this could be any configured ticket store /** @var mixed */ protected mixed $ticketStore; + /** - * Controller constructor. - * - * It initializes the global configuration for the controllers implemented here. + * @param Configuration|null $casConfig + * @param Simple|null $source + * @param SspContainer|null $container * + * @throws \Exception */ - public function __construct() - { - $this->casConfig = Configuration::getConfig('module_casserver.php'); + public function __construct( + // Facilitate testing + Configuration $casConfig = null, + Simple $source = null, + SspContainer $container = null, + ) { + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); /* Instantiate ticket store */ @@ -56,7 +66,8 @@ public function __construct() $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = new $ticketStoreClass($this->casConfig); - $this->authSource = new Simple($this->casConfig->getValue('authsource')); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->container = $container ?? new SspContainer(); } /** @@ -84,10 +95,11 @@ public function logout( // Construct the logout redirect url if ($skipLogoutPage) { $logoutRedirectUrl = $url; + $params = []; } else { - $loggedOutUrl = Module::getModuleURL('casserver/loggedOut.php'); - $logoutRedirectUrl = $url === null ? $loggedOutUrl - : $loggedOutUrl . '?' . http_build_query(['url' => $url]); + $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut.php'); + $params = $url === null ? [] + : ['url' => $url]; } // Delete the ticket from the session @@ -98,7 +110,7 @@ public function logout( // Redirect if (!$this->authSource->isAuthenticated()) { - $this->redirect($logoutRedirectUrl); + $this->container->redirect($logoutRedirectUrl, $params); } // Logout and redirect diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 0292c31c..e73897ff 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -4,35 +4,92 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use SimpleSAML\Auth\Simple; +use SimpleSAML\Compat\SspContainer; +use SimpleSAML\Configuration; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase { - /** @var LogoutController */ - private $controller; + private array $moduleConfig; + + private Simple $authSimpleMock; + + private SspContainer $sspContainer; protected function setUp(): void { - $this->controller = new LogoutController(); + $this->authSimpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['logout', 'isAuthenticated']) + ->getMock(); + + $this->sspContainer = $this->getMockBuilder(SspContainer::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirect']) + ->getMock(); + + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; } - public static function requestParameters(): array + public function testLogoutNotAllowed(): void { - return [ - 'no redirect url' => [''], - 'with redirect url' => ['http://example.com/redirect'], - ]; + $this->moduleConfig['enable_logout'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Logout not allowed'); + + $controller = new LogoutController($config, $this->authSimpleMock); + $controller->logout(Request::create('/')); + } + + public function testLogoutNoRedirectUrlOnSkipLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = true; + $config = Configuration::loadFromArray($this->moduleConfig); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Required URL query parameter [url] not provided. (CAS Server)'); + + $controller = new LogoutController($config, $this->authSimpleMock); + $controller->logout(Request::create('/')); } - #[DataProvider('requestParameters')] - public function testLogout(string $redirectUrl): void + public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void { - $request = Request::create( - uri: 'https://localhost/casserver/logout', - parameters: ['url' => $redirectUrl], + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + // Unauthenticated + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), + [], ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller->logout(Request::create('/')); } } From 85bc4766d2f283a225a54a1993442c8a7ba76b10 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 20 Nov 2024 20:58:57 +0200 Subject: [PATCH 09/57] psalm --- composer.json | 7 +++++-- psalm-dev.xml | 4 ++++ psalm.xml | 6 ++++++ tests/src/Controller/LogoutControllerTest.php | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index a3fc946c..9f98896a 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,8 @@ "phpunit/phpunit": "^10", "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", - "maglnet/composer-require-checker": "4.7.1" + "maglnet/composer-require-checker": "4.7.1", + "vimeo/psalm": "^5" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -61,7 +62,9 @@ "validate": [ "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", - "vendor/bin/composer-require-checker check composer.json" + "vendor/bin/composer-require-checker check composer.json", + "vendor/bin/psalm -c psalm-dev.xml", + "vendor/bin/psalm -c psalm.xml" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/psalm-dev.xml b/psalm-dev.xml index fc2fbd3d..071de9a8 100644 --- a/psalm-dev.xml +++ b/psalm-dev.xml @@ -3,6 +3,10 @@ name="SimpleSAMLphp testsuite" useDocblockTypes="true" errorLevel="4" + resolveFromConfigFile="true" + autoloader="vendor/autoload.php" + findUnusedCode="false" + findUnusedBaselineEntry="true" reportMixedIssues="false" hideExternalErrors="true" allowStringToStandInForClass="true" diff --git a/psalm.xml b/psalm.xml index a7a64d4c..72284dce 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,12 @@ useDocblockTypes="true" errorLevel="2" reportMixedIssues="false" + resolveFromConfigFile="true" + autoloader="vendor/autoload.php" + findUnusedCode="false" + findUnusedBaselineEntry="true" + hideExternalErrors="true" + allowStringToStandInForClass="true" > diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index e73897ff..1538c71e 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -82,8 +82,9 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated + /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - + /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), [], From 13db7a7491b989a9c739f6c36237608902717fa9 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 13:38:09 +0200 Subject: [PATCH 10/57] LogoutController.php tests part 2 --- src/Controller/LogoutController.php | 13 +- tests/src/Controller/LogoutControllerTest.php | 117 ++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 2e07d7a4..6623a28f 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -56,6 +56,9 @@ public function __construct( SspContainer $container = null, ) { $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->container = $container ?? new SspContainer(); + /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); /* Instantiate ticket store */ @@ -66,8 +69,6 @@ public function __construct( $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = new $ticketStoreClass($this->casConfig); - $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->container = $container ?? new SspContainer(); } /** @@ -120,6 +121,14 @@ public function logout( return null; } + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } + /** * @param string $message * diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 1538c71e..b36263ea 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -8,7 +8,9 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; +use SimpleSAML\Session; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase @@ -75,6 +77,33 @@ public function testLogoutNoRedirectUrlOnSkipLogout(): void $controller->logout(Request::create('/')); } + public function testLogoutWithRedirectUrlOnSkipLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = true; + $config = Configuration::loadFromArray($this->moduleConfig); + $urlParam = 'https://example.com/test'; + + // Unauthenticated + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + /** @psalm-suppress UndefinedMethod */ + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo($urlParam), + [], + ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + + $logoutUrl = Module::getModuleURL('casserver/logout.php'); + + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $controller->logout($request, $urlParam); + } + public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void { $this->moduleConfig['enable_logout'] = true; @@ -93,4 +122,92 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } + + public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + $urlParam = 'https://example.com/test'; + $logoutUrl = Module::getModuleURL('casserver/loggedOut.php'); + + // Unauthenticated + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + /** @psalm-suppress UndefinedMethod */ + $this->sspContainer->expects($this->once())->method('redirect')->with( + $this->equalTo($logoutUrl), + ['url' => $urlParam], + ); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $request = Request::create( + uri: $logoutUrl, + parameters: ['url' => $urlParam], + ); + $controller->logout($request, $urlParam); + } + + public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + // Unauthenticated + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); + /** @psalm-suppress UndefinedMethod */ + $this->authSimpleMock->expects($this->once())->method('logout') + ->with('http://localhost/module.php/casserver/loggedOut.php'); + + $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller->logout(Request::create('/')); + } + + public function testTicketIdGetsDeletedOnLogout(): void + { + $this->moduleConfig['enable_logout'] = true; + $this->moduleConfig['skip_logout_page'] = false; + $config = Configuration::loadFromArray($this->moduleConfig); + + $controllerMock = $this->getMockBuilder(LogoutController::class) + ->setConstructorArgs([$config, $this->authSimpleMock, $this->sspContainer]) + ->onlyMethods(['getSession']) + ->getMock(); + + $ticketStore = $controllerMock->getTicketStore(); + $sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $sessionId = session_create_id(); + $sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); + + $ticket = [ + 'id' => $sessionId, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'service' => 'https://localhost/ssp/module.php/cas/linkback.php?stateId=_332b2b157041f4fc70dd290339a05a4e915674c1f2%3Ahttps%3A%2F%2Flocalhost%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver', + 'forceAuthn' => false, + 'userName' => 'test1@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'test@google.com', + ], + ], + 'proxies' => + [ + ], + ]; + + $ticketStore->addTicket($ticket); + $controllerMock->expects($this->once())->method('getSession')->willReturn($sessionMock); + + $controllerMock->logout(Request::create('/')); + // The Ticket has been successfully deleted + $this->assertEquals(null, $ticketStore->getTicket($ticket['id'])); + } } From 1c8b8a8c9ab709aa7236c3753616f22e2175585c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 16:51:41 +0200 Subject: [PATCH 11/57] add CAS10 validate.php tests --- src/Controller/Cas10Controller.php | 69 +++-- tests/src/Controller/Cas10ControllerTest.php | 293 +++++++++++++++++++ 2 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 tests/src/Controller/Cas10ControllerTest.php diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 4037b1aa..caa6fca9 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -34,14 +34,16 @@ class Cas10Controller protected mixed $ticketStore; /** - * Controller constructor. - * - * It initializes the global configuration for the controllers implemented here. + * @param Configuration|null $casConfig + * @param $ticketStore * + * @throws \Exception */ - public function __construct() - { - $this->casConfig = Configuration::getConfig('module_casserver.php'); + public function __construct( + Configuration $casConfig = null, + $ticketStore = null, + ) { + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -53,40 +55,39 @@ public function __construct() $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; /** @psalm-suppress InvalidStringClass */ - $this->ticketStore = new $ticketStoreClass($this->casConfig); + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } /** - * @param Request $request + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service * * @return Response */ - public function validate(Request $request): Response - { + public function validate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + ): Response { + + $forceAuthn = $renew; // Check if any of the required query parameters are missing - if (!$request->query->has('service')) { - Logger::debug('casserver: Missing service parameter: [service]'); - return new Response( - $this->cas10Protocol->getValidateFailureResponse(), - Response::HTTP_BAD_REQUEST, - ); - } elseif (!$request->query->has('ticket')) { - Logger::debug('casserver: Missing service parameter: [ticket]'); + if ($service === null || $ticket === null) { + $messagePostfix = $service === null ? 'service' : 'ticket'; + Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); return new Response( $this->cas10Protocol->getValidateFailureResponse(), Response::HTTP_BAD_REQUEST, ); } - // Check if we are required to force an authentication - $forceAuthn = $request->query->has('renew') && $request->query->get('renew'); - // Get the ticket - $ticket = $request->query->get('ticket'); - // Get the service - $service = $request->query->get('service'); - try { // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // unserialization handlers. $serviceTicket = $this->ticketStore->getTicket($ticket); // Delete the ticket $this->ticketStore->deleteTicket($ticket); @@ -112,12 +113,14 @@ public function validate(Request $request): Response } elseif ($this->ticketFactory->isExpired($serviceTicket)) { $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; $failed = true; - } elseif ($this->sanitize($serviceTicket['service']) === $this->sanitize($service)) { + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) { + // The service we pass to the query parameters does not match the one in the ticket. $message = 'Mismatching service parameters: expected ' . var_export($serviceTicket['service'], true) . ' but was: ' . var_export($service, true); $failed = true; - } elseif ($forceAuthn && isset($serviceTicket['forceAuthn']) && $serviceTicket['forceAuthn']) { + } elseif ($forceAuthn) { + // If the forceAuthn/renew is true $message = 'Ticket was issued from single sign on session'; $failed = true; } @@ -139,6 +142,10 @@ public function validate(Request $request): Response 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), ); + return new Response( + $this->cas10Protocol->getValidateFailureResponse(), + Response::HTTP_BAD_REQUEST, + ); } // Successful validation @@ -147,4 +154,12 @@ public function validate(Request $request): Response Response::HTTP_OK, ); } + + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php new file mode 100644 index 00000000..ca517231 --- /dev/null +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -0,0 +1,293 @@ +sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 1731111111, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; + } + + public static function queryParameterValues(): array + { + return [ + 'Has Service' => [ + ['service' => 'https://myservice.com/abcd'], + ], + 'Has Ticket' => [ + ['ticket' => '1234567'], + ], + 'Has Neither Service Nor Ticket' => [ + [], + ], + ]; + } + + #[DataProvider('queryParameterValues')] + public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturn500OnDeleteTicketThatThrows(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $ticketStore = new class ($config) extends FileSystemTicketStore { + public function getTicket(string $ticketId): ?array + { + throw new Exception(); + } + }; + + $cas10Controller = new Cas10Controller($config, $ticketStore); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketNotExist(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketExpired(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketNotService(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $this->ticket['id'] = $this->sessionId; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketMissingUsernameField(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/failservice', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ]; + $this->ticket['validBefore'] = 9999999999; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals("no\n\n", $response->getContent()); + } + + public function testSuccessfullValidation(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $this->ticket['validBefore'] = 9999999999; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas10Controller = new Cas10Controller($config); + $ticketStore = $cas10Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas10Controller->validate($request, ...$params); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("yes\neduPersonPrincipalName@google.com\n", $response->getContent()); + } +} From 4e00a203d6e70ad55da4908867e1b6ae31c509ce Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 16:58:09 +0200 Subject: [PATCH 12/57] Add missing dependency --- src/Controller/Cas10Controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index caa6fca9..63b32854 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; #[AsController] class Cas10Controller From 44a3b0ed01a71e26b74953e2efbbdbad099cea2d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 21 Nov 2024 19:16:40 +0200 Subject: [PATCH 13/57] LoggedInController.php --- composer.json | 1 + public/loggedIn.php | 32 ------------------- routing/routes/routes.php | 5 +++ routing/services/services.yml | 3 ++ src/Controller/LoggedInController.php | 45 +++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 32 deletions(-) delete mode 100644 public/loggedIn.php create mode 100644 src/Controller/LoggedInController.php diff --git a/composer.json b/composer.json index 9f98896a..bc5196b3 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "ext-libxml": "*", "ext-SimpleXML": "*", "ext-pdo": "*", + "ext-session": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", diff --git a/public/loggedIn.php b/public/loggedIn.php deleted file mode 100644 index 295f4821..00000000 --- a/public/loggedIn.php +++ /dev/null @@ -1,32 +0,0 @@ -send(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 3034db74..31a14806 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -24,6 +25,8 @@ ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(RoutesEnum::LoggedIn->name, RoutesEnum::LoggedIn->value) + ->controller([LoggedInController::class, 'main']); // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) @@ -32,4 +35,6 @@ ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(LegacyRoutesEnum::LegacyLoggedIn->name, LegacyRoutesEnum::LegacyLoggedIn->value) + ->controller([LoggedInController::class, 'main']); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index ef66c66e..6b968fc5 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -22,4 +22,7 @@ services: public: true SimpleSAML\Module\casserver\Controller\LoggedOutController: + public: true + + SimpleSAML\Module\casserver\Controller\LoggedInController: public: true \ No newline at end of file diff --git a/src/Controller/LoggedInController.php b/src/Controller/LoggedInController.php new file mode 100644 index 00000000..e4049f65 --- /dev/null +++ b/src/Controller/LoggedInController.php @@ -0,0 +1,45 @@ +config = $config ?? Configuration::getInstance(); + } + + /** + * Show Log out view. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + public function main(Request $request): Response + { + session_cache_limiter('nocache'); + return new Template($this->config, 'casserver:loggedIn.twig'); + } +} From 95af6871b514c504f53554874c1fb62bff323523 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 22 Nov 2024 12:04:16 +0200 Subject: [PATCH 14/57] Cas10 validate improvements --- src/Controller/Cas10Controller.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 63b32854..a7d72759 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -102,26 +102,26 @@ public function validate( $failed = false; $message = ''; - // No ticket - if ($serviceTicket === null) { + if (empty($serviceTicket)) { + // No ticket $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; $failed = true; - // This is not a service ticket } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { + // This is not a service ticket $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; $failed = true; - // the ticket has expired } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; $failed = true; } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) { - // The service we pass to the query parameters does not match the one in the ticket. + // The service url we passed to the query parameters does not match the one in the ticket. $message = 'Mismatching service parameters: expected ' . var_export($serviceTicket['service'], true) . ' but was: ' . var_export($service, true); $failed = true; - } elseif ($forceAuthn) { - // If the forceAuthn/renew is true + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket $message = 'Ticket was issued from single sign on session'; $failed = true; } @@ -157,6 +157,8 @@ public function validate( } /** + * Used by the unit tests + * * @return mixed */ public function getTicketStore(): mixed From d9ab4000d9466daad9517e256997ebea22978c6b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 25 Nov 2024 19:53:46 +0200 Subject: [PATCH 15/57] serviceValidate, proxyValidate, logoutController --- public/login.php | 2 +- public/proxyValidate.php | 33 --- public/serviceValidate.php | 33 --- public/utility/urlUtils.php | 22 ++ public/utility/validateTicket.php | 202 ------------- routing/routes/routes.php | 13 + routing/services/services.yml | 3 + src/Controller/Cas10Controller.php | 10 +- src/Controller/Cas20Controller.php | 268 ++++++++++++++++++ src/Controller/LoginController.php | 252 ++++++++++++++++ src/Controller/LogoutController.php | 5 + src/Http/XmlResponse.php | 17 ++ tests/src/Controller/Cas10ControllerTest.php | 21 +- tests/src/Controller/LogoutControllerTest.php | 18 +- 14 files changed, 612 insertions(+), 287 deletions(-) delete mode 100644 public/proxyValidate.php delete mode 100644 public/serviceValidate.php delete mode 100644 public/utility/validateTicket.php create mode 100644 src/Controller/Cas20Controller.php create mode 100644 src/Controller/LoginController.php create mode 100644 src/Http/XmlResponse.php diff --git a/public/login.php b/public/login.php index 5b9798ab..126b7706 100644 --- a/public/login.php +++ b/public/login.php @@ -226,7 +226,7 @@ $_GET[$ticketName] = $serviceTicket['id']; // We want to capture the output from echo used in validateTicket ob_start(); - require_once 'utility/validateTicket.php'; + echo casServiceValidate($serviceTicket['id'], $serviceUrl); $casResponse = ob_get_contents(); ob_end_clean(); echo '
' . htmlspecialchars($casResponse) . '
'; diff --git a/public/proxyValidate.php b/public/proxyValidate.php deleted file mode 100644 index 01ab183d..00000000 --- a/public/proxyValidate.php +++ /dev/null @@ -1,33 +0,0 @@ -addURLParameters( + Module::getModuleURL('casserver/serviceValidate.php'), + compact('ticket', 'service'), + ); + + return $httpUtils->fetch($url); +} diff --git a/public/utility/validateTicket.php b/public/utility/validateTicket.php deleted file mode 100644 index 0d639d26..00000000 --- a/public/utility/validateTicket.php +++ /dev/null @@ -1,202 +0,0 @@ -getOptionalValue( - 'ticketstore', - ['class' => 'casserver:FileSystemTicketStore'], - ); - $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @var TicketStore $ticketStore */ - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @var TicketFactory $ticketFactory */ - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $serviceTicket = $ticketStore->getTicket($_GET['ticket']); - - /** - * @psalm-suppress UndefinedGlobalVariable - * @psalm-suppress TypeDoesNotContainType - */ - if ( - !is_null($serviceTicket) && ($ticketFactory->isServiceTicket($serviceTicket) || - ($ticketFactory->isProxyTicket($serviceTicket) && $method === 'proxyValidate')) - ) { - $ticketStore->deleteTicket($_GET['ticket']); - - $attributes = $serviceTicket['attributes']; - - if ( - !$ticketFactory->isExpired($serviceTicket) && - sanitize($serviceTicket['service']) == sanitize($serviceUrl) && - (!$forceAuthn || $serviceTicket['forceAuthn']) - ) { - $protocol->setAttributes($attributes); - - if (isset($_GET['pgtUrl'])) { - $sessionTicket = $ticketStore->getTicket($serviceTicket['sessionId']); - - $pgtUrl = $_GET['pgtUrl']; - - if ( - !is_null($sessionTicket) && $ticketFactory->isSessionTicket($sessionTicket) && - !$ticketFactory->isExpired($sessionTicket) - ) { - $proxyGrantingTicket = $ticketFactory->createProxyGrantingTicket([ - 'userName' => $serviceTicket['userName'], - 'attributes' => $attributes, - 'forceAuthn' => false, - 'proxies' => array_merge([$serviceUrl], $serviceTicket['proxies']), - 'sessionId' => $serviceTicket['sessionId'], - ]); - $httpUtils = new Utils\HTTP(); - try { - $httpUtils->fetch($pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . - '&pgtId=' . $proxyGrantingTicket['id']); - - $protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - - $ticketStore->addTicket($proxyGrantingTicket); - } catch (Exception $e) { - // Fall through - } - } - } - - echo $protocol->getValidateSuccessResponse($serviceTicket['userName']); - } else { - if ($ticketFactory->isExpired($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' has expired'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - if (sanitize($serviceTicket['service']) != sanitize($serviceUrl)) { - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($serviceUrl, true); - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - } else { - if ($serviceTicket['forceAuthn'] != $forceAuthn) { - $message = 'Ticket was issue from single sign on session'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - Logger::error('casserver:' . $method . ': internal server error.'); - - echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, 'Unknown internal error'); - } - } - } - } - } else { - if (is_null($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' not recognized'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - /** - * @psalm-suppress UndefinedGlobalVariable - * @psalm-suppress TypeDoesNotContainType - * @psalm-suppress RedundantCondition - */ - if ($ticketFactory->isProxyTicket($serviceTicket) && ($method === 'serviceValidate')) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . - ' is a proxy ticket. Use proxyValidate instead.'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } else { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . ' is not a service ticket'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_TICKET, $message); - } - } - } - } catch (Exception $e) { - Logger::error( - 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true), - ); - - echo $protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $e->getMessage()); - } -} else { - if (!array_key_exists('service', $_GET)) { - $message = 'Missing service parameter: [service]'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message); - } else { - $message = 'Missing ticket parameter: [ticket]'; - - Logger::debug('casserver:' . $message); - - echo $protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message); - } -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 31a14806..9488594c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\casserver\Codebooks\LegacyRoutesEnum; use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; +use SimpleSAML\Module\casserver\Controller\Cas20Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; @@ -21,6 +22,12 @@ // New Routes $routes->add(RoutesEnum::Validate->name, RoutesEnum::Validate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(RoutesEnum::ServiceValidate->name, RoutesEnum::ServiceValidate->value) + ->controller([Cas20Controller::class, 'serviceValidate']) + ->methods(['GET']); + $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value) + ->controller([Cas20Controller::class, 'proxyValidate']) + ->methods(['GET']); $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value) ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) @@ -31,6 +38,12 @@ // Legacy Routes $routes->add(LegacyRoutesEnum::LegacyValidate->name, LegacyRoutesEnum::LegacyValidate->value) ->controller([Cas10Controller::class, 'validate']); + $routes->add(LegacyRoutesEnum::LegacyServiceValidate->name, LegacyRoutesEnum::LegacyServiceValidate->value) + ->controller([Cas20Controller::class, 'serviceValidate']) + ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value) + ->controller([Cas20Controller::class, 'proxyValidate']) + ->methods(['GET']); $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) diff --git a/routing/services/services.yml b/routing/services/services.yml index 6b968fc5..94d335b6 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -18,6 +18,9 @@ services: SimpleSAML\Module\casserver\Controller\Cas10Controller: public: true + SimpleSAML\Module\casserver\Controller\Cas20Controller: + public: true + SimpleSAML\Module\casserver\Controller\LogoutController: public: true diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index a7d72759..b270459a 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -25,6 +25,9 @@ class Cas10Controller /** @var Configuration */ protected Configuration $casConfig; + /** @var Configuration */ + protected Configuration $sspConfig; + /** @var Cas10 */ protected Cas10 $cas10Protocol; @@ -35,15 +38,18 @@ class Cas10Controller protected mixed $ticketStore; /** + * @param Configuration|null $sspConfig * @param Configuration|null $casConfig - * @param $ticketStore + * @param null $ticketStore * * @throws \Exception */ public function __construct( + Configuration $sspConfig = null, Configuration $casConfig = null, $ticketStore = null, ) { + $this->sspConfig = $sspConfig ?? Configuration::getInstance(); $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ @@ -127,7 +133,7 @@ public function validate( } if ($failed) { - Logger::error('casserver:validate: ' . $message, true); + Logger::error('casserver:validate: ' . $message); return new Response( $this->cas10Protocol->getValidateFailureResponse(), Response::HTTP_BAD_REQUEST, diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php new file mode 100644 index 00000000..bbe879d7 --- /dev/null +++ b/src/Controller/Cas20Controller.php @@ -0,0 +1,268 @@ +sspConfig = $sspConfig ?? Configuration::getInstance(); + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->cas20Protocol = new Cas20($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + /** @psalm-suppress InvalidStringClass */ + $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + } + + /** + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function serviceValidate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + $request, + 'serviceValidate', + $renew, + $ticket, + $service, + $pgtUrl, + ); + } + + /** + * @param Request $request + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function proxyValidate( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $pgtUrl = null, + ): XmlResponse { + return $this->validate( + $request, + 'proxyValidate', + $renew, + $ticket, + $service, + $pgtUrl, + ); + } + + /** + * @param Request $request + * @param string $method + * @param bool $renew + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function validate( + Request $request, + string $method, + bool $renew = false, + ?string $ticket = null, + ?string $service = null, + ?string $pgtUrl = null, + ): XmlResponse { + $forceAuthn = $renew; + $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + + // Check if any of the required query parameters are missing + if ($serviceUrl === null || $ticket === null) { + $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; + $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + Logger::debug($message); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + Response::HTTP_BAD_REQUEST, + ); + } + + try { + // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // unserialization handlers. + $serviceTicket = $this->ticketStore->getTicket($ticket); + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + } catch (\Exception $e) { + $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + Logger::error($message); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $failed = false; + $message = ''; + if (empty($serviceTicket)) { + // No ticket + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { + $message = 'Ticket ' . var_export($_GET['ticket'], true) . + ' is a proxy ticket. Use proxyValidate instead.'; + $failed = true; + } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { + // This is not a service ticket + $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $failed = true; + } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired + $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $failed = true; + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { + // The service url we passed to the query parameters does not match the one in the ticket. + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($serviceUrl, true); + $failed = true; + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); + + ob_start(); + echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); + $responseContent = ob_get_clean(); + + return new XmlResponse( + $responseContent, + Response::HTTP_BAD_REQUEST, + ); + } + + $attributes = $serviceTicket['attributes']; + $this->cas20Protocol->setAttributes($attributes); + + if (isset($pgtUrl)) { + $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); + if ( + $sessionTicket !== null + && $this->ticketFactory->isSessionTicket($sessionTicket) + && !$this->ticketFactory->isExpired($sessionTicket) + ) { + $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( + [ + 'userName' => $serviceTicket['userName'], + 'attributes' => $attributes, + 'forceAuthn' => false, + 'proxies' => array_merge( + [$serviceUrl], + $serviceTicket['proxies'], + ), + 'sessionId' => $serviceTicket['sessionId'], + ], + ); + try { + $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (Exception $e) { + // Fall through + } + } + } + + ob_start(); + echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); + $successContent = ob_get_clean(); + + return new XmlResponse( + $successContent, + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 00000000..1814615b --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,252 @@ +sspConfig = $sspConfig ?? Configuration::getInstance(); + $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); + + $this->serviceValidator = new ServiceValidator($this->casConfig); + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' + . explode(':', $ticketStoreConfig['class'])[1]; + // Ticket Store + $this->ticketStore = new $ticketStoreClass($this->casConfig); + // Processing Chain Factory + $processingChainFactory = new ProcessingChainFactory($this->casconfig); + // Attribute Extractor + $this->attributeExtractor = new AttributeExtractor($this->casconfig, $processingChainFactory); + } + + /** + * + * @param Request $request + * @param bool $renew + * @param bool $gateway + * @param string|null $service + * @param string|null $scope + * @param string|null $language + * @param string|null $entityId + * + * @return RedirectResponse|null + * @throws \Exception + */ + public function login( + Request $request, + #[MapQueryParameter] bool $renew = false, + #[MapQueryParameter] bool $gateway = false, + #[MapQueryParameter] string $service = null, + #[MapQueryParameter] string $scope = null, + #[MapQueryParameter] string $language = null, + #[MapQueryParameter] string $entityId = null, + #[MapQueryParameter] string $debugMode = null, + ): RedirectResponse|null { + $this->handleServiceConfiguration($service); + $this->handleScope($scope); + $this->handleLanguage($language); + + if ($request->query->has(ProcessingChain::AUTHPARAM)) { + $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); + } + + // Get the ticket from the session + $session = Session::getSessionFromRequest(); + $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + + // Construct the ticket name + $defaultTicketName = isset($service) ? 'ticket' : 'SAMLart'; + $ticketName = $this->casconfig->getOptionalValue('ticketName', $defaultTicketName); + + + $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + } + + public function handleDebugMode( + Request $request, + ?string $debugMode, + string $ticketName, + array $serviceTicket, + ): void { + // Check if the debugMode is supported + if (!\in_array($debugMode, ['true', 'samlValidate'], true)) { + return; + } + + if ($debugMode === 'true') { + // Service validate CAS20 + $this->httpUtils->redirectTrustedURL( + Module::getModuleURL('/cas/serviceValidate.php'), + [ ...$request->getQueryParams(), $ticketName => $serviceTicket['id'] ], + ); + } + + // samlValidate Mode + $samlValidate = new SamlValidateResponder(); + $samlResponse = $samlValidate->convertToSaml($serviceTicket); + $soap = $samlValidate->wrapInSoap($samlResponse); + echo '
' . htmlspecialchars((string)$soap) . '
'; + } + + /** + * @param array|null $sessionTicket + * + * @return string + */ + public function getReturnUrl(?array $sessionTicket): string + { + // Parse the query parameters and return them in an array + $query = parseQueryParameters($sessionTicket); + // Construct the ReturnTo URL + return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); + } + + /** + * @param string|null $service + * + * @return void + * @throws \Exception + */ + public function handleServiceConfiguration(?string $service): void + { + // todo: Check request objec the TARGET + $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + if ($serviceUrl === null) { + return; + } + $serviceCasConfig = $this->serviceValidator->checkServiceURL(sanitize($serviceUrl)); + if (!isset($serviceCasConfig)) { + $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' . + var_export($serviceUrl, true); + Logger::debug('casserver:' . $message); + + throw new \Exception($message); + } + + // Override the cas configuration to use for this service + $this->casconfig = $serviceCasConfig; + } + + /** + * @param string|null $language + * + * @return void + */ + public function handleLanguage(?string $language): void + { + // If null, do nothing + if ($language === null) { + return; + } + + Language::setLanguageCookie($language); + } + + /** + * @param string|null $scope + * + * @return void + * @throws \Exception + */ + public function handleScope(?string $scope): void + { + // If null, do nothing + if ($scope === null) { + return; + } + + // Get the scopes from the configuration + $scopes = $this->casconfig->getOptionalValue('scopes', []); + + // Fail + if (!isset($scopes[$scope])) { + $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . + var_export($_GET['scope'], true); + Logger::debug('casserver:' . $message); + + throw new \Exception($message); + } + + // Set the idplist from the scopes + $this->idpList = $scopes[$scope]; + } +} diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 6623a28f..2461ac53 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -28,6 +28,9 @@ class LogoutController /** @var Configuration */ protected Configuration $casConfig; + /** @var Configuration */ + protected Configuration $sspConfig; + /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -50,11 +53,13 @@ class LogoutController * @throws \Exception */ public function __construct( + Configuration $sspConfig = null, // Facilitate testing Configuration $casConfig = null, Simple $source = null, SspContainer $container = null, ) { + $this->sspConfig = $sspConfig ?? Configuration::getInstance(); $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->container = $container ?? new SspContainer(); diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php new file mode 100644 index 00000000..8480b659 --- /dev/null +++ b/src/Http/XmlResponse.php @@ -0,0 +1,17 @@ + 'text/xml; charset=ISO-8859-1', + ])); + } +} diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index ca517231..e398c640 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -23,8 +23,11 @@ class Cas10ControllerTest extends TestCase private string $sessionId; + private Configuration $sspConfig; + protected function setUp(): void { + $this->sspConfig = Configuration::getConfig('config.php'); $this->sessionId = session_create_id(); $this->moduleConfig = [ 'ticketstore' => [ @@ -95,7 +98,7 @@ public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(400, $response->getStatusCode()); @@ -122,7 +125,7 @@ public function getTicket(string $ticketId): ?array } }; - $cas10Controller = new Cas10Controller($config, $ticketStore); + $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(500, $response->getStatusCode()); @@ -142,7 +145,7 @@ public function testReturnBadRequestOnTicketNotExist(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(400, $response->getStatusCode()); @@ -162,7 +165,7 @@ public function testReturnBadRequestOnTicketExpired(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -186,7 +189,7 @@ public function testReturnBadRequestOnTicketNotService(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -210,7 +213,7 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -234,7 +237,7 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -258,7 +261,7 @@ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); @@ -282,7 +285,7 @@ public function testSuccessfullValidation(): void parameters: $params, ); - $cas10Controller = new Cas10Controller($config); + $cas10Controller = new Cas10Controller($this->sspConfig, $config); $ticketStore = $cas10Controller->getTicketStore(); $ticketStore->addTicket($this->ticket); $response = $cas10Controller->validate($request, ...$params); diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index b36263ea..69ac65e3 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -21,6 +21,8 @@ class LogoutControllerTest extends TestCase private SspContainer $sspContainer; + private Configuration $sspConfig; + protected function setUp(): void { $this->authSimpleMock = $this->getMockBuilder(Simple::class) @@ -39,6 +41,8 @@ protected function setUp(): void 'directory' => __DIR__ . '../../../../tests/ticketcache', ], ]; + + $this->sspConfig = Configuration::getConfig('config.php'); } public static function setUpBeforeClass(): void @@ -60,7 +64,7 @@ public function testLogoutNotAllowed(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Logout not allowed'); - $controller = new LogoutController($config, $this->authSimpleMock); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); $controller->logout(Request::create('/')); } @@ -73,7 +77,7 @@ public function testLogoutNoRedirectUrlOnSkipLogout(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Required URL query parameter [url] not provided. (CAS Server)'); - $controller = new LogoutController($config, $this->authSimpleMock); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock); $controller->logout(Request::create('/')); } @@ -93,7 +97,7 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void [], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $logoutUrl = Module::getModuleURL('casserver/logout.php'); @@ -119,7 +123,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void [], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } @@ -140,7 +144,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void ['url' => $urlParam], ); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $request = Request::create( uri: $logoutUrl, parameters: ['url' => $urlParam], @@ -161,7 +165,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut.php'); - $controller = new LogoutController($config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); } @@ -172,7 +176,7 @@ public function testTicketIdGetsDeletedOnLogout(): void $config = Configuration::loadFromArray($this->moduleConfig); $controllerMock = $this->getMockBuilder(LogoutController::class) - ->setConstructorArgs([$config, $this->authSimpleMock, $this->sspContainer]) + ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer]) ->onlyMethods(['getSession']) ->getMock(); From bd3649f8c4f6a38b4cc7ed44df8596526a4befa6 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 26 Nov 2024 19:47:03 +0200 Subject: [PATCH 16/57] samlValidate.php controller and tests --- composer.json | 1 + public/samlValidate.php | 36 -- routing/routes/routes.php | 7 + routing/services/services.yml | 14 +- src/Controller/Cas10Controller.php | 28 +- src/Controller/Cas20Controller.php | 73 ++-- src/Controller/Cas30Controller.php | 134 +++++++ src/Controller/LoginController.php | 5 +- src/Controller/LogoutController.php | 12 +- ...892424614ae738153cf6fda6ea372f54489870b8eb | 1 + tests/resources/saml/samlRequest.xml | 8 + tests/src/Controller/Cas30ControllerTest.php | 361 ++++++++++++++++++ 12 files changed, 589 insertions(+), 91 deletions(-) delete mode 100644 public/samlValidate.php create mode 100644 src/Controller/Cas30Controller.php create mode 100644 tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb create mode 100644 tests/resources/saml/samlRequest.xml create mode 100644 tests/src/Controller/Cas30ControllerTest.php diff --git a/composer.json b/composer.json index bc5196b3..7dd8fda7 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "ext-SimpleXML": "*", "ext-pdo": "*", "ext-session": "*", + "ext-xml": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", diff --git a/public/samlValidate.php b/public/samlValidate.php deleted file mode 100644 index fdb50128..00000000 --- a/public/samlValidate.php +++ /dev/null @@ -1,36 +0,0 @@ -(.*)validateAndDeleteTicket($ticketId, $target); -if (!is_array($ticket)) { - throw new \Exception('Error loading ticket'); -} -$samlValidator = new SamlValidateResponder(); -$response = $samlValidator->convertToSaml($ticket); -$soap = $samlValidator->wrapInSoap($response); - -echo strval($soap); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 9488594c..7d6cac67 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\casserver\Codebooks\RoutesEnum; use SimpleSAML\Module\casserver\Controller\Cas10Controller; use SimpleSAML\Module\casserver\Controller\Cas20Controller; +use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; use SimpleSAML\Module\casserver\Controller\LogoutController; @@ -28,6 +29,9 @@ $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->methods(['GET']); + $routes->add(RoutesEnum::SamlValidate->name, RoutesEnum::SamlValidate->value) + ->controller([Cas30Controller::class, 'samlValidate']) + ->methods(['POST']); $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value) ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) @@ -44,6 +48,9 @@ $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacySamlValidate->name, LegacyRoutesEnum::LegacySamlValidate->value) + ->controller([Cas30Controller::class, 'samlValidate']) + ->methods(['POST']); $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value) ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) diff --git a/routing/services/services.yml b/routing/services/services.yml index 94d335b6..4af98b89 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -8,11 +8,12 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. SimpleSAML\Module\casserver\Controller\: - resource: '../../src/Controller/*' - exclude: - - '../../src/Controller/Traits/*' - public: true - tags: ['controller.service_arguments'] + resource: '../../src/Controller/*' + exclude: + - '../../src/Controller/Traits/*' + public: true + tags: [ 'controller.service_arguments' ] + # Explicit service definitions for CasServer Controllers SimpleSAML\Module\casserver\Controller\Cas10Controller: @@ -21,6 +22,9 @@ services: SimpleSAML\Module\casserver\Controller\Cas20Controller: public: true + SimpleSAML\Module\casserver\Controller\Cas30Controller: + public: true + SimpleSAML\Module\casserver\Controller\LogoutController: public: true diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index b270459a..66ee55c1 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -25,9 +25,6 @@ class Cas10Controller /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var Cas10 */ protected Cas10 $cas10Protocol; @@ -38,19 +35,22 @@ class Cas10Controller protected mixed $ticketStore; /** - * @param Configuration|null $sspConfig + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param null $ticketStore * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testin. + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->cas10Protocol = new Cas10($this->casConfig); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -67,21 +67,25 @@ public function __construct( /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary credentials. + * It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login. + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued * * @return Response */ public function validate( Request $request, - #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, + #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $service = null, ): Response { - $forceAuthn = $renew; // Check if any of the required query parameters are missing + // Even though we can delegate the check to Symfony's `MapQueryParameter` we cannot return + // the failure response needed. As a result, we allow a default value, and we handle the missing + // values afterwards. if ($service === null || $ticket === null) { $messagePostfix = $service === null ? 'service' : 'ticket'; Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index bbe879d7..2f3ba1f4 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -27,9 +27,6 @@ class Cas20Controller /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var Cas20 */ protected Cas20 $cas20Protocol; @@ -40,18 +37,22 @@ class Cas20Controller protected mixed $ticketStore; /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param $ticketStore * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testing + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->cas20Protocol = new Cas20($this->casConfig); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -62,65 +63,74 @@ public function __construct( ); $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; - /** @psalm-suppress InvalidStringClass */ $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl + * @param string $TARGET // todo: this should go away + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback * * @return XmlResponse */ public function serviceValidate( Request $request, + #[MapQueryParameter] string $TARGET = '', #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, #[MapQueryParameter] ?string $pgtUrl = null, ): XmlResponse { return $this->validate( - $request, - 'serviceValidate', - $renew, - $ticket, - $service, - $pgtUrl, + request: $request, + method: 'serviceValidate', + target: $TARGET, + renew: $renew, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, ); } /** * @param Request $request - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * + * @param string $TARGET // todo: this should go away??? + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback * @return XmlResponse */ public function proxyValidate( Request $request, + #[MapQueryParameter] string $TARGET = '', #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, #[MapQueryParameter] ?string $pgtUrl = null, ): XmlResponse { return $this->validate( - $request, - 'proxyValidate', - $renew, - $ticket, - $service, - $pgtUrl, + request: $request, + method: 'proxyValidate', + target: $TARGET, + renew: $renew, + ticket: $ticket, + service: $service, + pgtUrl: $pgtUrl, ); } /** * @param Request $request * @param string $method + * @param string $target * @param bool $renew * @param string|null $ticket * @param string|null $service @@ -131,13 +141,15 @@ public function proxyValidate( public function validate( Request $request, string $method, + string $target, bool $renew = false, ?string $ticket = null, ?string $service = null, ?string $pgtUrl = null, ): XmlResponse { $forceAuthn = $renew; - $serviceUrl = $service ?? $_GET['TARGET'] ?? null; + // todo: According to the protocol, there is no target??? Why are we using it? + $serviceUrl = $service ?? $target ?? null; // Check if any of the required query parameters are missing if ($serviceUrl === null || $ticket === null) { @@ -250,12 +262,13 @@ public function validate( $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); $this->ticketStore->addTicket($proxyGrantingTicket); - } catch (Exception $e) { + } catch (\Exception $e) { // Fall through } } } + // TODO: Replace with string casting ob_start(); echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); $successContent = ob_get_clean(); diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php new file mode 100644 index 00000000..d71ee46e --- /dev/null +++ b/src/Controller/Cas30Controller.php @@ -0,0 +1,134 @@ +casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + $this->cas20Protocol = new Cas20($this->casConfig); + $this->ticketValidator = $ticketValidator ?? new TicketValidator($this->casConfig); + $this->validateResponder = new SamlValidateResponder(); + } + + + /** + * POST /casserver/samlValidate?TARGET= + * Host: cas.example.com + * Content-Length: 491 + * Content-Type: text/xml + * + * @param Request $request + * @param string $TARGET URL encoded service identifier of the back-end service. + * + * @throws \RuntimeException + * @return XmlResponse + * @link https://apereo.github.io/cas/7.1.x/protocol/CAS-Protocol-Specification.html#42-samlvalidate-cas-30 + */ + public function samlValidate( + Request $request, + #[MapQueryParameter] string $TARGET, + ): XmlResponse { + // From SAML2\SOAP::receive() + $postBody = $request->getContent(); + if (empty($postBody)) { + throw new \RuntimeException('samlValidate expects a soap body.'); + } + + // SAML request values + // + // samlp:Request + // - RequestID [REQUIRED] - unique identifier for the request + // - IssueInstant [REQUIRED] - timestamp of the request + // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service + + $ticketParser = xml_parser_create(); + xml_parser_set_option($ticketParser, XML_OPTION_CASE_FOLDING, 0); + xml_parser_set_option($ticketParser, XML_OPTION_SKIP_WHITE, 1); + xml_parse_into_struct($ticketParser, $postBody, $values, $tags); + xml_parser_free($ticketParser); + + // Check for the required saml attributes + $samlRequestAttributes = $values[ $tags['samlp:Request'][0] ]['attributes']; + if (!isset($samlRequestAttributes['RequestID'])) { + throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); + } elseif (!isset($samlRequestAttributes['IssueInstant'])) { + throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + } + + if ( + !isset($tags['samlp:AssertionArtifact']) + || empty($values[$tags['samlp:AssertionArtifact'][0]]['value']) + ) { + throw new \RuntimeException('Missing ticketId in AssertionArtifact'); + } + + $ticketId = $values[$tags['samlp:AssertionArtifact'][0]]['value']; + Logger::debug('samlvalidate: Checking ticket ' . $ticketId); + + try { + // validateAndDeleteTicket might throw a CasException. In order to avoid third party modules + // dependencies, we will catch and rethrow the Exception. + $ticket = $this->ticketValidator->validateAndDeleteTicket($ticketId, $TARGET); + } catch (\Exception $e) { + throw new \RuntimeException($e->getMessage()); + } + if (!\is_array($ticket)) { + throw new \RuntimeException('Error loading ticket'); + } + + $response = $this->validateResponder->convertToSaml($ticket); + $soap = $this->validateResponder->wrapInSoap($response); + + return new XmlResponse( + (string)$soap, + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 1814615b..c486e850 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -10,6 +10,7 @@ use SimpleSAML\Locale\Language; use SimpleSAML\Logger; use SimpleSAML\Module; +use SimpleSAML\Module\casserver\Cas\AttributeExtractor; use SimpleSAML\Module\casserver\Cas\Factories\ProcessingChainFactory; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; @@ -175,7 +176,7 @@ public function handleDebugMode( public function getReturnUrl(?array $sessionTicket): string { // Parse the query parameters and return them in an array - $query = parseQueryParameters($sessionTicket); + $query = $this->parseQueryParameters($sessionTicket); // Construct the ReturnTo URL return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); } @@ -193,7 +194,7 @@ public function handleServiceConfiguration(?string $service): void if ($serviceUrl === null) { return; } - $serviceCasConfig = $this->serviceValidator->checkServiceURL(sanitize($serviceUrl)); + $serviceCasConfig = $this->serviceValidator->checkServiceURL($this->sanitize($serviceUrl)); if (!isset($serviceCasConfig)) { $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' . var_export($serviceUrl, true); diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 2461ac53..60b0990b 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -28,9 +28,6 @@ class LogoutController /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -53,14 +50,17 @@ class LogoutController * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, // Facilitate testing Configuration $casConfig = null, Simple $source = null, SspContainer $container = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since + // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor + // argument in order to facilitate testin. + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->container = $container ?? new SspContainer(); diff --git a/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb new file mode 100644 index 00000000..f04b8568 --- /dev/null +++ b/tests/resources/saml/ST-892424614ae738153cf6fda6ea372f54489870b8eb @@ -0,0 +1 @@ +a:8:{s:2:"id";s:45:"ST-892424614ae738153cf6fda6ea372f54489870b8eb";s:11:"validBefore";i:9939632015;s:7:"service";s:233:"https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3Ahttps%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver";s:10:"forceAuthn";b:0;s:8:"userName";s:32:"107159103605108465131@google.com";s:10:"attributes";a:1:{s:22:"eduPersonPrincipalName";a:1:{i:0;s:32:"107159103605108465131@google.com";}}s:7:"proxies";a:0:{}s:9:"sessionId";s:26:"23n62fa26kck94olh1cpcftsq5";} \ No newline at end of file diff --git a/tests/resources/saml/samlRequest.xml b/tests/resources/saml/samlRequest.xml new file mode 100644 index 00000000..6de610ab --- /dev/null +++ b/tests/resources/saml/samlRequest.xml @@ -0,0 +1,8 @@ + + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php new file mode 100644 index 00000000..99bf3ae3 --- /dev/null +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -0,0 +1,361 @@ +sspConfig = Configuration::getConfig('config.php'); + $this->sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + // Hard code the ticket store + $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig)); + + $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class) + ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)]) + ->onlyMethods(['validateAndDeleteTicket']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 9999999999, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + public function testNoSoapBody(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: '', + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('samlValidate expects a soap body.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSoapBodyMissingRequestIdAttribute(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing RequestID samlp:Request attribute.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSoapBodyMissingIssueInstantAttribute(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing IssueInstant samlp:Request attribute.'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSoapBodyMissingTicketId(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + + + + +SOAP; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing ticketId in AssertionArtifact'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testCasValidateAndDeleteTicketThrowsException(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + ST-892424614ae738153cf6fda6ea372f54489870b8eb + + + +SOAP; + + $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' + . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' + . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $this->ticketValidatorMock + ->expects($this->once()) + ->method('validateAndDeleteTicket') + ->willThrowException(new \RuntimeException('Cas validateAndDeleteTicket failed')); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + $this->ticketValidatorMock, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cas validateAndDeleteTicket failed'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testUnableToLoadTicket(): void + { + $this->ticketStore->addTicket(['id' => $this->ticket['id']]); + // We add the ticket. We need to make + + $ticketId = $this->ticket['id']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + $ticketId + + + +SOAP; + + $target = 'https://myservice.com/abcd'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $this->ticketValidatorMock + ->expects($this->once()) + ->method('validateAndDeleteTicket') + ->willReturn('i am a string'); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + $this->ticketValidatorMock, + ); + + // Exception expected + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Error loading ticket'); + + $cas30Controller->samlValidate($this->samlValidateRequest, $target); + } + + public function testSuccessfullValidation(): void + { + $this->ticketStore->addTicket($this->ticket); + $ticketId = $this->ticket['id']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = << + + + + $ticketId + + + +SOAP; + + $target = 'https://myservice.com/abcd'; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/samlValidate'), + method: 'POST', + parameters: ['TARGET' => $target], + content: $samlRequest, + ); + + $cas30Controller = new Cas30Controller( + $this->sspConfig, + $casconfig, + ); + + $resp = $cas30Controller->samlValidate($this->samlValidateRequest, $target); + $this->assertEquals($resp->getStatusCode(), Response::HTTP_OK); + $this->assertStringContainsString( + 'eduPersonPrincipalName@google.com', + $resp->getContent(), + ); + } +} From 63269c8e25fa692356c4733eec9a3bbc14cb129d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 26 Nov 2024 19:55:01 +0200 Subject: [PATCH 17/57] fix psalm errors --- tests/src/Controller/Cas10ControllerTest.php | 1 + tests/src/Controller/Cas30ControllerTest.php | 33 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index e398c640..1acc1c4e 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -125,6 +125,7 @@ public function getTicket(string $ticketId): ?array } }; + /** @psalm-suppress InvalidArgument */ $cas10Controller = new Cas10Controller($this->sspConfig, $config, $ticketStore); $response = $cas10Controller->validate($request, ...$params); diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 99bf3ae3..8fed306f 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -33,7 +33,7 @@ class Cas30ControllerTest extends TestCase private array $ticket; /** - * @throws Exception + * @throws \Exception */ protected function setUp(): void { @@ -80,6 +80,10 @@ protected function setUp(): void ]; } + /** + * @return void + * @throws \Exception + */ public function testNoSoapBody(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -106,6 +110,10 @@ public function testNoSoapBody(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingRequestIdAttribute(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -145,6 +153,10 @@ public function testSoapBodyMissingRequestIdAttribute(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingIssueInstantAttribute(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -184,6 +196,10 @@ public function testSoapBodyMissingIssueInstantAttribute(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSoapBodyMissingTicketId(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -224,6 +240,11 @@ public function testSoapBodyMissingTicketId(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + + /** + * @return void + * @throws \Exception + */ public function testCasValidateAndDeleteTicketThrowsException(): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); @@ -252,6 +273,7 @@ public function testCasValidateAndDeleteTicketThrowsException(): void content: $samlRequest, ); + /** @psalm-suppress UndefinedMethod */ $this->ticketValidatorMock ->expects($this->once()) ->method('validateAndDeleteTicket') @@ -270,6 +292,10 @@ public function testCasValidateAndDeleteTicketThrowsException(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testUnableToLoadTicket(): void { $this->ticketStore->addTicket(['id' => $this->ticket['id']]); @@ -300,6 +326,7 @@ public function testUnableToLoadTicket(): void content: $samlRequest, ); + /** @psalm-suppress UndefinedMethod */ $this->ticketValidatorMock ->expects($this->once()) ->method('validateAndDeleteTicket') @@ -318,6 +345,10 @@ public function testUnableToLoadTicket(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } + /** + * @return void + * @throws \Exception + */ public function testSuccessfullValidation(): void { $this->ticketStore->addTicket($this->ticket); From e1b3012e2a8522c4ae98151282d39b4cb7a35518 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 27 Nov 2024 19:52:17 +0200 Subject: [PATCH 18/57] move proxy.php to Cas20Controller action --- public/proxy.php | 115 -------- routing/routes/routes.php | 6 + src/Cas/Protocol/Cas20.php | 2 +- src/Controller/Cas20Controller.php | 115 ++++++-- tests/src/Controller/Cas20ControllerTest.php | 283 +++++++++++++++++++ tests/src/Controller/Cas30ControllerTest.php | 1 - 6 files changed, 383 insertions(+), 139 deletions(-) delete mode 100644 public/proxy.php create mode 100644 tests/src/Controller/Cas20ControllerTest.php diff --git a/public/proxy.php b/public/proxy.php deleted file mode 100644 index ae4182f2..00000000 --- a/public/proxy.php +++ /dev/null @@ -1,115 +0,0 @@ -getOptionalValue('legal_target_service_urls', []); - -if ( - array_key_exists('targetService', $_GET) && - checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls) && array_key_exists('pgt', $_GET) -) { - $ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); - $ticketStoreClass = \SimpleSAML\Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - /** @psalm-suppress InvalidStringClass */ - $ticketStore = new $ticketStoreClass($casconfig); - - $ticketFactoryClass = \SimpleSAML\Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); - /** @psalm-suppress InvalidStringClass */ - $ticketFactory = new $ticketFactoryClass($casconfig); - - $proxyGrantingTicket = $ticketStore->getTicket($_GET['pgt']); - - if (!is_null($proxyGrantingTicket) && $ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) { - $sessionTicket = $ticketStore->getTicket($proxyGrantingTicket['sessionId']); - - if ( - !is_null($sessionTicket) && - $ticketFactory->isSessionTicket($sessionTicket) && - !$ticketFactory->isExpired($sessionTicket) - ) { - $proxyTicket = $ticketFactory->createProxyTicket( - ['service' => $_GET['targetService'], - 'forceAuthn' => $proxyGrantingTicket['forceAuthn'], - 'attributes' => $proxyGrantingTicket['attributes'], - 'proxies' => $proxyGrantingTicket['proxies'], - 'sessionId' => $proxyGrantingTicket['sessionId'], - ], - ); - - $ticketStore->addTicket($proxyTicket); - - echo $protocol->getProxySuccessResponse($proxyTicket['id']); - } else { - $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' has expired'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } - } elseif (!$ticketFactory->isProxyGrantingTicket($proxyGrantingTicket)) { - $message = 'Not a valid proxy granting ticket id: ' . var_export($_GET['pgt'], true); - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } else { - $message = 'Ticket ' . var_export($_GET['pgt'], true) . ' not recognized'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse('BAD_PGT', $message); - } -} elseif (!array_key_exists('targetService', $_GET)) { - $message = 'Missing target service parameter [targetService]'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} elseif (!checkServiceURL(sanitize($_GET['targetService']), $legal_target_service_urls)) { - $message = 'Target service parameter not listed as a legal service: [targetService] = ' . - var_export($_GET['targetService'], true); - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} else { - $message = 'Missing proxy granting ticket parameter: [pgt]'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - echo $protocol->getProxyFailureResponse(C::ERR_INVALID_REQUEST, $message); -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 7d6cac67..a60b6ff1 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -29,6 +29,9 @@ $routes->add(RoutesEnum::ProxyValidate->name, RoutesEnum::ProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->methods(['GET']); + $routes->add(RoutesEnum::Proxy->name, RoutesEnum::Proxy->value) + ->controller([Cas20Controller::class, 'proxy']) + ->methods(['GET']); $routes->add(RoutesEnum::SamlValidate->name, RoutesEnum::SamlValidate->value) ->controller([Cas30Controller::class, 'samlValidate']) ->methods(['POST']); @@ -48,6 +51,9 @@ $routes->add(LegacyRoutesEnum::LegacyProxyValidate->name, LegacyRoutesEnum::LegacyProxyValidate->value) ->controller([Cas20Controller::class, 'proxyValidate']) ->methods(['GET']); + $routes->add(LegacyRoutesEnum::LegacyProxy->name, LegacyRoutesEnum::LegacyProxy->value) + ->controller([Cas20Controller::class, 'proxy']) + ->methods(['GET']); $routes->add(LegacyRoutesEnum::LegacySamlValidate->name, LegacyRoutesEnum::LegacySamlValidate->value) ->controller([Cas30Controller::class, 'samlValidate']) ->methods(['POST']); diff --git a/src/Cas/Protocol/Cas20.php b/src/Cas/Protocol/Cas20.php index 4c63d1c8..23077392 100644 --- a/src/Cas/Protocol/Cas20.php +++ b/src/Cas/Protocol/Cas20.php @@ -185,7 +185,7 @@ public function getValidateFailureResponse(string $errorCode, string $explanatio public function getProxySuccessResponse(string $proxyTicketId): ServiceResponse { $proxyTicket = new ProxyTicket($proxyTicketId); - $proxySucces = new ProxySuccess($proxyTicket); + $proxySuccess = new ProxySuccess($proxyTicket); $serviceResponse = new ServiceResponse($proxySuccess); return $serviceResponse; diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 2f3ba1f4..f04c55e8 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -68,7 +68,7 @@ public function __construct( /** * @param Request $request - * @param string $TARGET // todo: this should go away + * @param string $TARGET * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed * if the service ticket was issued from the presentation of the user’s primary * credentials. It will fail if the ticket was issued from a single sign-on session. @@ -97,6 +97,94 @@ public function serviceValidate( ); } + /** + * /proxy provides proxy tickets to services that have + * acquired proxy-granting tickets and will be proxying authentication to back-end services. + * + * @param Request $request + * @param string|null $targetService [REQUIRED] - the service identifier of the back-end service. + * @param string|null $pgt [REQUIRED] - the proxy-granting ticket acquired by the service + * during service ticket or proxy ticket validation. + * + * @return XmlResponse + */ + public function proxy( + Request $request, + #[MapQueryParameter] ?string $targetService = null, + #[MapQueryParameter] ?string $pgt = null, + ): XmlResponse { + $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); + // Fail if + $message = match (true) { + // targetService pareameter is not defined + $targetService === null => 'Missing target service parameter [targetService]', + // pgt parameter is not defined + $pgt === null => 'Missing proxy granting ticket parameter: [pgt]', + !$this->checkServiceURL($this->sanitize($targetService), $legal_target_service_urls) => + "Target service parameter not listed as a legal service: [targetService] = {$targetService}", + default => null, + }; + + if (!empty($message)) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_REQUEST, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Get the ticket + $proxyGrantingTicket = $this->ticketStore->getTicket($pgt); + $message = match (true) { + // targetService parameter is not defined + $proxyGrantingTicket === null => "Ticket {$pgt} not recognized", + // pgt parameter is not defined + !$this->ticketFactory->isProxyGrantingTicket($proxyGrantingTicket) + => "Not a valid proxy granting ticket id: {$pgt}", + default => null, + }; + + if (!empty($message)) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Get the session id from the ticket + $sessionTicket = $this->ticketStore->getTicket($proxyGrantingTicket['sessionId']); + + if ( + $sessionTicket === null + || $this->ticketFactory->isSessionTicket($sessionTicket) === false + || $this->ticketFactory->isExpired($sessionTicket) + ) { + $message = "Ticket {$pgt} has expired"; + Logger::debug('casserver:' . $message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse('BAD_PGT', $message), + Response::HTTP_BAD_REQUEST, + ); + } + + $proxyTicket = $this->ticketFactory->createProxyTicket( + [ + 'service' => $targetService, + 'forceAuthn' => $proxyGrantingTicket['forceAuthn'], + 'attributes' => $proxyGrantingTicket['attributes'], + 'proxies' => $proxyGrantingTicket['proxies'], + 'sessionId' => $proxyGrantingTicket['sessionId'], + ], + ); + + $this->ticketStore->addTicket($proxyTicket); + + return new XmlResponse( + (string)$this->cas20Protocol->getProxySuccessResponse($proxyTicket['id']), + Response::HTTP_OK, + ); + } + /** * @param Request $request * @param string $TARGET // todo: this should go away??? @@ -157,12 +245,8 @@ public function validate( $message = "casserver: Missing service parameter: [{$messagePostfix}]"; Logger::debug($message); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_BAD_REQUEST, ); } @@ -178,12 +262,8 @@ public function validate( $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); Logger::error($message); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_INTERNAL_SERVER_ERROR, ); } @@ -222,12 +302,8 @@ public function validate( $finalMessage = 'casserver:validate: ' . $message; Logger::error($finalMessage); - ob_start(); - echo $this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message); - $responseContent = ob_get_clean(); - return new XmlResponse( - $responseContent, + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), Response::HTTP_BAD_REQUEST, ); } @@ -268,13 +344,8 @@ public function validate( } } - // TODO: Replace with string casting - ob_start(); - echo $this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']); - $successContent = ob_get_clean(); - return new XmlResponse( - $successContent, + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), Response::HTTP_OK, ); } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php new file mode 100644 index 00000000..5fc5a414 --- /dev/null +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -0,0 +1,283 @@ +sspConfig = Configuration::getConfig('config.php'); + $this->sessionId = session_create_id(); + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + ]; + + // Hard code the ticket store + $this->ticketStore = new FileSystemTicketStore(Configuration::loadFromArray($this->moduleConfig)); + + $this->ticketValidatorMock = $this->getMockBuilder(TicketValidator::class) + ->setConstructorArgs([Configuration::loadFromArray($this->moduleConfig)]) + ->onlyMethods(['validateAndDeleteTicket']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->ticket = [ + 'id' => 'ST-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; + } + + public static function queryParameterValues(): array + { + return [ + 'Only targetService query parameter' => [ + ['targetService' => 'https://myservice.com/abcd', 'pgt' => null], + 'Missing proxy granting ticket parameter: [pgt]', + ], + 'Only pgt query parameter' => [ + ['pgt' => '1234567', 'targetService' => null], + 'Missing target service parameter [targetService]', + ], + 'Has Neither pgt Nor targetService query parameters' => [ + ['pgt' => null, 'targetService' => null], + 'Missing target service parameter [targetService]', + ], + 'Target service query parameter not listed' => [ + ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd'], + 'Target service parameter not listed as a legal service: [targetService] = https://myservice.com/abcd', + ], + ]; + } + + #[DataProvider('queryParameterValues')] + public function testProxyRequestFails(array $params, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_REQUEST, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public function testProxyRequestFailsWhenPgtNotRecognized(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $params = ['pgt' => 'pgt', 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + 'Ticket pgt not recognized', + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyRequestFailsWhenPgtNotValid(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $params = ['pgt' => $this->ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket(['id' => $this->ticket['id']]); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + 'Not a valid proxy granting ticket id: ' . $this->ticket['id'], + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyRequestFailsWhenPgtExpired(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $ticket = [ + 'id' => 'PGT-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + ]; + $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket($ticket); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals('BAD_PGT', $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code']); + $this->assertEquals( + "Ticket {$ticket['id']} has expired", + $xml->xpath('//cas:authenticationFailure')[0], + ); + } + + public function testProxyReturnsProxyTicket(): void + { + $this->moduleConfig['legal_target_service_urls'] = ['https://myservice.com/abcd']; + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $ticket = [ + 'id' => 'PGT-' . $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + 'forceAuthn' => false, + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + ]; + $sessionTicket = [ + 'id' => $this->sessionId, + 'validBefore' => 9999999999, + 'service' => 'https://myservice.com/abcd', + 'sessionId' => $this->sessionId, + ]; + $params = ['pgt' => $ticket['id'], 'targetService' => 'https://myservice.com/abcd']; + $this->samlValidateRequest = Request::create( + uri: Module::getModuleURL('casserver/proxy'), + parameters: $params, + ); + + $this->ticketStore->addTicket($ticket); + $this->ticketStore->addTicket($sessionTicket); + $ticketFactory = new TicketFactory($casconfig); + + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->proxy($this->samlValidateRequest, ...$params); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertNotNull($xml->xpath('//cas:proxySuccess')); + $ticketId = (string)$xml->xpath('//cas:proxyTicket')[0]; + $proxyTicket = $this->ticketStore->getTicket($ticketId); + $this->assertTrue(filter_var($ticketFactory->isProxyTicket($proxyTicket), FILTER_VALIDATE_BOOLEAN)); + } +} diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 8fed306f..ba584a88 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -62,7 +62,6 @@ protected function setUp(): void $this->ticket = [ 'id' => 'ST-' . $this->sessionId, 'validBefore' => 9999999999, - // phpcs:ignore Generic.Files.LineLength.TooLong 'service' => 'https://myservice.com/abcd', 'forceAuthn' => false, 'userName' => 'username@google.com', From 3f3721adb1a39cd814e86a4aebfe9d1617c45088 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:19:41 +0200 Subject: [PATCH 19/57] Removed cas.php.Added LoginController.php --- public/.keep | 0 public/cas.php | 51 ------ public/login.php | 240 --------------------------- public/utility/urlUtils.php | 125 -------------- routing/routes/routes.php | 5 + routing/services/services.yml | 3 + src/Controller/Cas20Controller.php | 139 +--------------- src/Controller/LoginController.php | 246 +++++++++++++++++++++++----- src/Controller/LogoutController.php | 2 +- src/Controller/Traits/UrlTrait.php | 184 ++++++++++++++++----- 10 files changed, 361 insertions(+), 634 deletions(-) create mode 100644 public/.keep delete mode 100644 public/cas.php delete mode 100644 public/login.php delete mode 100644 public/utility/urlUtils.php diff --git a/public/.keep b/public/.keep new file mode 100644 index 00000000..e69de29b diff --git a/public/cas.php b/public/cas.php deleted file mode 100644 index 6c9f6028..00000000 --- a/public/cas.php +++ /dev/null @@ -1,51 +0,0 @@ - 'login', - 'validate' => 'validate', - 'serviceValidate' => 'serviceValidate', - 'logout' => 'logout', - 'proxy' => 'proxy', - 'proxyValidate' => 'proxyValidate', -]; - -$function = substr($_SERVER['PATH_INFO'], 1); - -if (!isset($validFunctions[$function])) { - $message = 'Not a valid function for cas.php.'; - - \SimpleSAML\Logger::debug('casserver:' . $message); - - throw new \Exception($message); -} - -/** @psalm-suppress UnresolvableInclude */ -include(dirname(__FILE__) . '/' . strval($validFunctions[$function]) . '.php'); diff --git a/public/login.php b/public/login.php deleted file mode 100644 index 126b7706..00000000 --- a/public/login.php +++ /dev/null @@ -1,240 +0,0 @@ -checkServiceURL(sanitize($serviceUrl)); - if (isset($serviceCasConfig)) { - // Override the cas configuration to use for this service - $casconfig = $serviceCasConfig; - } else { - $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' . - var_export($serviceUrl, true); - Logger::debug('casserver:' . $message); - - throw new \Exception($message); - } -} - -if (array_key_exists('scope', $_GET) && is_string($_GET['scope'])) { - $scopes = $casconfig->getOptionalValue('scopes', []); - - if (array_key_exists($_GET['scope'], $scopes)) { - $idpList = $scopes[$_GET['scope']]; - } else { - $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . - var_export($_GET['scope'], true); - Logger::debug('casserver:' . $message); - - throw new \Exception($message); - } -} - -if (array_key_exists('language', $_GET) && is_string($_GET['language'])) { - Language::setLanguageCookie($_GET['language']); -} - -/** Initializations */ - -// AuthSource Simple -$as = new Simple($casconfig->getValue('authsource')); - -// Ticket Store -$ticketStoreConfig = $casconfig->getOptionalValue('ticketstore', ['class' => 'casserver:FileSystemTicketStore']); -$ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); -/** @var $ticketStore TicketStore */ -/** @psalm-suppress InvalidStringClass */ -$ticketStore = new $ticketStoreClass($casconfig); - -// Ticket Factory -$ticketFactoryClass = Module::resolveClass('casserver:TicketFactory', 'Cas\Factories'); -/** @var $ticketFactory TicketFactory */ -/** @psalm-suppress InvalidStringClass */ -$ticketFactory = new $ticketFactoryClass($casconfig); - -// Processing Chain Factory -$processingChaingFactoryClass = Module::resolveClass('casserver:ProcessingChainFactory', 'Cas\Factories'); -/** @var $processingChainFactory ProcessingChainFactory */ -/** @psalm-suppress InvalidStringClass */ -$processingChainFactory = new $processingChaingFactoryClass($casconfig); - -// Attribute Extractor -$attributeExtractor = new AttributeExtractor($casconfig, $processingChainFactory); - -// HTTP Utils -$httpUtils = new Utils\HTTP(); -$session = Session::getSessionFromRequest(); - -$sessionTicket = $ticketStore->getTicket($session->getSessionId()); -$sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; -$requestRenewId = $_REQUEST['renewId'] ?? null; -// Parse the query parameters and return them in an array -$query = parseQueryParameters($sessionTicket); -// Construct the ReturnTo URL -$returnUrl = $httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); - -// Authenticate -if ( - !$as->isAuthenticated() || ($forceAuthn && $sessionRenewId != $requestRenewId) -) { - $params = [ - 'ForceAuthn' => $forceAuthn, - 'isPassive' => $isPassive, - 'ReturnTo' => $returnUrl, - ]; - - if (isset($_GET['entityId'])) { - $params['saml:idp'] = $_GET['entityId']; - } - - if (isset($idpList)) { - if (sizeof($idpList) > 1) { - $params['saml:IDPList'] = $idpList; - } else { - $params['saml:idp'] = $idpList[0]; - } - } - - $as->login($params); -} - -$sessionExpiry = $as->getAuthData('Expire'); - -if (!is_array($sessionTicket) || $forceAuthn) { - $sessionTicket = $ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry); - $ticketStore->addTicket($sessionTicket); -} - -$parameters = []; - -if (array_key_exists('language', $_GET)) { - $oldLanguagePreferred = Language::getLanguageCookie(); - - if (isset($oldLanguagePreferred)) { - $parameters['language'] = $oldLanguagePreferred; - } elseif (is_string($_GET['language'])) { - $parameters['language'] = $_GET['language']; - } -} - -// I am already logged in. Redirect to the logged in endpoint -if (!isset($serviceUrl) && $authProcId === null) { - // LOGGED IN - $httpUtils->redirectTrustedURL( - $httpUtils->addURLParameters(Module::getModuleURL('casserver/loggedIn.php'), $parameters), - ); -} - -$defaultTicketName = isset($_GET['service']) ? 'ticket' : 'SAMLart'; -$ticketName = $casconfig->getOptionalValue('ticketName', $defaultTicketName); - -// Get the state. -// If we come from an authproc filter, we will load the state from the stateId. -// If not, we will get the state from the AuthSource Data -$state = $authProcId !== null ? - $attributeExtractor->manageState($authProcId) : - $as->getAuthDataArray(); - -// Attribute Handler -$state['ReturnTo'] = $returnUrl; -if ($authProcId !== null) { - $state[ProcessingChain::AUTHPARAM] = $authProcId; -} -$mappedAttributes = $attributeExtractor->extractUserAndAttributes($state); - -$serviceTicket = $ticketFactory->createServiceTicket([ - 'service' => $serviceUrl, - 'forceAuthn' => $forceAuthn, - 'userName' => $mappedAttributes['user'], - 'attributes' => $mappedAttributes['attributes'], - 'proxies' => [], - 'sessionId' => $sessionTicket['id'], - ]); - -$ticketStore->addTicket($serviceTicket); - -$parameters[$ticketName] = $serviceTicket['id']; - -$validDebugModes = ['true', 'samlValidate']; - -// DEBUG MODE -if ( - array_key_exists('debugMode', $_GET) && - in_array($_GET['debugMode'], $validDebugModes, true) && - $casconfig->getOptionalBoolean('debugMode', false) -) { - if ($_GET['debugMode'] === 'samlValidate') { - $samlValidate = new SamlValidateResponder(); - $samlResponse = $samlValidate->convertToSaml($serviceTicket); - $soap = $samlValidate->wrapInSoap($samlResponse); - echo '
' . htmlspecialchars(strval($soap)) . '
'; - } else { - $method = 'serviceValidate'; - // Fake some options for validateTicket - $_GET[$ticketName] = $serviceTicket['id']; - // We want to capture the output from echo used in validateTicket - ob_start(); - echo casServiceValidate($serviceTicket['id'], $serviceUrl); - $casResponse = ob_get_contents(); - ob_end_clean(); - echo '
' . htmlspecialchars($casResponse) . '
'; - } -} elseif ($redirect) { - // GET - $httpUtils->redirectTrustedURL($httpUtils->addURLParameters($serviceUrl, $parameters)); -} else { - // POST - $httpUtils->submitPOSTData($serviceUrl, $parameters); -} diff --git a/public/utility/urlUtils.php b/public/utility/urlUtils.php deleted file mode 100644 index 97fbb8a1..00000000 --- a/public/utility/urlUtils.php +++ /dev/null @@ -1,125 +0,0 @@ - $legal_service_urls]); - $serviceValidator = new ServiceValidator($config); - return $serviceValidator->checkServiceURL($service) !== null; -} - - -/** - * @param string $parameter - * @return string - */ -function sanitize(string $parameter): string -{ - return TicketValidator::sanitize($parameter); -} - - -/** - * Parse the query Parameters from $_GET global and return them in an array. - * - * @param array|null $sessionTicket - * - * @return array - */ -function parseQueryParameters(?array $sessionTicket): array -{ - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - - $query = []; - - if ($sessionRenewId && $forceAuthn) { - $query['renewId'] = $sessionRenewId; - } - - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; - } - - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; - } - - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } - - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; - } - - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; - } - - if (array_key_exists('language', $_GET)) { - $query['language'] = is_string($_GET['language']) ? $_GET['language'] : null; - } - - if (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } - - return $query; -} - -/** - * Uses the cas service validate, this provides additional attributes - * - * @param string $ticket - * @param string $service - * - * @return array username and attributes - * @throws \SimpleSAML\Error\Exception - */ -function casServiceValidate(string $ticket, string $service): string -{ - $httpUtils = new Utils\HTTP(); - $url = $httpUtils->addURLParameters( - Module::getModuleURL('casserver/serviceValidate.php'), - compact('ticket', 'service'), - ); - - return $httpUtils->fetch($url); -} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index a60b6ff1..999a4a60 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Module\casserver\Controller\LoggedInController; use SimpleSAML\Module\casserver\Controller\LoggedOutController; +use SimpleSAML\Module\casserver\Controller\LoginController; use SimpleSAML\Module\casserver\Controller\LogoutController; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -39,6 +40,8 @@ ->controller([LogoutController::class, 'logout']); $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(RoutesEnum::Login->name, RoutesEnum::Login->value) + ->controller([LoginController::class, 'login']); $routes->add(RoutesEnum::LoggedIn->name, RoutesEnum::LoggedIn->value) ->controller([LoggedInController::class, 'main']); @@ -61,6 +64,8 @@ ->controller([LogoutController::class, 'logout']); $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value) ->controller([LoggedOutController::class, 'main']); + $routes->add(LegacyRoutesEnum::LegacyLogin->name, LegacyRoutesEnum::LegacyLogin->value) + ->controller([LoginController::class, 'login']); $routes->add(LegacyRoutesEnum::LegacyLoggedIn->name, LegacyRoutesEnum::LegacyLoggedIn->value) ->controller([LoggedInController::class, 'main']); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index 4af98b89..7348ffa9 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -32,4 +32,7 @@ services: public: true SimpleSAML\Module\casserver\Controller\LoggedInController: + public: true + + SimpleSAML\Module\casserver\Controller\LoginController: public: true \ No newline at end of file diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index f04c55e8..ef1891d3 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -89,8 +89,8 @@ public function serviceValidate( return $this->validate( request: $request, method: 'serviceValidate', - target: $TARGET, renew: $renew, + target: $TARGET, ticket: $ticket, service: $service, pgtUrl: $pgtUrl, @@ -207,146 +207,11 @@ public function proxyValidate( return $this->validate( request: $request, method: 'proxyValidate', - target: $TARGET, renew: $renew, + target: $TARGET, ticket: $ticket, service: $service, pgtUrl: $pgtUrl, ); } - - /** - * @param Request $request - * @param string $method - * @param string $target - * @param bool $renew - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * - * @return XmlResponse - */ - public function validate( - Request $request, - string $method, - string $target, - bool $renew = false, - ?string $ticket = null, - ?string $service = null, - ?string $pgtUrl = null, - ): XmlResponse { - $forceAuthn = $renew; - // todo: According to the protocol, there is no target??? Why are we using it? - $serviceUrl = $service ?? $target ?? null; - - // Check if any of the required query parameters are missing - if ($serviceUrl === null || $ticket === null) { - $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing service parameter: [{$messagePostfix}]"; - Logger::debug($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_BAD_REQUEST, - ); - } - - try { - // Get the service ticket - // `getTicket` uses the unserializable method and Objects may throw Throwables in their - // unserialization handlers. - $serviceTicket = $this->ticketStore->getTicket($ticket); - // Delete the ticket - $this->ticketStore->deleteTicket($ticket); - } catch (\Exception $e) { - $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); - Logger::error($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_INTERNAL_SERVER_ERROR, - ); - } - - $failed = false; - $message = ''; - if (empty($serviceTicket)) { - // No ticket - $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; - $failed = true; - } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . - ' is a proxy ticket. Use proxyValidate instead.'; - $failed = true; - } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { - // This is not a service ticket - $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; - $failed = true; - } elseif ($this->ticketFactory->isExpired($serviceTicket)) { - // the ticket has expired - $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; - $failed = true; - } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { - // The service url we passed to the query parameters does not match the one in the ticket. - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($serviceUrl, true); - $failed = true; - } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { - // If `forceAuthn` is required but not set in the ticket - $message = 'Ticket was issued from single sign on session'; - $failed = true; - } - - if ($failed) { - $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_BAD_REQUEST, - ); - } - - $attributes = $serviceTicket['attributes']; - $this->cas20Protocol->setAttributes($attributes); - - if (isset($pgtUrl)) { - $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); - if ( - $sessionTicket !== null - && $this->ticketFactory->isSessionTicket($sessionTicket) - && !$this->ticketFactory->isExpired($sessionTicket) - ) { - $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( - [ - 'userName' => $serviceTicket['userName'], - 'attributes' => $attributes, - 'forceAuthn' => false, - 'proxies' => array_merge( - [$serviceUrl], - $serviceTicket['proxies'], - ), - 'sessionId' => $serviceTicket['sessionId'], - ], - ); - try { - $this->httpUtils->fetch( - $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], - ); - - $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - - $this->ticketStore->addTicket($proxyGrantingTicket); - } catch (\Exception $e) { - // Fall through - } - } - } - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), - Response::HTTP_OK, - ); - } } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index c486e850..ca3d89cc 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -7,19 +7,21 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; -use SimpleSAML\Locale\Language; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\AttributeExtractor; use SimpleSAML\Module\casserver\Cas\Factories\ProcessingChainFactory; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; +use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; +use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; @@ -34,9 +36,6 @@ class LoginController /** @var Configuration */ protected Configuration $casConfig; - /** @var Configuration */ - protected Configuration $sspConfig; - /** @var TicketFactory */ protected TicketFactory $ticketFactory; @@ -46,6 +45,9 @@ class LoginController /** @var Utils\HTTP */ protected Utils\HTTP $httpUtils; + /** @var Cas20 */ + protected Cas20 $cas20Protocol; + // this could be any configured ticket store /** @var mixed */ protected mixed $ticketStore; @@ -56,28 +58,39 @@ class LoginController /** @var array */ protected array $idpList; - /** @var string */ - protected string $authProcId; + /** @var string|null */ + protected ?string $authProcId = null; + + protected array $postAuthUrlParameters = []; + + /** @var string[] */ + private const DEBUG_MODES = ['true', 'samlValidate']; /** @var AttributeExtractor */ protected AttributeExtractor $attributeExtractor; + /** @var SamlValidateResponder */ + private SamlValidateResponder $samlValidateResponder; + /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param SspContainer|null $httpUtils + * @param HTTP|null $httpUtils * * @throws \Exception */ public function __construct( - Configuration $sspConfig = null, + private readonly Configuration $sspConfig, // Facilitate testing Configuration $casConfig = null, Simple $source = null, Utils\HTTP $httpUtils = null, ) { - $this->sspConfig = $sspConfig ?? Configuration::getInstance(); - $this->casConfig = $casConfig ?? Configuration::getConfig('module_casserver.php'); + $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) + ? Configuration::getConfig('module_casserver.php') : $casConfig; + + $this->cas20Protocol = new Cas20($this->casConfig); $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->httpUtils = $httpUtils ?? new Utils\HTTP(); @@ -94,9 +107,11 @@ public function __construct( // Ticket Store $this->ticketStore = new $ticketStoreClass($this->casConfig); // Processing Chain Factory - $processingChainFactory = new ProcessingChainFactory($this->casconfig); + $processingChainFactory = new ProcessingChainFactory($this->casConfig); // Attribute Extractor - $this->attributeExtractor = new AttributeExtractor($this->casconfig, $processingChainFactory); + $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); + // Saml Validate Responsder + $this->samlValidateResponder = new SamlValidateResponder(); } /** @@ -105,11 +120,15 @@ public function __construct( * @param bool $renew * @param bool $gateway * @param string|null $service + * @param string|null $TARGET * @param string|null $scope * @param string|null $language * @param string|null $entityId + * @param string|null $debugMode + * @param string|null $method * - * @return RedirectResponse|null + * @return RedirectResponse|XmlResponse|null + * @throws NoState * @throws \Exception */ public function login( @@ -117,12 +136,26 @@ public function login( #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] bool $gateway = false, #[MapQueryParameter] string $service = null, + #[MapQueryParameter] string $TARGET = null, #[MapQueryParameter] string $scope = null, #[MapQueryParameter] string $language = null, #[MapQueryParameter] string $entityId = null, #[MapQueryParameter] string $debugMode = null, - ): RedirectResponse|null { - $this->handleServiceConfiguration($service); + #[MapQueryParameter] string $method = null, + ): RedirectResponse|XmlResponse|null { + $forceAuthn = $renew; + $serviceUrl = $service ?? $TARGET ?? null; + $redirect = !(isset($method) && $method === 'POST'); + + // Get the ticket from the session + $session = $this->getSession(); + $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + $sessionRenewId = $sessionTicket['renewId'] ?? null; + $requestRenewId = $this->getRequestParam($request, 'renewId'); + // if this parameter is true, single sign-on will be bypassed and authentication will be enforced + $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId; + + $this->handleServiceConfiguration($serviceUrl); $this->handleScope($scope); $this->handleLanguage($language); @@ -130,67 +163,185 @@ public function login( $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); } - // Get the ticket from the session - $session = Session::getSessionFromRequest(); - $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); + // Construct the ReturnTo URL + // This will be used to come back from the AuthSource login or from the Processing Chain + $returnToUrl = $this->getReturnUrl($request, $sessionTicket); + + // Authenticate + if ( + $requestForceAuthenticate || !$this->authSource->isAuthenticated() + ) { + $params = [ + 'ForceAuthn' => $forceAuthn, + 'isPassive' => $gateway, + 'ReturnTo' => $returnToUrl, + ]; + + if (isset($entityId)) { + $params['saml:idp'] = $entityId; + } + + if (isset($this->idpList)) { + if (sizeof($this->idpList) > 1) { + $params['saml:IDPList'] = $this->idpList; + } else { + $params['saml:idp'] = $this->idpList[0]; + } + } + + /* + * REDIRECT TO AUTHSOURCE LOGIN + * */ + $this->authSource->login($params); + } - // Construct the ticket name - $defaultTicketName = isset($service) ? 'ticket' : 'SAMLart'; - $ticketName = $this->casconfig->getOptionalValue('ticketName', $defaultTicketName); + // We are Authenticated. + $sessionExpiry = $this->authSource->getAuthData('Expire'); + // Create a new ticket if we do not have one alreday or if we are in a forced Authentitcation mode + if (!\is_array($sessionTicket) || $forceAuthn) { + $sessionTicket = $this->ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry); + $this->ticketStore->addTicket($sessionTicket); + } + + /* + * We are done. REDIRECT TO LOGGEDIN + * */ + if (!isset($serviceUrl) && $this->authProcId === null) { + $urlParameters = $this->httpUtils->addURLParameters( + Module::getModuleURL('casserver/loggedIn'), + $this->postAuthUrlParameters, + ); + $this->httpUtils->redirectTrustedURL($urlParameters); + } + + // Get the state. + $state = $this->getState(); + $state['ReturnTo'] = $returnToUrl; + if ($this->authProcId !== null) { + $state[ProcessingChain::AUTHPARAM] = $this->authProcId; + } + // Attribute Handler + $mappedAttributes = $this->attributeExtractor->extractUserAndAttributes($state); + $serviceTicket = $this->ticketFactory->createServiceTicket([ + 'service' => $serviceUrl, + 'forceAuthn' => $forceAuthn, + 'userName' => $mappedAttributes['user'], + 'attributes' => $mappedAttributes['attributes'], + 'proxies' => [], + 'sessionId' => $sessionTicket['id'], + ]); + $this->ticketStore->addTicket($serviceTicket); + + // Check if we are in debug mode. + if ($debugMode !== null && $this->casConfig->getOptionalBoolean('debugMode', false)) { + return $this->handleDebugMode($request, $debugMode, $serviceTicket); + } - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + $ticketName = $this->calculateTicketName($service); + $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id']; + + // GET + if ($redirect) { + $this->httpUtils->redirectTrustedURL( + $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), + ); + } + // POST + $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); + return null; } + /** + * @param Request $request + * @param string|null $debugMode + * @param array $serviceTicket + * + * @return XmlResponse + */ public function handleDebugMode( Request $request, ?string $debugMode, - string $ticketName, array $serviceTicket, - ): void { + ): XmlResponse { // Check if the debugMode is supported - if (!\in_array($debugMode, ['true', 'samlValidate'], true)) { - return; + if (!\in_array($debugMode, self::DEBUG_MODES, true)) { + return new XmlResponse( + 'invalid debug mode', + Response::HTTP_BAD_REQUEST, + ); } if ($debugMode === 'true') { // Service validate CAS20 - $this->httpUtils->redirectTrustedURL( - Module::getModuleURL('/cas/serviceValidate.php'), - [ ...$request->getQueryParams(), $ticketName => $serviceTicket['id'] ], + return $this->validate( + request: $request, + method: 'serviceValidate', + renew: $request->get('renew', false), + target: $request->get('target'), + ticket: $serviceTicket['id'], + service: $request->get('service'), + pgtUrl: $request->get('pgtUrl'), ); } // samlValidate Mode - $samlValidate = new SamlValidateResponder(); - $samlResponse = $samlValidate->convertToSaml($serviceTicket); - $soap = $samlValidate->wrapInSoap($samlResponse); - echo '
' . htmlspecialchars((string)$soap) . '
'; + $samlResponse = $this->samlValidateResponder->convertToSaml($serviceTicket); + return new XmlResponse( + (string)$this->samlValidateResponder->wrapInSoap($samlResponse), + Response::HTTP_OK, + ); + } + + /** + * @return array|null + * @throws \SimpleSAML\Error\NoState + */ + public function getState(): ?array + { + // If we come from an authproc filter, we will load the state from the stateId. + // If not, we will get the state from the AuthSource Data + + return $this->authProcId !== null ? + $this->attributeExtractor->manageState($this->authProcId) : + $this->authSource->getAuthDataArray(); + } + + /** + * Construct the ticket name + * + * @param string|null $service + * + * @return string + */ + public function calculateTicketName(?string $service): string + { + $defaultTicketName = $service !== null ? 'ticket' : 'SAMLart'; + return $this->casConfig->getOptionalValue('ticketName', $defaultTicketName); } /** + * @param Request $request * @param array|null $sessionTicket * * @return string */ - public function getReturnUrl(?array $sessionTicket): string + public function getReturnUrl(Request $request, ?array $sessionTicket): string { // Parse the query parameters and return them in an array - $query = $this->parseQueryParameters($sessionTicket); + $query = $this->parseQueryParameters($request, $sessionTicket); // Construct the ReturnTo URL return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query); } /** - * @param string|null $service + * @param string|null $serviceUrl * * @return void * @throws \Exception */ - public function handleServiceConfiguration(?string $service): void + public function handleServiceConfiguration(?string $serviceUrl): void { - // todo: Check request objec the TARGET - $serviceUrl = $service ?? $_GET['TARGET'] ?? null; if ($serviceUrl === null) { return; } @@ -204,7 +355,7 @@ public function handleServiceConfiguration(?string $service): void } // Override the cas configuration to use for this service - $this->casconfig = $serviceCasConfig; + $this->casConfig = $serviceCasConfig; } /** @@ -219,7 +370,7 @@ public function handleLanguage(?string $language): void return; } - Language::setLanguageCookie($language); + $this->postAuthUrlParameters['language'] = $language; } /** @@ -236,7 +387,7 @@ public function handleScope(?string $scope): void } // Get the scopes from the configuration - $scopes = $this->casconfig->getOptionalValue('scopes', []); + $scopes = $this->casConfig->getOptionalValue('scopes', []); // Fail if (!isset($scopes[$scope])) { @@ -250,4 +401,15 @@ public function handleScope(?string $scope): void // Set the idplist from the scopes $this->idpList = $scopes[$scope]; } + + /** + * Get the Session + * + * @return Session|null + * @throws \Exception + */ + protected function getSession(): ?Session + { + return Session::getSessionFromRequest(); + } } diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 60b0990b..d104d023 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -103,7 +103,7 @@ public function logout( $logoutRedirectUrl = $url; $params = []; } else { - $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutRedirectUrl = Module::getModuleURL('casserver/loggedOut'); $params = $url === null ? [] : ['url' => $url]; } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 858edac0..7e965e54 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -5,9 +5,12 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; use SimpleSAML\Configuration; +use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; +use SimpleSAML\Module\casserver\Http\XmlResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; trait UrlTrait { @@ -26,7 +29,6 @@ public function checkServiceURL(string $service, array $legal_service_urls): boo return $serviceValidator->checkServiceURL($service) !== null; } - /** * @param string $parameter * @return string @@ -36,71 +38,177 @@ public function sanitize(string $parameter): string return TicketValidator::sanitize($parameter); } - /** * Parse the query Parameters from $_GET global and return them in an array. * - * @param array|null $sessionTicket * @param Request $request + * @param array|null $sessionTicket * * @return array */ - public function parseQueryParameters(?array $sessionTicket, Request $request): array + public function parseQueryParameters(Request $request, ?array $sessionTicket): array { - $forceAuthn = isset($_GET['renew']) && $_GET['renew']; + $forceAuthn = $this->getRequestParam($request, 'renew'); $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; - $query = []; + $queryParameters = $request->query->all(); + $requestParameters = $request->request->all(); + + $query = array_merge($requestParameters, $queryParameters); if ($sessionRenewId && $forceAuthn) { $query['renewId'] = $sessionRenewId; } - if (isset($_REQUEST['service'])) { - $query['service'] = $_REQUEST['service']; + if (isset($query['language'])) { + $query['language'] = is_string($query['language']) ? $query['language'] : null; } - if (isset($_REQUEST['TARGET'])) { - $query['TARGET'] = $_REQUEST['TARGET']; - } + return $query; + } - if (isset($_REQUEST['method'])) { - $query['method'] = $_REQUEST['method']; - } + /** + * @param Request $request + * @param string $paramName + * + * @return string|int|array|null + */ + public function getRequestParam(Request $request, string $paramName): string|int|array|null + { + return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; + } - if (isset($_REQUEST['renew'])) { - $query['renew'] = $_REQUEST['renew']; + /** + * @param Request $request + * @param string $method + * @param bool $renew + * @param string|null $target + * @param string|null $ticket + * @param string|null $service + * @param string|null $pgtUrl + * + * @return XmlResponse + */ + public function validate( + Request $request, + string $method, + bool $renew = false, + ?string $target = null, + ?string $ticket = null, + ?string $service = null, + ?string $pgtUrl = null, + ): XmlResponse { + $forceAuthn = $renew; + $serviceUrl = $service ?? $target ?? null; + + // Check if any of the required query parameters are missing + if ($serviceUrl === null || $ticket === null) { + $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; + $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + Logger::debug($message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); } - if (isset($_REQUEST['gateway'])) { - $query['gateway'] = $_REQUEST['gateway']; + try { + // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // unserialization handlers. + $serviceTicket = $this->ticketStore->getTicket($ticket); + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + } catch (\Exception $e) { + $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + Logger::error($message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); } - if (\array_key_exists('language', $_GET)) { - $query['language'] = \is_string($_GET['language']) ? $_GET['language'] : null; + $failed = false; + $message = ''; + if (empty($serviceTicket)) { + // No ticket + $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { + $message = 'Ticket ' . var_export($_GET['ticket'], true) . + ' is a proxy ticket. Use proxyValidate instead.'; + $failed = true; + } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { + // This is not a service ticket + $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $failed = true; + } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired + $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $failed = true; + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { + // The service url we passed to the query parameters does not match the one in the ticket. + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($serviceUrl, true); + $failed = true; + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket + $message = 'Ticket was issued from single sign on session'; + $failed = true; } - if (isset($_REQUEST['debugMode'])) { - $query['debugMode'] = $_REQUEST['debugMode']; - } + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); - return $query; - } + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } - /** - * @param Request $request - * - * @return array - */ - public function getRequestParams(Request $request): array - { - $params = []; - if ($request->isMethod('GET')) { - $params = $request->query->all(); - } elseif ($request->isMethod('POST')) { - $params = $request->request->all(); + $attributes = $serviceTicket['attributes']; + $this->cas20Protocol->setAttributes($attributes); + + if (isset($pgtUrl)) { + $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); + if ( + $sessionTicket !== null + && $this->ticketFactory->isSessionTicket($sessionTicket) + && !$this->ticketFactory->isExpired($sessionTicket) + ) { + $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( + [ + 'userName' => $serviceTicket['userName'], + 'attributes' => $attributes, + 'forceAuthn' => false, + 'proxies' => array_merge( + [$serviceUrl], + $serviceTicket['proxies'], + ), + 'sessionId' => $serviceTicket['sessionId'], + ], + ); + try { + $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (\Exception $e) { + // Fall through + } + } } - return $params; + return new XmlResponse( + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), + Response::HTTP_OK, + ); } } From ef816db97a34dbb1f678eaced651cefe4cf67d94 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:40:01 +0200 Subject: [PATCH 20/57] UrlTrait tests --- tests/src/Controller/LogoutControllerTest.php | 6 ++--- .../Controller/Traits/UrlTraitTest.php} | 22 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) rename tests/{public/UtilsTest.php => src/Controller/Traits/UrlTraitTest.php} (87%) diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 69ac65e3..5d48ee49 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -119,7 +119,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo('http://localhost/module.php/casserver/loggedOut.php'), + $this->equalTo('http://localhost/module.php/casserver/loggedOut'), [], ); @@ -133,7 +133,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $this->moduleConfig['skip_logout_page'] = false; $config = Configuration::loadFromArray($this->moduleConfig); $urlParam = 'https://example.com/test'; - $logoutUrl = Module::getModuleURL('casserver/loggedOut.php'); + $logoutUrl = Module::getModuleURL('casserver/loggedOut'); // Unauthenticated /** @psalm-suppress UndefinedMethod */ @@ -163,7 +163,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('logout') - ->with('http://localhost/module.php/casserver/loggedOut.php'); + ->with('http://localhost/module.php/casserver/loggedOut'); $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); $controller->logout(Request::create('/')); diff --git a/tests/public/UtilsTest.php b/tests/src/Controller/Traits/UrlTraitTest.php similarity index 87% rename from tests/public/UtilsTest.php rename to tests/src/Controller/Traits/UrlTraitTest.php index 138b94a3..aa65eada 100644 --- a/tests/public/UtilsTest.php +++ b/tests/src/Controller/Traits/UrlTraitTest.php @@ -1,23 +1,17 @@ assertEquals($allowed, checkServiceURL(sanitize($service), $legalServices), "$service validated wrong"); + $this->assertEquals( + $allowed, + $this->checkServiceURL($this->sanitize($service), $legalServices), + "$service validated wrong", + ); } From 1072701e97c1787a36b3a97efc05fa0770862bf4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 Dec 2024 11:52:36 +0200 Subject: [PATCH 21/57] Add missing import --- src/Controller/Traits/UrlTrait.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 7e965e54..447854f5 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; +use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; From 38b43e5010e897138de70696f7998dd3e205d3d3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 2 Dec 2024 18:49:19 +0200 Subject: [PATCH 22/57] LoginController Tests --- phpunit.xml | 1 + psalm.xml | 1 - src/Controller/LoginController.php | 38 +- src/Controller/Traits/UrlTrait.php | 2 +- tests/public/LoginIntegrationTest.php | 527 ------------------ tests/src/Controller/LoginControllerTest.php | 321 +++++++++++ tests/src/Controller/LogoutControllerTest.php | 13 +- 7 files changed, 353 insertions(+), 550 deletions(-) delete mode 100644 tests/public/LoginIntegrationTest.php create mode 100644 tests/src/Controller/LoginControllerTest.php diff --git a/phpunit.xml b/phpunit.xml index 9163c388..60deba46 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,6 +4,7 @@ bootstrap="tests/bootstrap.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" backupGlobals="false" + displayDetailsOnTestsThatTriggerWarnings="true" cacheDirectory=".phpunit.cache"> diff --git a/psalm.xml b/psalm.xml index 72284dce..666c0157 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,7 +13,6 @@ > - diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index ca3d89cc..795c76c8 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -147,6 +147,11 @@ public function login( $serviceUrl = $service ?? $TARGET ?? null; $redirect = !(isset($method) && $method === 'POST'); + // Set initial configurations, or fail + $this->handleServiceConfiguration($serviceUrl); + $this->handleScope($scope); + $this->handleLanguage($language); + // Get the ticket from the session $session = $this->getSession(); $sessionTicket = $this->ticketStore->getTicket($session->getSessionId()); @@ -155,10 +160,6 @@ public function login( // if this parameter is true, single sign-on will be bypassed and authentication will be enforced $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId; - $this->handleServiceConfiguration($serviceUrl); - $this->handleScope($scope); - $this->handleLanguage($language); - if ($request->query->has(ProcessingChain::AUTHPARAM)) { $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM); } @@ -193,12 +194,14 @@ public function login( * REDIRECT TO AUTHSOURCE LOGIN * */ $this->authSource->login($params); + // We should never get here.This is to facilitate testing. + return null; } // We are Authenticated. $sessionExpiry = $this->authSource->getAuthData('Expire'); - // Create a new ticket if we do not have one alreday or if we are in a forced Authentitcation mode + // Create a new ticket if we do not have one alreday, or if we are in a forced Authentitcation mode if (!\is_array($sessionTicket) || $forceAuthn) { $sessionTicket = $this->ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry); $this->ticketStore->addTicket($sessionTicket); @@ -213,6 +216,8 @@ public function login( $this->postAuthUrlParameters, ); $this->httpUtils->redirectTrustedURL($urlParameters); + // We should never get here.This is to facilitate testing. + return null; } // Get the state. @@ -246,9 +251,12 @@ public function login( $this->httpUtils->redirectTrustedURL( $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), ); + // We should never get here.This is to facilitate testing. + return null; } // POST $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); + // We should never get here.This is to facilitate testing. return null; } @@ -338,7 +346,7 @@ public function getReturnUrl(Request $request, ?array $sessionTicket): string * @param string|null $serviceUrl * * @return void - * @throws \Exception + * @throws \RuntimeException */ public function handleServiceConfiguration(?string $serviceUrl): void { @@ -351,7 +359,7 @@ public function handleServiceConfiguration(?string $serviceUrl): void var_export($serviceUrl, true); Logger::debug('casserver:' . $message); - throw new \Exception($message); + throw new \RuntimeException($message); } // Override the cas configuration to use for this service @@ -377,7 +385,7 @@ public function handleLanguage(?string $language): void * @param string|null $scope * * @return void - * @throws \Exception + * @throws \RuntimeException */ public function handleScope(?string $scope): void { @@ -392,10 +400,10 @@ public function handleScope(?string $scope): void // Fail if (!isset($scopes[$scope])) { $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' . - var_export($_GET['scope'], true); + var_export($scope, true); Logger::debug('casserver:' . $message); - throw new \Exception($message); + throw new \RuntimeException($message); } // Set the idplist from the scopes @@ -408,8 +416,16 @@ public function handleScope(?string $scope): void * @return Session|null * @throws \Exception */ - protected function getSession(): ?Session + public function getSession(): ?Session { return Session::getSessionFromRequest(); } + + /** + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 447854f5..859d77d3 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -50,7 +50,7 @@ public function sanitize(string $parameter): string public function parseQueryParameters(Request $request, ?array $sessionTicket): array { $forceAuthn = $this->getRequestParam($request, 'renew'); - $sessionRenewId = $sessionTicket ? $sessionTicket['renewId'] : null; + $sessionRenewId = !empty($sessionTicket['renewId']) ? $sessionTicket['renewId'] : null; $queryParameters = $request->query->all(); $requestParameters = $request->request->all(); diff --git a/tests/public/LoginIntegrationTest.php b/tests/public/LoginIntegrationTest.php deleted file mode 100644 index a431e985..00000000 --- a/tests/public/LoginIntegrationTest.php +++ /dev/null @@ -1,527 +0,0 @@ -server = new BuiltInServer( - 'configLoader', - dirname(__FILE__, 3) . '/vendor/simplesamlphp/simplesamlphp/public', - ); - $this->server_addr = $this->server->start(); - $this->server_pid = $this->server->getPid(); - $this->shared_file = sys_get_temp_dir() . '/' . $this->server_pid . '.lock'; - $this->cookies_file = sys_get_temp_dir() . '/' . $this->server_pid . '.cookies'; - - $this->updateConfig([ - 'baseurlpath' => '/', - 'secretsalt' => 'abc123', - - 'tempdir' => sys_get_temp_dir(), - 'loggingdir' => sys_get_temp_dir(), - 'logging.handler' => 'file', - - 'module.enable' => [ - 'casserver' => true, - 'exampleauth' => true, - ], - ]); - } - - - /** - * The tear down method that is executed after all tests in this class. - * Removes the lock file and cookies file - */ - protected function tearDown(): void - { - @unlink($this->shared_file); - @unlink($this->cookies_file); // remove it if it exists - $this->server->stop(); - } - - - /** - * @param array $config - */ - protected function updateConfig(array $config): void - { - @unlink($this->shared_file); - $file = "shared_file, $file); - - Configuration::setPreloadedConfig(Configuration::loadFromArray($config)); - } - - - /** - * Test authenticating to the login endpoint with no parameters.' - */ - public function testNoQueryParameters(): void - { - $resp = $this->server->get( - self::$LINK_URL, - [], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(200, $resp['code']); - - $this->assertStringContainsString( - 'You are logged in.', - $resp['body'], - 'Login with no query parameters should make you authenticate and then take you to the login page.', - ); - } - - - /** - * Test incorrect service url - */ - public function testWrongServiceUrl(): void - { - $resp = $this->server->get( - self::$LINK_URL, - ['service' => 'http://not-legal'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(500, $resp['code']); - - $this->assertStringContainsString( - 'CAS server is not listed as a legal service', - $resp['body'], - 'Illegal cas service urls should be rejected', - ); - } - - - /** - * Test a valid service URL - * @param string $serviceParam The name of the query parameter to use for the service url - * @param string $ticketParam The name of the query parameter that will contain the ticket - */ - #[DataProvider('validServiceUrlProvider')] - public function testValidServiceUrl(string $serviceParam, string $ticketParam): void - { - $service_url = 'http://host1.domain:1234/path1'; - - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - [$serviceParam => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?' . $ticketParam . '=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - - // Config ticket can be validated - $matches = []; - $this->assertEquals(1, preg_match("@$ticketParam=(.*)@", $resp['headers']['Location'], $matches)); - $ticket = $matches[1]; - $resp = $this->server->get( - self::$VALIDATE_URL, - [ - $serviceParam => $service_url, - 'ticket' => $ticket, - ], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - - $expectedXml = simplexml_load_string( - file_get_contents(\dirname(__FILE__, 2) . '/resources/xml/testValidServiceUrl.xml'), - ); - - $actualXml = simplexml_load_string($resp['body']); - - // We will remove the cas:authenticationDate element since we know that it will fail. The dates will not match - $authenticationNodeToDeleteExpected = $expectedXml->xpath('//cas:authenticationDate')[0]; - $authenticationNodeToDeleteActual = $actualXml->xpath('//cas:authenticationDate')[0]; - unset($authenticationNodeToDeleteExpected[0], $authenticationNodeToDeleteActual[0]); - - $this->assertEquals(200, $resp['code']); - - $this->assertEquals( - $expectedXml->xpath('//cas:serviceResponse')[0]->asXML(), - $actualXml->xpath('//cas:serviceResponse')[0]->asXML(), - ); - } - - public static function validServiceUrlProvider(): array - { - return [ - ['service', 'ticket'], - ['TARGET', 'SAMLart'], - ]; - } - - /** - * Test changing the ticket name - */ - public function testValidTicketNameOverride(): void - { - $service_url = 'http://changeTicketParam/abc'; - - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - ['TARGET' => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?myTicket=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testDebugOutput(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'true'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - - $this->assertStringContainsString( - '<cas:eduPersonPrincipalName>testuser@example.com</cas:eduPersonPrincipalName>', - $resp['body'], - 'Attributes should have been printed.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testDebugOutputSamlValidate(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'samlValidate'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - - - $this->assertStringContainsString( - 'testuser@example.com</saml:NameIdentifier', - $resp['body'], - 'Attributes should have been printed.', - ); - } - - - /** - * Test outputting user info instead of redirecting - */ - public function testAlternateServiceConfigUsed(): void - { - $service_url = 'https://override.example.com/somepath'; - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url, 'debugMode' => 'true'], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(200, $resp['code']); - $this->assertStringContainsString( - '<cas:user>testuser</cas:user>', - $resp['body'], - 'cas:user attribute should have been overridden', - ); - $this->assertStringContainsString( - '<cas:cn>Test User</cas:cn>', - $resp['body'], - 'Attributes should have been printed with alternate attribute release', - ); - } - - - /** - * test a valid service URL with Post - */ - public function testValidServiceUrlWithPost(): void - { - $service_url = 'http://host1.domain:1234/path1'; - - $this->authenticate(); - $resp = $this->server->get( - self::$LINK_URL, - [ - 'service' => $service_url, - 'method' => 'POST', - ], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - - // POST responds with a form that is uses JavaScript to submit - $this->assertEquals(200, $resp['code']); - - // Validate the form contains the required elements - $body = $resp['body']; - $dom = new DOMDocument(); - $dom->loadHTML($body); - $form = $dom->getElementsByTagName('form'); - $item = $form->item(0); - if (is_null($item)) { - $this->fail('Unable to parse response.'); - return; - } - - $this->assertEquals($service_url, $item->getAttribute('action')); - $formInputs = $dom->getElementsByTagName('input'); - //note: $formInputs[0] is ''. See the post.php template from SSP - $item = $formInputs->item(1); - if (is_null($item)) { - $this->fail('Unable to parse response.'); - return; - } - $this->assertEquals( - 'ticket', - $item->getAttribute('name'), - ); - $this->assertStringStartsWith( - 'ST-', - $item->getAttribute('value'), - '', - ); - } - - - /** - */ - public function testSamlValidate(): void - { - $service_url = 'http://host1.domain:1234/path1'; - $this->authenticate(); - - $resp = $this->server->get( - self::$LINK_URL, - ['service' => $service_url], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - ], - ); - $this->assertEquals(303, $resp['code']); - - $this->assertStringStartsWith( - $service_url . '?ticket=ST-', - $resp['headers']['Location'], - 'Ticket should be part of the redirect.', - ); - - $location = $resp['headers']['Location']; - $matches = []; - $this->assertEquals(1, preg_match('@ticket=(.*)@', $location, $matches)); - $ticket = $matches[1]; - $soapRequest = << - - - - $ticket - - - -SOAP; - - $resp = $this->post( - self::$SAMLVALIDATE_URL, - $soapRequest, - [ - 'TARGET' => $service_url, - ], - ); - - $this->assertEquals(200, $resp['code']); - $this->assertStringContainsString('testuser@example.com', $resp['body']); - } - - - /** - * Sets up an authenticated session for the cookie $jar - */ - private function authenticate(): void - { - // Use cookies Jar to store auth session cookies - $resp = $this->server->get( - self::$LINK_URL, - [], - [ - CURLOPT_COOKIEJAR => $this->cookies_file, - CURLOPT_COOKIEFILE => $this->cookies_file, - CURLOPT_FOLLOWLOCATION => true, - ], - ); - $this->assertEquals(200, $resp['code'], $resp['body']); - } - - - /** - * TODO: migrate into BuiltInServer - * @param \CurlHandle $ch - * @return array - */ - private function execAndHandleCurlResponse(CurlHandle $ch): array - { - $resp = curl_exec($ch); - if ($resp === false) { - throw new \Exception('curl error: ' . curl_error($ch)); - } - - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - list($header, $body) = explode("\r\n\r\n", $resp, 2); - $raw_headers = explode("\r\n", $header); - array_shift($raw_headers); - $headers = []; - foreach ($raw_headers as $header) { - list($name, $value) = explode(':', $header, 2); - $headers[trim($name)] = trim($value); - } - curl_close($ch); - return [ - 'code' => $code, - 'headers' => $headers, - 'body' => $body, - ]; - } - - - /** - * TODO: migrate into BuiltInServer - * @param string $query The path at the embedded server to query - * @param string|array $body The content to post - * @param array $parameters Any query parameters to add. - * @param array $curlopts Additional curl options - * @return array The response code, headers and body - */ - public function post($query, $body, $parameters = [], $curlopts = []): array - { - $ch = curl_init(); - $url = 'http://' . $this->server_addr . $query; - $url .= (!empty($parameters)) ? '?' . http_build_query($parameters) : ''; - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_HEADER => 1, - CURLOPT_POST => 1, - ]); - - // body may be multi dimensional, so we convert it ourselves - // https://stackoverflow.com/a/21111209/54396 - $postParam = is_string($body) ? $body : http_build_query($body); - curl_setopt($ch, CURLOPT_POSTFIELDS, $postParam); - curl_setopt_array($ch, $curlopts); - return $this->execAndHandleCurlResponse($ch); - } -} diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php new file mode 100644 index 00000000..5216815f --- /dev/null +++ b/tests/src/Controller/LoginControllerTest.php @@ -0,0 +1,321 @@ +authSimpleMock = $this->getMockBuilder(Simple::class) + ->disableOriginalConstructor() + ->onlyMethods(['getAuthData', 'isAuthenticated', 'login', 'getAuthDataArray']) + ->getMock(); + + $this->sspContainer = $this->getMockBuilder(SspContainer::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirect']) + ->getMock(); + + $this->httpUtils = $this->getMockBuilder(Utils\HTTP::class) + ->disableOriginalConstructor() + ->onlyMethods(['redirectTrustedURL']) + ->getMock(); + + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSessionId']) + ->getMock(); + + $this->moduleConfig = [ + 'ticketstore' => [ + 'class' => 'casserver:FileSystemTicketStore', //Not intended for production + 'directory' => __DIR__ . '../../../../tests/ticketcache', + ], + 'authsource' => 'sso', + 'legal_service_urls' => [ + 'https://example.com/ssp/module.php/cas/linkback.php', + ], + 'debugMode' => false, + ]; + + $this->sspConfig = Configuration::getConfig('config.php'); + } + + public static function setUpBeforeClass(): void + { + // Some of the constructs in this test cause a Configuration to be created prior to us + // setting the one we want to use for the test. + Configuration::clearInternalState(); + + // To make lib/SimpleSAML/Utils/HTTP::getSelfURL() work... + global $_SERVER; + $_SERVER['REQUEST_URI'] = '/'; + } + + public static function loginParameters(): array + { + return [ + 'Wrong Service Url' => [ + ['service' => 'http://not-legal'], + // phpcs:ignore Generic.Files.LineLength.TooLong + "Service parameter provided to CAS server is not listed as a legal service: [service] = 'http://not-legal'", + ], + 'Invalid Scope' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'illegalscope', + ], + "Scope parameter provided to CAS server is not listed as legal scope: [scope] = 'illegalscope'", + + ], + ]; + } + + /** + * Test incorrect service url + * @throws \Exception + */ + #[DataProvider('loginParameters')] + public function testLoginFails(array $params, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $params, + ); + + $loginController = new LoginController( + $this->sspConfig, + $casconfig, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + + $loginController->login($loginRequest, ...$params); + } + + public static function loginOnAuthenticateParameters(): array + { + return [ + 'No EntityId Query Parameter' => [ + ['service' => 'https://example.com/ssp/module.php/cas/linkback.php'], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php', + ], + [], + ], + 'With EntityId Set' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'entityId' => 'http://localhost/entityId/sso', + ], + [ + 'saml:idp' => 'http://localhost/entityId/sso', + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&entityId=http%3A%2F%2Flocalhost%2FentityId%2Fsso', + ], + [], + ], + 'With Valid Scope List Set - More than 1 items' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'desktop', + ], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop', + 'saml:IDPList' => [ + 'http://localhost/entityId/sso/scope/A', + 'http://localhost/entityId/sso/scope/B', + ], + ], + [ + 'desktop' => [ + 'http://localhost/entityId/sso/scope/A', + 'http://localhost/entityId/sso/scope/B', + ], + ], + + ], + 'With Valid Scope List Set - 1 item' => [ + [ + 'service' => 'https://example.com/ssp/module.php/cas/linkback.php', + 'scope' => 'desktop', + ], + [ + 'ForceAuthn' => false, + 'isPassive' => false, + // phpcs:ignore Generic.Files.LineLength.TooLong + 'ReturnTo' => 'http://localhost/?service=https%3A%2F%2Fexample.com%2Fssp%2Fmodule.php%2Fcas%2Flinkback.php&scope=desktop', + 'saml:idp' => 'http://localhost/entityId/sso/scope/A', + ], + [ + 'desktop' => ['http://localhost/entityId/sso/scope/A'], + ], + + ], + ]; + } + + #[DataProvider('loginOnAuthenticateParameters')] + public function testAuthSourceLogin(array $requestParameters, array $loginParameters, array $scopes): void + { + $moduleConfig = $this->moduleConfig; + $moduleConfig['scopes'] = $scopes; + $casconfig = Configuration::loadFromArray($moduleConfig); + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $requestParameters, + ); + + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); + $this->authSimpleMock->expects($this->once())->method('login')->with($loginParameters); + $sessionId = session_create_id(); + $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); + + $controllerMock->login($loginRequest, ...$requestParameters); + } + + /** + * Check authenticated with debugMode false + */ + public function testIsAuthenticatedRedirectsToLoggedIn(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $sessionId = session_create_id(); + $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); + $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); + $this->httpUtils->expects($this->once())->method('redirectTrustedURL') + ->with('http://localhost/module.php/casserver/loggedIn?'); + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: [], + ); + + $controllerMock->login($loginRequest); + } + + public static function validServiceUrlProvider(): array + { + return [ + "'ticket' Query Parameter" => [ + 'service', + 'https://example.com/ssp/module.php/cas/linkback.php?ticket=ST-', + false, + ], + "'SAMLart' Query Parameter" => [ + 'TARGET', + 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-', + false, + ], + "'myTicket' Query Parameter for Ticket Name Override" => [ + 'TARGET', + 'https://example.com/ssp/module.php/cas/linkback.php?SAMLart=ST-', + true, + ], + ]; + } + + /** + * Test a valid service URL + * + * @param string $serviceParam The name of the query parameter to use for the service url + * @param string $redirectURL + * @param bool $hasTicketNameOverride + * + * @throws \Exception + */ + #[DataProvider('validServiceUrlProvider')] + public function testValidServiceUrl(string $serviceParam, string $redirectURL, bool $hasTicketNameOverride): void + { + $state['Attributes'] = [ + 'eduPersonPrincipalName' => ['testuser@example.com'], + 'additionalAttribute' => ['Taco Club'], + 'Expire' => 9999999999, + ]; + $moduleConfig = $this->moduleConfig; + if ($hasTicketNameOverride) { + $moduleConfig['legal_service_urls']['http://changeTicketParam'] = [ + 'ticketName' => 'myTicket', + ]; + } + $casconfig = Configuration::loadFromArray($moduleConfig); + $controllerMock = $this->getMockBuilder(LoginController::class) + ->setConstructorArgs([$this->sspConfig, $casconfig, $this->authSimpleMock, $this->httpUtils]) + ->onlyMethods(['getSession']) + ->getMock(); + + $sessionId = session_create_id(); + $this->sessionMock->expects($this->exactly(2))->method('getSessionId')->willReturn($sessionId); + $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); + $this->authSimpleMock->expects($this->once())->method('getAuthDataArray')->willReturn($state); + + $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); + $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true); + $this->httpUtils->expects($this->once())->method('redirectTrustedURL') + ->withAnyParameters() + ->willReturnCallback(function ($url) use ($redirectURL) { + $this->assertStringStartsWith( + $redirectURL, + $url, + 'Ticket should be part of the redirect.', + ); + }); + $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php']; + $loginRequest = Request::create( + uri: Module::getModuleURL('casserver/login'), + parameters: $queryParameters, + ); + + /** @psalm-suppress InvalidArgument */ + $controllerMock->login($loginRequest, ...$queryParameters); + } +} diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 5d48ee49..54c31ea8 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; @@ -17,9 +18,9 @@ class LogoutControllerTest extends TestCase { private array $moduleConfig; - private Simple $authSimpleMock; + private Simple|MockObject $authSimpleMock; - private SspContainer $sspContainer; + private SspContainer|MockObject $sspContainer; private Configuration $sspConfig; @@ -89,9 +90,7 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void $urlParam = 'https://example.com/test'; // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo($urlParam), [], @@ -115,9 +114,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo('http://localhost/module.php/casserver/loggedOut'), [], @@ -136,9 +133,7 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void $logoutUrl = Module::getModuleURL('casserver/loggedOut'); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - /** @psalm-suppress UndefinedMethod */ $this->sspContainer->expects($this->once())->method('redirect')->with( $this->equalTo($logoutUrl), ['url' => $urlParam], @@ -159,9 +154,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $config = Configuration::loadFromArray($this->moduleConfig); // Unauthenticated - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - /** @psalm-suppress UndefinedMethod */ $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut'); From 31cbf310c5e55bbc5100166971a76a88cb94cb94 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 3 Dec 2024 19:54:33 +0200 Subject: [PATCH 23/57] Tests for Cas20 validate --- src/Controller/Cas20Controller.php | 14 +- src/Controller/Traits/UrlTrait.php | 18 +- tests/src/Controller/Cas20ControllerTest.php | 221 ++++++++++++++++++- tests/src/Controller/Traits/UrlTraitTest.php | 108 ++++++--- 4 files changed, 318 insertions(+), 43 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index ef1891d3..0b6112b1 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -80,7 +80,7 @@ public function __construct( */ public function serviceValidate( Request $request, - #[MapQueryParameter] string $TARGET = '', + #[MapQueryParameter] string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -198,7 +198,7 @@ public function proxy( */ public function proxyValidate( Request $request, - #[MapQueryParameter] string $TARGET = '', + #[MapQueryParameter] string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -214,4 +214,14 @@ public function proxyValidate( pgtUrl: $pgtUrl, ); } + + /** + * Used by the unit tests + * + * @return mixed + */ + public function getTicketStore(): mixed + { + return $this->ticketStore; + } } diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 859d77d3..54088655 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -72,9 +72,9 @@ public function parseQueryParameters(Request $request, ?array $sessionTicket): a * @param Request $request * @param string $paramName * - * @return string|int|array|null + * @return mixed */ - public function getRequestParam(Request $request, string $paramName): string|int|array|null + public function getRequestParam(Request $request, string $paramName): mixed { return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; } @@ -122,7 +122,11 @@ public function validate( // Delete the ticket $this->ticketStore->deleteTicket($ticket); } catch (\Exception $e) { - $message = 'casserver:serviceValidate: internal server error. ' . var_export($e->getMessage(), true); + $messagePostfix = ''; + if (!empty($e->getMessage())) { + $messagePostfix = ': ' . var_export($e->getMessage(), true); + } + $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; Logger::error($message); return new XmlResponse( @@ -135,19 +139,19 @@ public function validate( $message = ''; if (empty($serviceTicket)) { // No ticket - $message = 'ticket: ' . var_export($ticket, true) . ' not recognized'; + $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; $failed = true; } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($_GET['ticket'], true) . + $message = 'Ticket ' . var_export($ticket, true) . ' is a proxy ticket. Use proxyValidate instead.'; $failed = true; } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { // This is not a service ticket - $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket'; + $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket'; $failed = true; } elseif ($this->ticketFactory->isExpired($serviceTicket)) { // the ticket has expired - $message = 'Ticket has ' . var_export($ticket, true) . ' expired'; + $message = 'Ticket ' . var_export($ticket, true) . ' has expired'; $failed = true; } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { // The service url we passed to the query parameters does not match the one in the ticket. diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 5fc5a414..bc10bfc2 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -64,7 +64,7 @@ protected function setUp(): void $this->ticket = [ 'id' => 'ST-' . $this->sessionId, - 'validBefore' => 9999999999, + 'validBefore' => 1731111111, 'service' => 'https://myservice.com/abcd', 'forceAuthn' => false, 'userName' => 'username@google.com', @@ -280,4 +280,223 @@ public function testProxyReturnsProxyTicket(): void $proxyTicket = $this->ticketStore->getTicket($ticketId); $this->assertTrue(filter_var($ticketFactory->isProxyTicket($proxyTicket), FILTER_VALIDATE_BOOLEAN)); } + + public static function validateFailsForEmptyServiceTicket(): array + { + return [ + 'Service URL & TARGET is empty' => [ + ['ticket' => 'ST-12334Q45W4563'], + 'casserver: Missing service parameter: [service]', + ], + 'Ticket is empty but Service is not' => [ + ['service' => 'http://localhost'], + 'casserver: Missing service parameter: [ticket]', + ], + 'Ticket is empty but TARGET is not' => [ + ['TARGET' => 'http://localhost'], + 'casserver: Missing service parameter: [ticket]', + ], + ]; + } + + #[DataProvider('validateFailsForEmptyServiceTicket')] + public function testServiceValidateFailing(array $requestParams, string $message): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $request = Request::create( + uri: '/', + parameters: $requestParams, + ); + $cas20Controller = new Cas20Controller( + $this->sspConfig, + $casconfig, + ); + + $response = $cas20Controller->serviceValidate($request, ...$requestParams); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public function testReturn500OnDeleteTicketThatThrows(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => $this->sessionId, + 'service' => 'https://myservice.com/abcd', + ]; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $ticketStore = new class ($config) extends FileSystemTicketStore { + public function getTicket(string $ticketId): ?array + { + throw new \Exception(); + } + }; + + /** @psalm-suppress InvalidArgument */ + $cas20Controller = new Cas20Controller($this->sspConfig, $config, $ticketStore); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = 'casserver:serviceValidate: internal server error'; + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + + public static function validateOnDifferentQueryParameterCombinations(): array + { + $sessionId = session_create_id(); + return [ + 'Returns Bad Request on Ticket Not Recogniged/Exists' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' not recognized", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket is Proxy' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'PT-{$sessionId}' is a proxy ticket. Use proxyValidate instead.", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket Expired' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'ST-{$sessionId}' has expired", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket Not A Service Ticket' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' is not a service ticket", + $sessionId, + ], + 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ], + "Ticket was issued from single sign on session", + 'ST-' . $sessionId, + 9999999999, + ], + 'Returns Success' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + 'username@google.com', + 'ST-' . $sessionId, + 9999999999, + ], + ]; + } + + #[DataProvider('validateOnDifferentQueryParameterCombinations')] + public function testServiceValidate( + array $requestParams, + string $message, + string $ticketId, + int $validBefore = 1111111111, + ): void { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $requestParams, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + + if (!empty($ticketId)) { + $ticketStore = $cas20Controller->getTicketStore(); + $ticket = $this->ticket; + $ticket['id'] = $ticketId; + $ticket['validBefore'] = $validBefore; + $ticketStore->addTicket($ticket); + } + + $response = $cas20Controller->serviceValidate($request, ...$requestParams); + + if ($message === 'username@google.com') { + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + 'username@google.com', + $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0], + ); + } else { + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + } + + public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/failservice', + ]; + $this->ticket['validBefore'] = 9999999999; + $this->ticket['attributes'] = []; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + $ticketStore = $cas20Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = "Mismatching service parameters: expected 'https://myservice.com/abcd'" . + " but was: 'https://myservice.com/failservice'"; + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } } diff --git a/tests/src/Controller/Traits/UrlTraitTest.php b/tests/src/Controller/Traits/UrlTraitTest.php index aa65eada..25b5dbb1 100644 --- a/tests/src/Controller/Traits/UrlTraitTest.php +++ b/tests/src/Controller/Traits/UrlTraitTest.php @@ -7,44 +7,12 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; +use Symfony\Component\HttpFoundation\Request; class UrlTraitTest extends TestCase { use UrlTrait; - /** - * @param string $service the service url to check - * @param bool $allowed is the service url allowed? - */ - #[DataProvider('checkServiceURLProvider')] - public function testCheckServiceURL(string $service, bool $allowed): void - { - $legalServices = [ - // Regular prefix match - 'https://myservice.com', - 'https://anotherservice.com/', - 'https://anotherservice.com:8080/', - 'http://sub.domain.com/path/a/b/c', - 'https://query.param/secure?apple=red', - 'https://encode.com/space test/', - - // Regex match - '|^https://.*\.subdomain.com/|', - '#^https://.*-someprefix.com/#', - - // Invalid settings don't blow up - '|invalid-regex', - '', - ]; - - $this->assertEquals( - $allowed, - $this->checkServiceURL($this->sanitize($service), $legalServices), - "$service validated wrong", - ); - } - - /** * @return array */ @@ -85,4 +53,78 @@ public static function checkServiceURLProvider(): array ['', false], ]; } + + /** + * @param string $service the service url to check + * @param bool $allowed is the service url allowed? + */ + #[DataProvider('checkServiceURLProvider')] + public function testCheckServiceURL(string $service, bool $allowed): void + { + $legalServices = [ + // Regular prefix match + 'https://myservice.com', + 'https://anotherservice.com/', + 'https://anotherservice.com:8080/', + 'http://sub.domain.com/path/a/b/c', + 'https://query.param/secure?apple=red', + 'https://encode.com/space test/', + + // Regex match + '|^https://.*\.subdomain.com/|', + '#^https://.*-someprefix.com/#', + + // Invalid settings don't blow up + '|invalid-regex', + '', + ]; + + $this->assertEquals( + $allowed, + $this->checkServiceURL($this->sanitize($service), $legalServices), + "$service validated wrong", + ); + } + + public static function requestParameters(): array + { + return [ + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + [], + ], + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true], + ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'renewId' => '1234'], + [ + 'renewId' => '1234', + ], + ], + [ + ['renew' => true, 'language' => 'Greek', 'debugMode' => true, 'TARGET' => 'http://localhost'], + [ + 'renew' => true, + 'language' => 'Greek', + 'debugMode' => true, + 'renewId' => '1234', + 'TARGET' => 'http://localhost', + ], + [ + 'renewId' => '1234', + ], + ], + ]; + } + + #[DataProvider('requestParameters')] + public function testParseQueryParameters(array $requestParams, array $query, array $sessionTicket): void + { + $request = Request::create( + uri: '/', + parameters: $requestParams, + ); + + $this->assertEquals($query, $this->parseQueryParameters($request, $sessionTicket)); + } } From 31618c57ecf16ddcffb46a56e418b8706332ab41 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 4 Dec 2024 12:47:08 +0200 Subject: [PATCH 24/57] add tests for Proxy Service Validate --- src/Controller/Cas20Controller.php | 6 +++ src/Controller/Traits/UrlTrait.php | 21 ++++++-- tests/src/Controller/Cas20ControllerTest.php | 52 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 0b6112b1..c2c8b4bb 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -24,6 +25,9 @@ class Cas20Controller /** @var Logger */ protected Logger $logger; + /** @var Utils\HTTP */ + protected Utils\HTTP $httpUtils; + /** @var Configuration */ protected Configuration $casConfig; @@ -47,6 +51,7 @@ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, $ticketStore = null, + Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor @@ -64,6 +69,7 @@ public function __construct( $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' . explode(':', $ticketStoreConfig['class'])[1]; $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } /** diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 54088655..be3ef99b 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -198,15 +198,28 @@ public function validate( ], ); try { - $this->httpUtils->fetch( + // Here we assume that the fetch will throw on any error. + // The generation of the proxy-granting-ticket or the corresponding proxy granting ticket IOU may + // fail due to the proxy callback url failing to meet the minimum security requirements such as + // failure to establish trust between peers or unresponsiveness of the endpoint, etc. + // In case of failure, no proxy-granting ticket will be issued and the CAS service response + // as described in Section 2.5.2 MUST NOT contain a block. + // At this point, the issuance of a proxy-granting ticket is halted and service ticket + // validation will fail. + $data = $this->httpUtils->fetch( $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], ); - + Logger::debug(__METHOD__ . '::data: ' . var_export($data, true)); $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - $this->ticketStore->addTicket($proxyGrantingTicket); } catch (\Exception $e) { - // Fall through + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse( + C::ERR_INVALID_SERVICE, + 'Proxy callback url is failing.', + ), + Response::HTTP_BAD_REQUEST, + ); } } } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index bc10bfc2..049f5f46 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; @@ -14,6 +15,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas20Controller; use SimpleSAML\Session; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -33,6 +35,8 @@ class Cas20ControllerTest extends TestCase private TicketValidator $ticketValidatorMock; + private Utils\HTTP|MockObject $utilsHttpMock; + private array $ticket; /** @@ -62,6 +66,11 @@ protected function setUp(): void ->onlyMethods(['getSessionId']) ->getMock(); + $this->utilsHttpMock = $this->getMockBuilder(Utils\HTTP::class) + ->disableOriginalConstructor() + ->onlyMethods(['fetch']) + ->getMock(); + $this->ticket = [ 'id' => 'ST-' . $this->sessionId, 'validBefore' => 1731111111, @@ -499,4 +508,47 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], ); } + + public function testThrowOnProxyServiceIdentityFail(): void + { + $config = Configuration::loadFromArray($this->moduleConfig); + $params = [ + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'pgtUrl' => 'https://myservice.com/proxy', + ]; + $this->ticket['validBefore'] = 9999999999; + $sessionTicket = $this->ticket; + $sessionTicket['id'] = $this->sessionId; + + $request = Request::create( + uri: 'http://localhost', + parameters: $params, + ); + + $this->utilsHttpMock->expects($this->once()) + ->method('fetch') + ->willThrowException(new \Exception()); + + $cas20Controller = new Cas20Controller( + sspConfig: $this->sspConfig, + casConfig: $config, + httpUtils: $this->utilsHttpMock, + ); + $ticketStore = $cas20Controller->getTicketStore(); + $ticketStore->addTicket($this->ticket); + $ticketStore->addTicket($sessionTicket); + $response = $cas20Controller->serviceValidate($request, ...$params); + + $message = 'Proxy callback url is failing.'; + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } } From f3144727bbf131f0754ae5d0acc4cef5c7788f02 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 4 Dec 2024 13:39:21 +0200 Subject: [PATCH 25/57] More tests on Cas20Controller.php --- composer.json | 3 ++ tests/src/Controller/Cas20ControllerTest.php | 34 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/composer.json b/composer.json index 7dd8fda7..bf44351d 100644 --- a/composer.json +++ b/composer.json @@ -70,6 +70,9 @@ ], "tests": [ "vendor/bin/phpunit --no-coverage" + ], + "propose-fix": [ + "vendor/bin/phpcs --report=diff" ] } } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 049f5f46..3cebcc18 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -91,6 +91,40 @@ protected function setUp(): void ]; } + public function testProxyValidatePassesTheCorrectMethodToValidate(): void + { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $requestParameters = [ + 'renew' => false, + 'service' => 'https://myservice.com/abcd', + 'ticket' => 'ST-' . $this->sessionId, + ]; + + $request = Request::create( + uri: 'https://myservice.com/abcd', + parameters: $requestParameters, + ); + + $expectedArguments = [ + 'request' => $request, + 'method' => 'proxyValidate', + 'renew' => false, + 'target' => null, + 'ticket' => 'ST-' . $this->sessionId, + 'service' => 'https://myservice.com/abcd', + 'pgtUrl' => null, + ]; + + $controllerMock = $this->getMockBuilder(Cas20Controller::class) + ->setConstructorArgs([$this->sspConfig, $casconfig]) + ->onlyMethods(['validate']) + ->getMock(); + + $controllerMock->expects($this->once())->method('validate') + ->with(...$expectedArguments); + $controllerMock->proxyValidate($request, ...$requestParameters); + } + public static function queryParameterValues(): array { return [ From 36c2e4e2a7870ef0d4304b67d0cbaf669b47b625 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 08:52:21 +0200 Subject: [PATCH 26/57] Use TicketStore abstract class type --- src/Controller/Cas10Controller.php | 9 +++++---- src/Controller/Cas20Controller.php | 10 ++++++---- src/Controller/LoginController.php | 8 ++++---- src/Controller/LogoutController.php | 6 +++--- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 66ee55c1..a41b2f90 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -8,6 +8,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas10; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,20 +32,20 @@ class Cas10Controller /** @var TicketFactory */ protected TicketFactory $ticketFactory; - // this could be any configured ticket store - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** * @param Configuration $sspConfig * @param Configuration|null $casConfig - * @param null $ticketStore + * @param TicketStore|null $ticketStore * * @throws \Exception */ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, - $ticketStore = null, + TicketStore $ticketStore = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index c2c8b4bb..2095ac4b 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -9,6 +9,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Utils; @@ -37,20 +38,21 @@ class Cas20Controller /** @var TicketFactory */ protected TicketFactory $ticketFactory; - // this could be any configured ticket store - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** * @param Configuration $sspConfig * @param Configuration|null $casConfig - * @param $ticketStore + * @param TicketStore|null $ticketStore + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ public function __construct( private readonly Configuration $sspConfig, Configuration $casConfig = null, - $ticketStore = null, + TicketStore $ticketStore = null, Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 795c76c8..a329a464 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; use SimpleSAML\Module\casserver\Cas\ServiceValidator; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; @@ -48,9 +49,8 @@ class LoginController /** @var Cas20 */ protected Cas20 $cas20Protocol; - // this could be any configured ticket store - /** @var mixed */ - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** @var ServiceValidator */ protected ServiceValidator $serviceValidator; @@ -76,7 +76,7 @@ class LoginController * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param HTTP|null $httpUtils + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index d104d023..d8f89d06 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -10,6 +10,7 @@ use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; +use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -37,9 +38,8 @@ class LogoutController /** @var SspContainer */ protected SspContainer $container; - // this could be any configured ticket store - /** @var mixed */ - protected mixed $ticketStore; + /** @var TicketStore */ + protected TicketStore $ticketStore; /** From 640c7eb150155f354a9a2745b077ee528f6cb39d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 09:05:53 +0200 Subject: [PATCH 27/57] Use Module::resolveClass --- src/Controller/Cas10Controller.php | 5 ++--- src/Controller/Cas20Controller.php | 4 ++-- src/Controller/LoginController.php | 3 +-- src/Controller/LogoutController.php | 3 +-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index a41b2f90..c3ddacae 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -6,6 +6,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas10; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; @@ -60,9 +61,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; - /** @psalm-suppress InvalidStringClass */ + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); } diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 2095ac4b..3da28a3d 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -7,6 +7,7 @@ use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; @@ -68,8 +69,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig); $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index a329a464..6d5f7316 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -102,8 +102,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); // Ticket Store $this->ticketStore = new $ticketStoreClass($this->casConfig); // Processing Chain Factory diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index d8f89d06..825893e3 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -71,8 +71,7 @@ public function __construct( 'ticketstore', ['class' => 'casserver:FileSystemTicketStore'], ); - $ticketStoreClass = 'SimpleSAML\\Module\\casserver\\Cas\\Ticket\\' - . explode(':', $ticketStoreConfig['class'])[1]; + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); $this->ticketStore = new $ticketStoreClass($this->casConfig); } From 801c4fd2c1452eccede36cd049e6ca39a4af6b1c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 09:53:20 +0200 Subject: [PATCH 28/57] Use RunnableResponse instead of redirect --- src/Controller/LogoutController.php | 21 +++---- tests/src/Controller/LogoutControllerTest.php | 59 ++++++++++--------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 825893e3..11b3ad2b 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -5,15 +5,15 @@ namespace SimpleSAML\Module\casserver\Controller; use SimpleSAML\Auth\Simple; -use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Session; -use Symfony\Component\HttpFoundation\RedirectResponse; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; @@ -35,17 +35,18 @@ class LogoutController /** @var Simple */ protected Simple $authSource; - /** @var SspContainer */ - protected SspContainer $container; + /** @var Utils\HTTP */ + protected Utils\HTTP $httpUtils; /** @var TicketStore */ protected TicketStore $ticketStore; /** + * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param SspContainer|null $container + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ @@ -54,7 +55,7 @@ public function __construct( // Facilitate testing Configuration $casConfig = null, Simple $source = null, - SspContainer $container = null, + Utils\HTTP $httpUtils = null, ) { // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor @@ -62,7 +63,7 @@ public function __construct( $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) ? Configuration::getConfig('module_casserver.php') : $casConfig; $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->container = $container ?? new SspContainer(); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); /* Instantiate ticket factory */ $this->ticketFactory = new TicketFactory($this->casConfig); @@ -80,12 +81,12 @@ public function __construct( * @param Request $request * @param string|null $url * - * @return RedirectResponse|null + * @return RunnableResponse|null */ public function logout( Request $request, #[MapQueryParameter] ?string $url = null, - ): RedirectResponse|null { + ): RunnableResponse|null { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { $this->handleExceptionThrown('Logout not allowed'); } @@ -115,7 +116,7 @@ public function logout( // Redirect if (!$this->authSource->isAuthenticated()) { - $this->container->redirect($logoutRedirectUrl, $params); + return new RunnableResponse([$this->httpUtils, 'redirectTrustedURL'], [$logoutRedirectUrl, $params]); } // Logout and redirect diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 54c31ea8..6df16f65 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -7,11 +7,11 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; -use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; use SimpleSAML\Session; +use SimpleSAML\Utils; use Symfony\Component\HttpFoundation\Request; class LogoutControllerTest extends TestCase @@ -20,7 +20,7 @@ class LogoutControllerTest extends TestCase private Simple|MockObject $authSimpleMock; - private SspContainer|MockObject $sspContainer; + private Utils\HTTP $httpUtils; private Configuration $sspConfig; @@ -31,11 +31,6 @@ protected function setUp(): void ->onlyMethods(['logout', 'isAuthenticated']) ->getMock(); - $this->sspContainer = $this->getMockBuilder(SspContainer::class) - ->disableOriginalConstructor() - ->onlyMethods(['redirect']) - ->getMock(); - $this->moduleConfig = [ 'ticketstore' => [ 'class' => 'casserver:FileSystemTicketStore', //Not intended for production @@ -43,6 +38,7 @@ protected function setUp(): void ], ]; + $this->httpUtils = new Utils\HTTP(); $this->sspConfig = Configuration::getConfig('config.php'); } @@ -91,12 +87,8 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo($urlParam), - [], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $logoutUrl = Module::getModuleURL('casserver/logout.php'); @@ -104,7 +96,14 @@ public function testLogoutWithRedirectUrlOnSkipLogout(): void uri: $logoutUrl, parameters: ['url' => $urlParam], ); - $controller->logout($request, $urlParam); + $response = $controller->logout($request, $urlParam); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($urlParam, $arguments[0]); } public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void @@ -115,13 +114,16 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutUnAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo('http://localhost/module.php/casserver/loggedOut'), - [], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); - $controller->logout(Request::create('/')); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); + $response = $controller->logout(Request::create('/')); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]); } public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void @@ -134,17 +136,20 @@ public function testLogoutWithRedirectUrlOnNoSkipLogoutUnAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->sspContainer->expects($this->once())->method('redirect')->with( - $this->equalTo($logoutUrl), - ['url' => $urlParam], - ); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $request = Request::create( uri: $logoutUrl, parameters: ['url' => $urlParam], ); - $controller->logout($request, $urlParam); + $response = $controller->logout($request, $urlParam); + + $callable = $response->getCallable(); + $method = is_array($callable) ? $callable[1] : 'unknown'; + $arguments = $response->getArguments(); + $this->assertEquals('redirectTrustedURL', $method); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('http://localhost/module.php/casserver/loggedOut', $arguments[0]); } public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void @@ -158,7 +163,7 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void $this->authSimpleMock->expects($this->once())->method('logout') ->with('http://localhost/module.php/casserver/loggedOut'); - $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer); + $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); $controller->logout(Request::create('/')); } @@ -169,7 +174,7 @@ public function testTicketIdGetsDeletedOnLogout(): void $config = Configuration::loadFromArray($this->moduleConfig); $controllerMock = $this->getMockBuilder(LogoutController::class) - ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->sspContainer]) + ->setConstructorArgs([$this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils]) ->onlyMethods(['getSession']) ->getMock(); From 18e3e7220da38ab38d816a69ff14d04b83f2d80b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 15 Dec 2024 10:29:59 +0200 Subject: [PATCH 29/57] Added phpdocs --- tests/src/Controller/Cas10ControllerTest.php | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index 1acc1c4e..6eba2289 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -88,6 +88,12 @@ public static function queryParameterValues(): array ]; } + /** + * @param array $params + * + * @return void + * @throws Exception + */ #[DataProvider('queryParameterValues')] public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void { @@ -105,6 +111,10 @@ public function testReturnBadRequestOnEmptyServiceOrTicket(array $params): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturn500OnDeleteTicketThatThrows(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -133,6 +143,10 @@ public function getTicket(string $ticketId): ?array $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketNotExist(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -153,6 +167,10 @@ public function testReturnBadRequestOnTicketNotExist(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketExpired(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -175,6 +193,10 @@ public function testReturnBadRequestOnTicketExpired(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketNotService(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -199,6 +221,10 @@ public function testReturnBadRequestOnTicketNotService(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketMissingUsernameField(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -223,6 +249,10 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -247,6 +277,10 @@ public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): voi $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void { $config = Configuration::loadFromArray($this->moduleConfig); @@ -271,6 +305,10 @@ public function testReturnBadRequestOnTicketIssuedBySingleSignOnSession(): void $this->assertEquals("no\n\n", $response->getContent()); } + /** + * @return void + * @throws Exception + */ public function testSuccessfullValidation(): void { $config = Configuration::loadFromArray($this->moduleConfig); From dfea964910485dea96800e02fec516a0b2e8910e Mon Sep 17 00:00:00 2001 From: Patrick Radtke Date: Wed, 4 Dec 2024 16:56:10 -0800 Subject: [PATCH 30/57] Fix run with docker instructions --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c26f5f0..2538915e 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,16 @@ of the `casserver` module mounted in the container, along with some configuratio "live" in the container, allowing you to test and iterate different things. ```bash -# Note: this currently errors on this module requiring a newer version of `simplesamlphp/xml-common` than what is in the base image docker run --name ssp-casserver-dev \ --mount type=bind,source="$(pwd)",target=/var/simplesamlphp/staging-modules/casserver,readonly \ -e STAGINGCOMPOSERREPOS=casserver \ - -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-casserver:@dev simplesamlphp/simplesamlphp-module-preprodwarning" + -e COMPOSER_REQUIRE="simplesamlphp/simplesamlphp-module-casserver:@dev simplesamlphp/simplesamlphp-module-preprodwarning" \ -e SSP_ADMIN_PASSWORD=secret1 \ --mount type=bind,source="$(pwd)/docker/ssp/module_casserver.php",target=/var/simplesamlphp/config/module_casserver.php,readonly \ --mount type=bind,source="$(pwd)/docker/ssp/authsources.php",target=/var/simplesamlphp/config/authsources.php,readonly \ --mount type=bind,source="$(pwd)/docker/ssp/config-override.php",target=/var/simplesamlphp/config/config-override.php,readonly \ --mount type=bind,source="$(pwd)/docker/apache-override.cf",target=/etc/apache2/sites-enabled/ssp-override.cf,readonly \ - -p 443:443 cirrusid/simplesamlphp:v2.3.2 + -p 443:443 cirrusid/simplesamlphp:v2.3.5 ``` Visit [https://localhost/simplesaml/](https://localhost/simplesaml/) and confirm you get the default page. From 9cc0b5c539fcc55782fd4ac76a09840ad05b7f50 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 19 Dec 2024 15:54:24 +0200 Subject: [PATCH 31/57] xml response to UTF-8 --- src/Controller/Cas10Controller.php | 2 +- src/Http/XmlResponse.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index c3ddacae..7802503c 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -85,7 +85,7 @@ public function validate( // Check if any of the required query parameters are missing // Even though we can delegate the check to Symfony's `MapQueryParameter` we cannot return // the failure response needed. As a result, we allow a default value, and we handle the missing - // values afterwards. + // values afterward. if ($service === null || $ticket === null) { $messagePostfix = $service === null ? 'service' : 'ticket'; Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]"); diff --git a/src/Http/XmlResponse.php b/src/Http/XmlResponse.php index 8480b659..23e00c0e 100644 --- a/src/Http/XmlResponse.php +++ b/src/Http/XmlResponse.php @@ -11,7 +11,7 @@ class XmlResponse extends Response public function __construct(?string $content = '', int $status = 200, array $headers = []) { parent::__construct($content, $status, array_merge($headers, [ - 'Content-Type' => 'text/xml; charset=ISO-8859-1', + 'Content-Type' => 'text/xml; charset=UTF-8', ])); } } From 5ea16ac9315e80376c4643b5bafd042db8ee95ba Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 19 Dec 2024 21:52:28 +0200 Subject: [PATCH 32/57] Fix validation condition for CAS10 --- src/Cas/AttributeExtractor.php | 7 ++----- src/Controller/Cas10Controller.php | 6 +++--- tests/src/Controller/Cas10ControllerTest.php | 4 ++-- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Cas/AttributeExtractor.php b/src/Cas/AttributeExtractor.php index 275f38d8..291574e2 100644 --- a/src/Cas/AttributeExtractor.php +++ b/src/Cas/AttributeExtractor.php @@ -55,7 +55,7 @@ public function __construct( * @param array|null $state * * @return array - * @throws Exception + * @throws \Exception */ public function extractUserAndAttributes(?array $state): array { @@ -76,12 +76,11 @@ public function extractUserAndAttributes(?array $state): array throw new \Exception("No cas user defined for attribute $casUsernameAttribute"); } + $casAttributes = []; if ($this->casconfig->getOptionalValue('attributes', true)) { $attributesToTransfer = $this->casconfig->getOptionalValue('attributes_to_transfer', []); if (sizeof($attributesToTransfer) > 0) { - $casAttributes = []; - foreach ($attributesToTransfer as $key) { if (\array_key_exists($key, $attributes)) { $casAttributes[$key] = $attributes[$key]; @@ -90,8 +89,6 @@ public function extractUserAndAttributes(?array $state): array } else { $casAttributes = $attributes; } - } else { - $casAttributes = []; } return [ diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 7802503c..0de3e102 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -147,8 +147,8 @@ public function validate( // Get the username field $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - // Fail if the username field is not present in the attribute list - if (!\array_key_exists($usernameField, $serviceTicket['attributes'])) { + // Fail if the username is not present in the ticket + if (empty($serviceTicket['userName'])) { Logger::error( 'casserver:validate: internal server error. Missing user name attribute: ' . var_export($usernameField, true), @@ -161,7 +161,7 @@ public function validate( // Successful validation return new Response( - $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['attributes'][$usernameField][0]), + $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['userName']), Response::HTTP_OK, ); } diff --git a/tests/src/Controller/Cas10ControllerTest.php b/tests/src/Controller/Cas10ControllerTest.php index 6eba2289..0b37f18a 100644 --- a/tests/src/Controller/Cas10ControllerTest.php +++ b/tests/src/Controller/Cas10ControllerTest.php @@ -233,7 +233,7 @@ public function testReturnBadRequestOnTicketMissingUsernameField(): void 'service' => 'https://myservice.com/abcd', ]; $this->ticket['validBefore'] = 9999999999; - $this->ticket['attributes'] = []; + $this->ticket['userName'] = ''; $request = Request::create( uri: 'http://localhost', @@ -330,6 +330,6 @@ public function testSuccessfullValidation(): void $response = $cas10Controller->validate($request, ...$params); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals("yes\neduPersonPrincipalName@google.com\n", $response->getContent()); + $this->assertEquals("yes\nusername@google.com\n", $response->getContent()); } } From a1780a80c2a2a0bd4cefaf3c3d61d41965a0c862 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 09:06:31 +0200 Subject: [PATCH 33/57] review fixes #1 --- src/Cas/ServiceValidator.php | 77 ++++++++++++++++++----------- src/Cas/Ticket/SQLTicketStore.php | 8 +-- src/Controller/Cas10Controller.php | 11 +---- src/Controller/Cas20Controller.php | 8 +-- src/Controller/LoginController.php | 18 +++---- src/Controller/LogoutController.php | 4 +- 6 files changed, 69 insertions(+), 57 deletions(-) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index f6dd8897..ed7c79e6 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -29,53 +29,70 @@ public function __construct(Configuration $mainConfig) /** * Check that the $service is allowed, and if so return the configuration to use. - * @param string $service The service url. Assume to already be url decoded + * + * @param string $service The service url. Assume to already be url decoded + * * @return Configuration|null Return the configuration to use for this service, or null if service is not allowed + * @throws \ErrorException */ public function checkServiceURL(string $service): ?Configuration { $isValidService = false; $legalUrl = 'undefined'; $configOverride = null; - foreach ($this->mainConfig->getOptionalArray('legal_service_urls', []) as $index => $value) { + $legalServiceUrlsConfig = $this->mainConfig->getOptionalArray('legal_service_urls', []); + + foreach ($legalServiceUrlsConfig as $index => $value) { // Support two styles: 0 => 'https://example' and 'https://example' => [ extra config ] - if (is_int($index)) { - $legalUrl = $value; - $configOverride = null; - } else { - $legalUrl = $index; - $configOverride = $value; - } + $legalUrl = \is_int($index) ? $value : $index; if (empty($legalUrl)) { Logger::warning("Ignoring empty CAS legal service url '$legalUrl'."); continue; } - if (!ctype_alnum($legalUrl[0])) { - // Probably a regex. Suppress errors incase the format is invalid - $result = @preg_match($legalUrl, $service); - if ($result === 1) { - $isValidService = true; - break; - } elseif ($result === false) { - Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error()); + + $configOverride = \is_int($index) ? null : $value; + + // URL String + if (str_starts_with($service, $legalUrl)) { + $isValidService = true; + break; + } + + // Regex + // Since "If the regex pattern passed does not compile to a valid regex, an E_WARNING is emitted. " + // we will throw an exception if the warning is emitted and use try-catch to handle it + set_error_handler(static function ($severity, $message, $file, $line) { + throw new \ErrorException($message, $severity, $severity, $file, $line); + }, E_WARNING); + + try { + $result = preg_match($legalUrl, $service); + if ($result !== 1) { + throw new \RuntimeException('Service URL does not match legal service URL.'); } - } elseif (strpos($service, $legalUrl) === 0) { $isValidService = true; break; + } catch (\Exception $e) { + // do nothing + Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error()); + } finally { + restore_error_handler(); } } - if ($isValidService) { - $serviceConfig = $this->mainConfig->toArray(); - // Return contextual information about which url rule triggered the validation - $serviceConfig['casService'] = [ - 'matchingUrl' => $legalUrl, - 'serviceUrl' => $service, - ]; - if ($configOverride) { - $serviceConfig = array_merge($serviceConfig, $configOverride); - } - return Configuration::loadFromArray($serviceConfig); + + if (!$isValidService) { + return null; + } + + $serviceConfig = $this->mainConfig->toArray(); + // Return contextual information about which url rule triggered the validation + $serviceConfig['casService'] = [ + 'matchingUrl' => $legalUrl, + 'serviceUrl' => $service, + ]; + if ($configOverride) { + $serviceConfig = array_merge($serviceConfig, $configOverride); } - return null; + return Configuration::loadFromArray($serviceConfig); } } diff --git a/src/Cas/Ticket/SQLTicketStore.php b/src/Cas/Ticket/SQLTicketStore.php index 5ab0d844..0603ed8c 100644 --- a/src/Cas/Ticket/SQLTicketStore.php +++ b/src/Cas/Ticket/SQLTicketStore.php @@ -321,9 +321,11 @@ private function get(string $key): ?array /** - * @param string $key - * @param array $value - * @param int|null $expire + * @param string $key + * @param array $value + * @param int|null $expire + * + * @throws Exception */ private function set(string $key, array $value, int $expire = null): void { diff --git a/src/Controller/Cas10Controller.php b/src/Controller/Cas10Controller.php index 0de3e102..81c7f4c7 100644 --- a/src/Controller/Cas10Controller.php +++ b/src/Controller/Cas10Controller.php @@ -144,15 +144,8 @@ public function validate( ); } - // Get the username field - $usernameField = $this->casConfig->getOptionalValue('attrname', 'eduPersonPrincipalName'); - // Fail if the username is not present in the ticket if (empty($serviceTicket['userName'])) { - Logger::error( - 'casserver:validate: internal server error. Missing user name attribute: ' - . var_export($usernameField, true), - ); return new Response( $this->cas10Protocol->getValidateFailureResponse(), Response::HTTP_BAD_REQUEST, @@ -169,9 +162,9 @@ public function validate( /** * Used by the unit tests * - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 3da28a3d..eb903f4e 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -88,7 +88,7 @@ public function __construct( */ public function serviceValidate( Request $request, - #[MapQueryParameter] string $TARGET = null, + #[MapQueryParameter] ?string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, @@ -124,7 +124,7 @@ public function proxy( $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); // Fail if $message = match (true) { - // targetService pareameter is not defined + // targetService parameter is not defined $targetService === null => 'Missing target service parameter [targetService]', // pgt parameter is not defined $pgt === null => 'Missing proxy granting ticket parameter: [pgt]', @@ -226,9 +226,9 @@ public function proxyValidate( /** * Used by the unit tests * - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 6d5f7316..05bbda62 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -134,13 +134,13 @@ public function login( Request $request, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] bool $gateway = false, - #[MapQueryParameter] string $service = null, - #[MapQueryParameter] string $TARGET = null, - #[MapQueryParameter] string $scope = null, - #[MapQueryParameter] string $language = null, - #[MapQueryParameter] string $entityId = null, - #[MapQueryParameter] string $debugMode = null, - #[MapQueryParameter] string $method = null, + #[MapQueryParameter] ?string $service = null, + #[MapQueryParameter] ?string $TARGET = null, + #[MapQueryParameter] ?string $scope = null, + #[MapQueryParameter] ?string $language = null, + #[MapQueryParameter] ?string $entityId = null, + #[MapQueryParameter] ?string $debugMode = null, + #[MapQueryParameter] ?string $method = null, ): RedirectResponse|XmlResponse|null { $forceAuthn = $renew; $serviceUrl = $service ?? $TARGET ?? null; @@ -421,9 +421,9 @@ public function getSession(): ?Session } /** - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 11b3ad2b..1ee868dc 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -127,9 +127,9 @@ public function logout( } /** - * @return mixed + * @return TicketStore */ - public function getTicketStore(): mixed + public function getTicketStore(): TicketStore { return $this->ticketStore; } From c56cac33f4765b1ce865bd2e966aad7d579f324e Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 10:01:28 +0200 Subject: [PATCH 34/57] Intantiate classes after we finalize the casconfig overrides. Refactor checkServiceUrl.Improve composer validate. --- composer.json | 6 ++-- src/Controller/Cas20Controller.php | 2 +- src/Controller/LoginController.php | 54 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/composer.json b/composer.json index bf44351d..db8476b8 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.1", - "ext-ctype": "*", "ext-dom": "*", "ext-filter": "*", "ext-libxml": "*", @@ -54,7 +53,8 @@ "psalm/plugin-phpunit": "^0.19.0", "squizlabs/php_codesniffer": "^3.7", "maglnet/composer-require-checker": "4.7.1", - "vimeo/psalm": "^5" + "vimeo/psalm": "^5", + "icanhazstring/composer-unused": "^0.8.11" }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp-module-casserver/issues", @@ -66,7 +66,7 @@ "vendor/bin/phpcs -p", "vendor/bin/composer-require-checker check composer.json", "vendor/bin/psalm -c psalm-dev.xml", - "vendor/bin/psalm -c psalm.xml" + "vendor/bin/composer-unused" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index eb903f4e..717accbe 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -206,7 +206,7 @@ public function proxy( */ public function proxyValidate( Request $request, - #[MapQueryParameter] string $TARGET = null, + #[MapQueryParameter] ?string $TARGET = null, #[MapQueryParameter] bool $renew = false, #[MapQueryParameter] ?string $ticket = null, #[MapQueryParameter] ?string $service = null, diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 05bbda62..5e7f3ec5 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -76,7 +76,7 @@ class LoginController * @param Configuration $sspConfig * @param Configuration|null $casConfig * @param Simple|null $source - * @param Utils\HTTP|null $httpUtils + * @param Utils\HTTP|null $httpUtils * * @throws \Exception */ @@ -89,28 +89,12 @@ public function __construct( ) { $this->casConfig = ($casConfig === null || $casConfig === $sspConfig) ? Configuration::getConfig('module_casserver.php') : $casConfig; - - $this->cas20Protocol = new Cas20($this->casConfig); - $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); - $this->httpUtils = $httpUtils ?? new Utils\HTTP(); - - $this->serviceValidator = new ServiceValidator($this->casConfig); - /* Instantiate ticket factory */ - $this->ticketFactory = new TicketFactory($this->casConfig); - /* Instantiate ticket store */ - $ticketStoreConfig = $this->casConfig->getOptionalValue( - 'ticketstore', - ['class' => 'casserver:FileSystemTicketStore'], - ); - $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); - // Ticket Store - $this->ticketStore = new $ticketStoreClass($this->casConfig); - // Processing Chain Factory - $processingChainFactory = new ProcessingChainFactory($this->casConfig); - // Attribute Extractor - $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); // Saml Validate Responsder $this->samlValidateResponder = new SamlValidateResponder(); + // Service Validator needs the generic casserver configuration. We do not need + $this->serviceValidator = new ServiceValidator($this->casConfig); + $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); + $this->httpUtils = $httpUtils ?? new Utils\HTTP(); } /** @@ -148,6 +132,7 @@ public function login( // Set initial configurations, or fail $this->handleServiceConfiguration($serviceUrl); + $this->intantiateClassDependencies($this->authSource, $this->httpUtils); $this->handleScope($scope); $this->handleLanguage($language); @@ -427,4 +412,31 @@ public function getTicketStore(): TicketStore { return $this->ticketStore; } + + /** + * @param Simple|null $source + * @param Utils\HTTP|null $httpUtils + * + * @return void + * @throws \Exception + */ + private function intantiateClassDependencies(Simple $source = null, Utils\HTTP $httpUtils = null): void + { + $this->cas20Protocol = new Cas20($this->casConfig); + + /* Instantiate ticket factory */ + $this->ticketFactory = new TicketFactory($this->casConfig); + /* Instantiate ticket store */ + $ticketStoreConfig = $this->casConfig->getOptionalValue( + 'ticketstore', + ['class' => 'casserver:FileSystemTicketStore'], + ); + $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket'); + // Ticket Store + $this->ticketStore = new $ticketStoreClass($this->casConfig); + // Processing Chain Factory + $processingChainFactory = new ProcessingChainFactory($this->casConfig); + // Attribute Extractor + $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory); + } } From 8c9a76428d4a0d07d92105ee6890c8bb349d25ae Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 11:53:15 +0200 Subject: [PATCH 35/57] fix UrlTrait::validate to handle proxy tickets --- src/Controller/Cas20Controller.php | 27 ++-- src/Controller/Cas30Controller.php | 5 +- src/Controller/LoginController.php | 7 +- src/Controller/Traits/UrlTrait.php | 41 ++++-- tests/src/Controller/Cas20ControllerTest.php | 130 ++++++++++++++++++- 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 717accbe..3946080b 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -76,13 +76,13 @@ public function __construct( /** * @param Request $request - * @param string $TARGET - * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed - * if the service ticket was issued from the presentation of the user’s primary - * credentials. It will fail if the ticket was issued from a single sign-on session. - * @param string|null $ticket [REQUIRED] - the service ticket issued by /login - * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued - * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * @param string|null $TARGET + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback * * @return XmlResponse */ @@ -195,13 +195,14 @@ public function proxy( /** * @param Request $request - * @param string $TARGET // todo: this should go away??? - * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed - * if the service ticket was issued from the presentation of the user’s primary - * credentials. It will fail if the ticket was issued from a single sign-on session. - * @param string|null $ticket [REQUIRED] - the service ticket issued by /login + * @param string|null $TARGET + * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed + * if the service ticket was issued from the presentation of the user’s primary + * credentials. It will fail if the ticket was issued from a single sign-on session. + * @param string|null $ticket [REQUIRED] - the service ticket issued by /login * @param string|null $service [REQUIRED] - the identifier of the service for which the ticket was issued - * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * @param string|null $pgtUrl [OPTIONAL] - the URL of the proxy callback + * * @return XmlResponse */ public function proxyValidate( diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index d71ee46e..874dc155 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -37,8 +37,9 @@ class Cas30Controller protected SamlValidateResponder $validateResponder; /** - * @param Configuration $sspConfig - * @param Configuration|null $casConfig + * @param Configuration $sspConfig + * @param Configuration|null $casConfig + * @param TicketValidator|null $ticketValidator * * @throws \Exception */ diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 5e7f3ec5..58d0ac9a 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -132,7 +132,7 @@ public function login( // Set initial configurations, or fail $this->handleServiceConfiguration($serviceUrl); - $this->intantiateClassDependencies($this->authSource, $this->httpUtils); + $this->instantiateClassDependencies(); $this->handleScope($scope); $this->handleLanguage($language); @@ -414,13 +414,10 @@ public function getTicketStore(): TicketStore } /** - * @param Simple|null $source - * @param Utils\HTTP|null $httpUtils - * * @return void * @throws \Exception */ - private function intantiateClassDependencies(Simple $source = null, Utils\HTTP $httpUtils = null): void + private function instantiateClassDependencies(): void { $this->cas20Protocol = new Cas20($this->casConfig); diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index be3ef99b..761ab477 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -105,7 +105,7 @@ public function validate( // Check if any of the required query parameters are missing if ($serviceUrl === null || $ticket === null) { $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing service parameter: [{$messagePostfix}]"; + $message = "casserver: Missing {$messagePostfix} parameter: [{$messagePostfix}]"; Logger::debug($message); return new XmlResponse( @@ -117,10 +117,8 @@ public function validate( try { // Get the service ticket // `getTicket` uses the unserializable method and Objects may throw Throwables in their - // unserialization handlers. + // un-serialization handlers. $serviceTicket = $this->ticketStore->getTicket($ticket); - // Delete the ticket - $this->ticketStore->deleteTicket($ticket); } catch (\Exception $e) { $messagePostfix = ''; if (!empty($e->getMessage())) { @@ -137,19 +135,40 @@ public function validate( $failed = false; $message = ''; + // Below, we do not have a ticket or the ticket does not meet the very basic criteria that allow + // any further handling if (empty($serviceTicket)) { // No ticket $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; $failed = true; - } elseif ($method === 'serviceValidate' && $this->ticketFactory->isProxyTicket($serviceTicket)) { - $message = 'Ticket ' . var_export($ticket, true) . - ' is a proxy ticket. Use proxyValidate instead.'; + } elseif ($method === 'proxyValidate' && !$this->ticketFactory->isProxyTicket($serviceTicket)) { + // proxyValidate but not a proxy ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a proxy ticket.'; $failed = true; - } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) { - // This is not a service ticket - $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket'; + } elseif ($method === 'serviceValidate' && !$this->ticketFactory->isServiceTicket($serviceTicket)) { + // serviceValidate but not a service ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket.'; $failed = true; - } elseif ($this->ticketFactory->isExpired($serviceTicket)) { + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + + // Check if the ticket + // - has expired + // - does not pass sanitization + // - forceAutnn criteria are not met + if ($this->ticketFactory->isExpired($serviceTicket)) { // the ticket has expired $message = 'Ticket ' . var_export($ticket, true) . ' has expired'; $failed = true; diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 3cebcc18..046cfdb5 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -38,6 +38,7 @@ class Cas20ControllerTest extends TestCase private Utils\HTTP|MockObject $utilsHttpMock; private array $ticket; + private array $proxyTicket; /** * @throws \Exception @@ -89,6 +90,25 @@ protected function setUp(): void ], 'sessionId' => $this->sessionId, ]; + + $this->proxyTicket = [ + 'id' => 'PT-' . $this->sessionId, + 'validBefore' => 1731111111, + 'service' => 'https://myservice.com/abcd', + 'forceAuthn' => false, + 'userName' => 'username@google.com', + 'attributes' => + [ + 'eduPersonPrincipalName' => + [ + 0 => 'eduPersonPrincipalName@google.com', + ], + ], + 'proxies' => + [ + ], + 'sessionId' => $this->sessionId, + ]; } public function testProxyValidatePassesTheCorrectMethodToValidate(): void @@ -333,11 +353,11 @@ public static function validateFailsForEmptyServiceTicket(): array ], 'Ticket is empty but Service is not' => [ ['service' => 'http://localhost'], - 'casserver: Missing service parameter: [ticket]', + 'casserver: Missing ticket parameter: [ticket]', ], 'Ticket is empty but TARGET is not' => [ ['TARGET' => 'http://localhost'], - 'casserver: Missing service parameter: [ticket]', + 'casserver: Missing ticket parameter: [ticket]', ], ]; } @@ -407,7 +427,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array { $sessionId = session_create_id(); return [ - 'Returns Bad Request on Ticket Not Recogniged/Exists' => [ + 'Returns Bad Request on Ticket Not Recognised/Exists' => [ [ 'ticket' => $sessionId, 'service' => 'https://myservice.com/abcd', @@ -420,7 +440,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array 'ticket' => 'PT-' . $sessionId, 'service' => 'https://myservice.com/abcd', ], - "Ticket 'PT-{$sessionId}' is a proxy ticket. Use proxyValidate instead.", + "Ticket 'PT-{$sessionId}' is not a service ticket.", 'PT-' . $sessionId, ], 'Returns Bad Request on Ticket Expired' => [ @@ -445,7 +465,7 @@ public static function validateOnDifferentQueryParameterCombinations(): array 'service' => 'https://myservice.com/abcd', 'renew' => true, ], - "Ticket was issued from single sign on session", + 'Ticket was issued from single sign on session', 'ST-' . $sessionId, 9999999999, ], @@ -510,6 +530,106 @@ public function testServiceValidate( } } + + public static function validateOnDifferentQueryParameterCombinationsProxyValidate(): array + { + $sessionId = session_create_id(); + return [ + 'Returns Bad Request on Ticket Not Recognised/Exists' => [ + [ + 'ticket' => $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket '{$sessionId}' not recognized", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket Expired' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'PT-{$sessionId}' has expired", + 'PT-' . $sessionId, + ], + 'Returns Bad Request on Ticket is A Service Ticket' => [ + [ + 'ticket' => 'ST-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + "Ticket 'ST-{$sessionId}' is not a proxy ticket.", + 'ST-' . $sessionId, + ], + 'Returns Bad Request on Ticket Issued By Single SignOn Session' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + 'renew' => true, + ], + 'Ticket was issued from single sign on session', + 'PT-' . $sessionId, + 9999999999, + ], + 'Returns Success' => [ + [ + 'ticket' => 'PT-' . $sessionId, + 'service' => 'https://myservice.com/abcd', + ], + 'username@google.com', + 'PT-' . $sessionId, + 9999999999, + ], + ]; + } + + #[DataProvider('validateOnDifferentQueryParameterCombinationsProxyValidate')] + public function testProxyValidate( + array $requestParams, + string $message, + string $ticketId, + int $validBefore = 1111111111, + ): void { + $config = Configuration::loadFromArray($this->moduleConfig); + + $request = Request::create( + uri: 'http://localhost', + parameters: $requestParams, + ); + + $cas20Controller = new Cas20Controller($this->sspConfig, $config); + + if (!empty($ticketId)) { + $ticketStore = $cas20Controller->getTicketStore(); + $ticket = $this->proxyTicket; + $ticket['id'] = $ticketId; + $ticket['validBefore'] = $validBefore; + $ticketStore->addTicket($ticket); + } + + $response = $cas20Controller->proxyValidate($request, ...$requestParams); + + if ($message === 'username@google.com') { + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + 'username@google.com', + $xml->xpath('//cas:authenticationSuccess/cas:user')[0][0], + ); + } else { + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertStringContainsString($message, $response->getContent()); + $xml = simplexml_load_string($response->getContent()); + $xml->registerXPathNamespace('cas', 'serviceResponse'); + $this->assertEquals('serviceResponse', $xml->getName()); + $this->assertEquals( + C::ERR_INVALID_SERVICE, + $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], + ); + } + } + public function testReturnBadRequestOnTicketServiceQueryAndTicketMismatch(): void { $config = Configuration::loadFromArray($this->moduleConfig); From 7a8e7bb7951de1513528ba0cc12381ddb24b56ef Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 12:04:54 +0200 Subject: [PATCH 36/57] fix UrlTrait::validate call test for the different methods. --- tests/src/Controller/Cas20ControllerTest.php | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index 046cfdb5..f7ff434e 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -111,13 +111,28 @@ protected function setUp(): void ]; } - public function testProxyValidatePassesTheCorrectMethodToValidate(): void + public static function validateMethods(): array + { + return [ + 'Call serviceValidate action' => [ + 'ST-', + 'serviceValidate', + ], + 'Call proxyValidate action' => [ + 'PT-', + 'proxyValidate', + ], + ]; + } + + #[DataProvider('validateMethods')] + public function testProxyValidatePassesTheCorrectMethodToValidate(string $prefix, string $method): void { $casconfig = Configuration::loadFromArray($this->moduleConfig); $requestParameters = [ 'renew' => false, 'service' => 'https://myservice.com/abcd', - 'ticket' => 'ST-' . $this->sessionId, + 'ticket' => $prefix . $this->sessionId, ]; $request = Request::create( @@ -127,10 +142,10 @@ public function testProxyValidatePassesTheCorrectMethodToValidate(): void $expectedArguments = [ 'request' => $request, - 'method' => 'proxyValidate', + 'method' => $method, 'renew' => false, 'target' => null, - 'ticket' => 'ST-' . $this->sessionId, + 'ticket' => $prefix . $this->sessionId, 'service' => 'https://myservice.com/abcd', 'pgtUrl' => null, ]; @@ -142,7 +157,7 @@ public function testProxyValidatePassesTheCorrectMethodToValidate(): void $controllerMock->expects($this->once())->method('validate') ->with(...$expectedArguments); - $controllerMock->proxyValidate($request, ...$requestParameters); + $controllerMock->$method($request, ...$requestParameters); } public static function queryParameterValues(): array From fb6664862ff43cc6cd0818b5aa5629e026c3da5b Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 14:07:43 +0200 Subject: [PATCH 37/57] user RunnableResponse --- config/module_casserver.php.dist | 3 +- src/Controller/Cas20Controller.php | 4 +- src/Controller/LoginController.php | 43 +++++++++---------- src/Controller/LogoutController.php | 12 +++--- tests/src/Controller/LoginControllerTest.php | 32 +++++++------- tests/src/Controller/LogoutControllerTest.php | 15 +++++-- 6 files changed, 60 insertions(+), 49 deletions(-) diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 9cfb61cd..4dcc3d7a 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,10 +26,11 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // Some configuration options can be overridden + // The FOLLOWING configuration options can be overridden 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], + //'authproc' => [] ], ], diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index 3946080b..b6564d81 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -76,7 +76,7 @@ public function __construct( /** * @param Request $request - * @param string|null $TARGET + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed * if the service ticket was issued from the presentation of the user’s primary * credentials. It will fail if the ticket was issued from a single sign-on session. @@ -195,7 +195,7 @@ public function proxy( /** * @param Request $request - * @param string|null $TARGET + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' * @param bool $renew [OPTIONAL] - if this parameter is set, ticket validation will only succeed * if the service ticket was issued from the presentation of the user’s primary * credentials. It will fail if the ticket was issued from a single sign-on session. diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 58d0ac9a..0bd1eccc 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -7,6 +7,7 @@ use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Logger; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Cas\AttributeExtractor; @@ -20,7 +21,6 @@ use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; use SimpleSAML\Utils; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -103,16 +103,14 @@ public function __construct( * @param bool $renew * @param bool $gateway * @param string|null $service - * @param string|null $TARGET + * @param string|null $TARGET Query parameter name for "service" used by older CAS clients' * @param string|null $scope * @param string|null $language * @param string|null $entityId * @param string|null $debugMode * @param string|null $method * - * @return RedirectResponse|XmlResponse|null - * @throws NoState - * @throws \Exception + * @return RunnableResponse */ public function login( Request $request, @@ -125,13 +123,15 @@ public function login( #[MapQueryParameter] ?string $entityId = null, #[MapQueryParameter] ?string $debugMode = null, #[MapQueryParameter] ?string $method = null, - ): RedirectResponse|XmlResponse|null { + ): RunnableResponse { $forceAuthn = $renew; $serviceUrl = $service ?? $TARGET ?? null; $redirect = !(isset($method) && $method === 'POST'); // Set initial configurations, or fail $this->handleServiceConfiguration($serviceUrl); + // Instantiate the classes that rely on the override configuration. + // We do not do this in the constructor since we do not have the correct values yet. $this->instantiateClassDependencies(); $this->handleScope($scope); $this->handleLanguage($language); @@ -177,9 +177,10 @@ public function login( /* * REDIRECT TO AUTHSOURCE LOGIN * */ - $this->authSource->login($params); - // We should never get here.This is to facilitate testing. - return null; + return new RunnableResponse( + [$this->authSource, 'login'], + [$params], + ); } // We are Authenticated. @@ -195,13 +196,11 @@ public function login( * We are done. REDIRECT TO LOGGEDIN * */ if (!isset($serviceUrl) && $this->authProcId === null) { - $urlParameters = $this->httpUtils->addURLParameters( - Module::getModuleURL('casserver/loggedIn'), - $this->postAuthUrlParameters, + $loggedInUrl = Module::getModuleURL('casserver/loggedIn'); + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$loggedInUrl, $this->postAuthUrlParameters], ); - $this->httpUtils->redirectTrustedURL($urlParameters); - // We should never get here.This is to facilitate testing. - return null; } // Get the state. @@ -232,16 +231,16 @@ public function login( // GET if ($redirect) { - $this->httpUtils->redirectTrustedURL( - $this->httpUtils->addURLParameters($serviceUrl, $this->postAuthUrlParameters), + return new RunnableResponse( + [$this->httpUtils, 'redirectTrustedURL'], + [$serviceUrl, $this->postAuthUrlParameters], ); - // We should never get here.This is to facilitate testing. - return null; } // POST - $this->httpUtils->submitPOSTData($serviceUrl, $this->postAuthUrlParameters); - // We should never get here.This is to facilitate testing. - return null; + return new RunnableResponse( + [$this->httpUtils, 'submitPOSTData'], + [$serviceUrl, $this->postAuthUrlParameters], + ); } /** diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 1ee868dc..c0922a2d 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -81,12 +81,12 @@ public function __construct( * @param Request $request * @param string|null $url * - * @return RunnableResponse|null + * @return RunnableResponse */ public function logout( Request $request, #[MapQueryParameter] ?string $url = null, - ): RunnableResponse|null { + ): RunnableResponse { if (!$this->casConfig->getOptionalValue('enable_logout', false)) { $this->handleExceptionThrown('Logout not allowed'); } @@ -120,10 +120,10 @@ public function logout( } // Logout and redirect - $this->authSource->logout($logoutRedirectUrl); - - // We should never get here - return null; + return new RunnableResponse( + [$this->authSource, 'logout'], + [$logoutRedirectUrl], + ); } /** diff --git a/tests/src/Controller/LoginControllerTest.php b/tests/src/Controller/LoginControllerTest.php index 5216815f..96a36e78 100644 --- a/tests/src/Controller/LoginControllerTest.php +++ b/tests/src/Controller/LoginControllerTest.php @@ -10,6 +10,7 @@ use SimpleSAML\Auth\Simple; use SimpleSAML\Compat\SspContainer; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LoginController; use SimpleSAML\Session; @@ -209,11 +210,13 @@ public function testAuthSourceLogin(array $requestParameters, array $loginParame ->getMock(); $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(false); - $this->authSimpleMock->expects($this->once())->method('login')->with($loginParameters); $sessionId = session_create_id(); $this->sessionMock->expects($this->once())->method('getSessionId')->willReturn($sessionId); - $controllerMock->login($loginRequest, ...$requestParameters); + $response = $controllerMock->login($loginRequest, ...$requestParameters); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('login', $callable[1] ?? ''); } /** @@ -233,14 +236,16 @@ public function testIsAuthenticatedRedirectsToLoggedIn(): void $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); $this->authSimpleMock->expects($this->once())->method('getAuthData')->with('Expire')->willReturn(9999999999); - $this->httpUtils->expects($this->once())->method('redirectTrustedURL') - ->with('http://localhost/module.php/casserver/loggedIn?'); + $loginRequest = Request::create( uri: Module::getModuleURL('casserver/login'), parameters: [], ); - $controllerMock->login($loginRequest); + $response = $controllerMock->login($loginRequest); + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); } public static function validServiceUrlProvider(): array @@ -300,15 +305,6 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b $controllerMock->expects($this->once())->method('getSession')->willReturn($this->sessionMock); $this->authSimpleMock->expects($this->any())->method('isAuthenticated')->willReturn(true); - $this->httpUtils->expects($this->once())->method('redirectTrustedURL') - ->withAnyParameters() - ->willReturnCallback(function ($url) use ($redirectURL) { - $this->assertStringStartsWith( - $redirectURL, - $url, - 'Ticket should be part of the redirect.', - ); - }); $queryParameters = [$serviceParam => 'https://example.com/ssp/module.php/cas/linkback.php']; $loginRequest = Request::create( uri: Module::getModuleURL('casserver/login'), @@ -316,6 +312,12 @@ public function testValidServiceUrl(string $serviceParam, string $redirectURL, b ); /** @psalm-suppress InvalidArgument */ - $controllerMock->login($loginRequest, ...$queryParameters); + $response = $controllerMock->login($loginRequest, ...$queryParameters); + $this->assertInstanceOf(RunnableResponse::class, $response); + $arguments = $response->getArguments(); + $this->assertEquals('https://example.com/ssp/module.php/cas/linkback.php', $arguments[0]); + $this->assertStringStartsWith('ST-', array_values($arguments[1])[0] ?? []); + $callable = (array)$response->getCallable(); + $this->assertEquals('redirectTrustedURL', $callable[1] ?? ''); } } diff --git a/tests/src/Controller/LogoutControllerTest.php b/tests/src/Controller/LogoutControllerTest.php index 6df16f65..60d6490b 100644 --- a/tests/src/Controller/LogoutControllerTest.php +++ b/tests/src/Controller/LogoutControllerTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Auth\Simple; use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Module; use SimpleSAML\Module\casserver\Controller\LogoutController; use SimpleSAML\Session; @@ -160,11 +161,19 @@ public function testLogoutNoRedirectUrlOnNoSkipLogoutAuthenticated(): void // Unauthenticated $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - $this->authSimpleMock->expects($this->once())->method('logout') - ->with('http://localhost/module.php/casserver/loggedOut'); $controller = new LogoutController($this->sspConfig, $config, $this->authSimpleMock, $this->httpUtils); - $controller->logout(Request::create('/')); + $queryParameters = ['url' => 'http://localhost/module.php/casserver/loggedOut']; + $logoutRequest = Request::create( + uri: Module::getModuleURL('casserver/loggedOut'), + parameters: $queryParameters, + ); + + $response = $controller->logout($logoutRequest, ...$queryParameters); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $callable = (array)$response->getCallable(); + $this->assertEquals('logout', $callable[1] ?? ''); } public function testTicketIdGetsDeletedOnLogout(): void From 7215414d0649ef491e1579dbc16e6910c5062ab4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 17:57:31 +0200 Subject: [PATCH 38/57] preg_match better error handling --- src/Cas/ServiceValidator.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index ed7c79e6..cb968caf 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -72,6 +72,9 @@ public function checkServiceURL(string $service): ?Configuration } $isValidService = true; break; + } catch (\RuntimeException $e) { + // do nothing + Logger::warning($e->getMessage()); } catch (\Exception $e) { // do nothing Logger::warning("Invalid CAS legal service url '$legalUrl'. Error " . preg_last_error()); From 4484886a83ff8e1f3c8f1d3a0d365ffd268b577d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 2 Jan 2025 21:39:40 +0200 Subject: [PATCH 39/57] Use DOMDocumentFactory instead of ext-xml to parse the SOAP message --- composer.json | 6 +++--- src/Controller/Cas30Controller.php | 23 ++++++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index db8476b8..9253f1d8 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "config": { "preferred-install": { "simplesamlphp/simplesamlphp": "source", - "*": "source" + "*": "dist" }, "allow-plugins": { "composer/package-versions-deprecated": true, @@ -36,11 +36,11 @@ "ext-SimpleXML": "*", "ext-pdo": "*", "ext-session": "*", - "ext-xml": "*", "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/simplesamlphp": "^2.2", + "simplesamlphp/saml2": "^4.6", + "simplesamlphp/simplesamlphp": "^2.3", "simplesamlphp/xml-cas": "^1.3", "simplesamlphp/xml-common": "^1.17", "simplesamlphp/xml-soap": "^1.5", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 874dc155..e0ba08eb 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -4,6 +4,8 @@ namespace SimpleSAML\Module\casserver\Controller; +use DOMXPath; +use SAML2\DOMDocumentFactory; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; @@ -89,28 +91,27 @@ public function samlValidate( // - IssueInstant [REQUIRED] - timestamp of the request // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service - $ticketParser = xml_parser_create(); - xml_parser_set_option($ticketParser, XML_OPTION_CASE_FOLDING, 0); - xml_parser_set_option($ticketParser, XML_OPTION_SKIP_WHITE, 1); - xml_parse_into_struct($ticketParser, $postBody, $values, $tags); - xml_parser_free($ticketParser); + $documentBody = DOMDocumentFactory::fromString($postBody); + $xPath = new DOMXpath($documentBody); + $xPath->registerNamespace('soap-env', 'http://schemas.xmlsoap.org/soap/envelope/'); + $samlRequestAttributes = $xPath->query('/soap-env:Envelope/soap-env:Body/*'); // Check for the required saml attributes - $samlRequestAttributes = $values[ $tags['samlp:Request'][0] ]['attributes']; - if (!isset($samlRequestAttributes['RequestID'])) { + if (!$samlRequestAttributes->item(0)->hasAttribute('RequestID')) { throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!isset($samlRequestAttributes['IssueInstant'])) { + } elseif (!$samlRequestAttributes->item(0)->hasAttribute('IssueInstant')) { throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); } + $assertionArtifactNode = $samlRequestAttributes->item(0)->getElementsByTagName('AssertionArtifact'); if ( - !isset($tags['samlp:AssertionArtifact']) - || empty($values[$tags['samlp:AssertionArtifact'][0]]['value']) + $assertionArtifactNode->count() === 0 + || empty($assertionArtifactNode->item(0)->nodeValue) ) { throw new \RuntimeException('Missing ticketId in AssertionArtifact'); } - $ticketId = $values[$tags['samlp:AssertionArtifact'][0]]['value']; + $ticketId = $assertionArtifactNode->item(0)->nodeValue; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { From 38f97d55faf85aedf1ece08e74cc73bfc7fd019c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 3 Jan 2025 14:56:48 +0200 Subject: [PATCH 40/57] Refactor samlValidate, parsing postbody. --- composer.json | 1 - src/Controller/Cas30Controller.php | 42 ++++++++++++++++-------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/composer.json b/composer.json index 9253f1d8..bb0f1fa9 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,6 @@ "simplesamlphp/assert": "^1.1", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/saml2": "^4.6", "simplesamlphp/simplesamlphp": "^2.3", "simplesamlphp/xml-cas": "^1.3", "simplesamlphp/xml-common": "^1.17", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index e0ba08eb..45750758 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -4,8 +4,6 @@ namespace SimpleSAML\Module\casserver\Controller; -use DOMXPath; -use SAML2\DOMDocumentFactory; use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; @@ -13,6 +11,8 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SOAP\XML\env_200106\Envelope; +use SimpleSAML\XML\DOMDocumentFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -92,26 +92,28 @@ public function samlValidate( // samlp:AssertionArtifact [REQUIRED] - the valid CAS Service $documentBody = DOMDocumentFactory::fromString($postBody); - $xPath = new DOMXpath($documentBody); - $xPath->registerNamespace('soap-env', 'http://schemas.xmlsoap.org/soap/envelope/'); - $samlRequestAttributes = $xPath->query('/soap-env:Envelope/soap-env:Body/*'); - - // Check for the required saml attributes - if (!$samlRequestAttributes->item(0)->hasAttribute('RequestID')) { - throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!$samlRequestAttributes->item(0)->hasAttribute('IssueInstant')) { - throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + $envelope = Envelope::fromXML($documentBody->documentElement); + foreach ($envelope->getBody()->getElements() as $element) { + $samlpRequestXMLElement = $element->getXML(); + // Check for the required saml attributes + if ($samlpRequestXMLElement->nodeName !== 'samlp:Request') { + throw new \RuntimeException('Missing samlp:Request node.'); + } elseif (!$samlpRequestXMLElement->hasAttribute('RequestID')) { + throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); + } elseif (!$samlpRequestXMLElement->hasAttribute('IssueInstant')) { + throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); + } + // Assertion Artifact Element + $assertionArtifactNode = $samlpRequestXMLElement->firstElementChild; + if ( + $assertionArtifactNode->nodeName !== 'samlp:AssertionArtifact' + || empty($assertionArtifactNode->nodeValue) + ) { + throw new \RuntimeException('Missing ticketId in AssertionArtifact'); + } } - $assertionArtifactNode = $samlRequestAttributes->item(0)->getElementsByTagName('AssertionArtifact'); - if ( - $assertionArtifactNode->count() === 0 - || empty($assertionArtifactNode->item(0)->nodeValue) - ) { - throw new \RuntimeException('Missing ticketId in AssertionArtifact'); - } - - $ticketId = $assertionArtifactNode->item(0)->nodeValue; + $ticketId = $assertionArtifactNode?->nodeValue ?? ''; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { From de620701eb46233b55ca4a43a178a2b74173f9c4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sat, 4 Jan 2025 18:34:17 +0200 Subject: [PATCH 41/57] Improve saml validate --- composer.json | 3 ++- src/Controller/Cas30Controller.php | 22 +++++++------------- tests/src/Controller/Cas30ControllerTest.php | 9 ++++---- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index bb0f1fa9..400bdac3 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "simplesamlphp/xml-common": "^1.17", "simplesamlphp/xml-soap": "^1.5", "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.4" + "symfony/http-kernel": "^6.4", + "simplesamlphp/saml11": "^1.2" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 45750758..9b4b1f52 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SAML11\XML\samlp\Request as SamlRequest; use SimpleSAML\SOAP\XML\env_200106\Envelope; use SimpleSAML\XML\DOMDocumentFactory; use Symfony\Component\HttpFoundation\Request; @@ -94,26 +95,17 @@ public function samlValidate( $documentBody = DOMDocumentFactory::fromString($postBody); $envelope = Envelope::fromXML($documentBody->documentElement); foreach ($envelope->getBody()->getElements() as $element) { - $samlpRequestXMLElement = $element->getXML(); - // Check for the required saml attributes - if ($samlpRequestXMLElement->nodeName !== 'samlp:Request') { - throw new \RuntimeException('Missing samlp:Request node.'); - } elseif (!$samlpRequestXMLElement->hasAttribute('RequestID')) { - throw new \RuntimeException('Missing RequestID samlp:Request attribute.'); - } elseif (!$samlpRequestXMLElement->hasAttribute('IssueInstant')) { - throw new \RuntimeException('Missing IssueInstant samlp:Request attribute.'); - } + // Request Element + $samlpRequestParsed = SamlRequest::fromXML($element->getXML()); + // Assertion Artifact Element - $assertionArtifactNode = $samlpRequestXMLElement->firstElementChild; - if ( - $assertionArtifactNode->nodeName !== 'samlp:AssertionArtifact' - || empty($assertionArtifactNode->nodeValue) - ) { + $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; + if (empty($assertionArtifactParsed->getContent())) { throw new \RuntimeException('Missing ticketId in AssertionArtifact'); } } - $ticketId = $assertionArtifactNode?->nodeValue ?? ''; + $ticketId = $assertionArtifactParsed?->getContent() ?? ''; Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index ba584a88..1d4f96ca 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Session; +use SimpleSAML\XML\Exception\MissingAttributeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -146,8 +147,8 @@ public function testSoapBodyMissingRequestIdAttribute(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing RequestID samlp:Request attribute.'); + $this->expectException(MissingAttributeException::class); + $this->expectExceptionMessage("Missing 'RequestID' attribute on samlp:Request."); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } @@ -189,8 +190,8 @@ public function testSoapBodyMissingIssueInstantAttribute(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing IssueInstant samlp:Request attribute.'); + $this->expectException(MissingAttributeException::class); + $this->expectExceptionMessage("Missing 'IssueInstant' attribute on samlp:Request."); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } From 3d4d2ef84d456720bff0dac7aad09ac8b732e6ac Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 5 Jan 2025 08:00:17 +0200 Subject: [PATCH 42/57] Improve samlValidate. Use saml11 1,2,4 --- src/Controller/Cas30Controller.php | 26 +++-- tests/src/Controller/Cas30ControllerTest.php | 109 ++++++------------- 2 files changed, 49 insertions(+), 86 deletions(-) diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index 9b4b1f52..b02ece76 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; +use SimpleSAML\SAML11\Exception\ProtocolViolationException; use SimpleSAML\SAML11\XML\samlp\Request as SamlRequest; use SimpleSAML\SOAP\XML\env_200106\Envelope; use SimpleSAML\XML\DOMDocumentFactory; @@ -71,7 +72,9 @@ public function __construct( * @param Request $request * @param string $TARGET URL encoded service identifier of the back-end service. * - * @throws \RuntimeException + * @throw SimpleSAML\SAML11\Exception\ProtocolViolationException + * @throw SimpleSAML\XML\Exception\MissingAttributeException + * @throw \RuntimeException * @return XmlResponse * @link https://apereo.github.io/cas/7.1.x/protocol/CAS-Protocol-Specification.html#42-samlvalidate-cas-30 */ @@ -94,18 +97,19 @@ public function samlValidate( $documentBody = DOMDocumentFactory::fromString($postBody); $envelope = Envelope::fromXML($documentBody->documentElement); - foreach ($envelope->getBody()->getElements() as $element) { - // Request Element - $samlpRequestParsed = SamlRequest::fromXML($element->getXML()); - - // Assertion Artifact Element - $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; - if (empty($assertionArtifactParsed->getContent())) { - throw new \RuntimeException('Missing ticketId in AssertionArtifact'); - } + + // The SOAP Envelope must have only one ticket + $elements = $envelope->getBody()->getElements(); + if (count($elements) > 1 || count($elements) < 1) { + throw new ProtocolViolationException('samlValidate expects a soap body with only one ticket.'); } - $ticketId = $assertionArtifactParsed?->getContent() ?? ''; + // Request Element + $samlpRequestParsed = SamlRequest::fromXML($elements[0]->getXML()); + // Assertion Artifact Element + $assertionArtifactParsed = $samlpRequestParsed->getRequest()[0]; + + $ticketId = $assertionArtifactParsed->getContent(); Logger::debug('samlvalidate: Checking ticket ' . $ticketId); try { diff --git a/tests/src/Controller/Cas30ControllerTest.php b/tests/src/Controller/Cas30ControllerTest.php index 1d4f96ca..d1518aaf 100644 --- a/tests/src/Controller/Cas30ControllerTest.php +++ b/tests/src/Controller/Cas30ControllerTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\casserver\Tests\Controller; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module; @@ -11,7 +12,6 @@ use SimpleSAML\Module\casserver\Cas\TicketValidator; use SimpleSAML\Module\casserver\Controller\Cas30Controller; use SimpleSAML\Session; -use SimpleSAML\XML\Exception\MissingAttributeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -110,14 +110,11 @@ public function testNoSoapBody(): void $cas30Controller->samlValidate($this->samlValidateRequest, $target); } - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingRequestIdAttribute(): void + public static function soapEnvelopes(): array { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -129,38 +126,12 @@ public function testSoapBodyMissingRequestIdAttribute(): void -SOAP; - - $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' - . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' - . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; - $this->samlValidateRequest = Request::create( - uri: Module::getModuleURL('casserver/samlValidate'), - method: 'POST', - parameters: ['TARGET' => $target], - content: $samlRequest, - ); - - $cas30Controller = new Cas30Controller( - $this->sspConfig, - $casconfig, - ); - - // Exception expected - $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage("Missing 'RequestID' attribute on samlp:Request."); - - $cas30Controller->samlValidate($this->samlValidateRequest, $target); - } - - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingIssueInstantAttribute(): void - { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -172,38 +143,12 @@ public function testSoapBodyMissingIssueInstantAttribute(): void -SOAP; - - $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' - . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' - . 'https%3A%2F%2Fcomanage-ioi-dev.workbench.incommon.org%2Fssp%2Fmodule.php%2Fadmin%2Ftest%2Fcasserver'; - $this->samlValidateRequest = Request::create( - uri: Module::getModuleURL('casserver/samlValidate'), - method: 'POST', - parameters: ['TARGET' => $target], - content: $samlRequest, - ); - - $cas30Controller = new Cas30Controller( - $this->sspConfig, - $casconfig, - ); - - // Exception expected - $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage("Missing 'IssueInstant' attribute on samlp:Request."); - - $cas30Controller->samlValidate($this->samlValidateRequest, $target); - } - - /** - * @return void - * @throws \Exception - */ - public function testSoapBodyMissingTicketId(): void - { - $casconfig = Configuration::loadFromArray($this->moduleConfig); - $samlRequest = << [ + << @@ -216,7 +161,21 @@ public function testSoapBodyMissingTicketId(): void -SOAP; +SOAP, + 'Expected a non-whitespace string. Got: ""', + 'SimpleSAML\SAML11\Exception\ProtocolViolationException', + ], + ]; + } + + #[DataProvider('soapEnvelopes')] + public function testSoapMessageIsInvalid( + string $soapMessage, + string $exceptionMessage, + string $exceptionClassName, + ): void { + $casconfig = Configuration::loadFromArray($this->moduleConfig); + $samlRequest = $soapMessage; $target = 'https://comanage-ioi-dev.workbench.incommon.org/ssp/module.php/cas/linkback.php?' . 'stateId=_bd6b7a3d207ed26ea893f49e555515b5f839547b59%3A' @@ -234,8 +193,8 @@ public function testSoapBodyMissingTicketId(): void ); // Exception expected - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Missing ticketId in AssertionArtifact'); + $this->expectException($exceptionClassName); + $this->expectExceptionMessage($exceptionMessage); $cas30Controller->samlValidate($this->samlValidateRequest, $target); } From b6591100b73293990474628dc09c91368a8d1054 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 14:40:38 +0200 Subject: [PATCH 43/57] Move Cas20 validate function to TicketValidatorTrait --- src/Controller/Cas20Controller.php | 2 + src/Controller/Cas30Controller.php | 1 - .../Traits/TicketValidatorTrait.php | 184 ++++++++++++++++++ src/Controller/Traits/UrlTrait.php | 174 ----------------- tests/src/Controller/Cas20ControllerTest.php | 2 +- 5 files changed, 187 insertions(+), 176 deletions(-) create mode 100644 src/Controller/Traits/TicketValidatorTrait.php diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index b6564d81..a0bbac4c 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory; use SimpleSAML\Module\casserver\Cas\Protocol\Cas20; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; +use SimpleSAML\Module\casserver\Controller\Traits\TicketValidatorTrait; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Utils; @@ -23,6 +24,7 @@ class Cas20Controller { use UrlTrait; + use TicketValidatorTrait; /** @var Logger */ protected Logger $logger; diff --git a/src/Controller/Cas30Controller.php b/src/Controller/Cas30Controller.php index b02ece76..6765e7a5 100644 --- a/src/Controller/Cas30Controller.php +++ b/src/Controller/Cas30Controller.php @@ -82,7 +82,6 @@ public function samlValidate( Request $request, #[MapQueryParameter] string $TARGET, ): XmlResponse { - // From SAML2\SOAP::receive() $postBody = $request->getContent(); if (empty($postBody)) { throw new \RuntimeException('samlValidate expects a soap body.'); diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php new file mode 100644 index 00000000..8bad39cc --- /dev/null +++ b/src/Controller/Traits/TicketValidatorTrait.php @@ -0,0 +1,184 @@ +cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + try { + // Get the service ticket + // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // un-serialization handlers. + $serviceTicket = $this->ticketStore->getTicket($ticket); + } catch (\Exception $e) { + $messagePostfix = ''; + if (!empty($e->getMessage())) { + $messagePostfix = ': ' . var_export($e->getMessage(), true); + } + $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; + Logger::error($message); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $message), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $failed = false; + $message = ''; + // Below, we do not have a ticket or the ticket does not meet the very basic criteria that allow + // any further handling + if (empty($serviceTicket)) { + // No ticket + $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; + $failed = true; + } elseif ($method === 'proxyValidate' && !$this->ticketFactory->isProxyTicket($serviceTicket)) { + // proxyValidate but not a proxy ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a proxy ticket.'; + $failed = true; + } elseif ($method === 'serviceValidate' && !$this->ticketFactory->isServiceTicket($serviceTicket)) { + // serviceValidate but not a service ticket + $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket.'; + $failed = true; + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + // Delete the ticket + $this->ticketStore->deleteTicket($ticket); + + // Check if the ticket + // - has expired + // - does not pass sanitization + // - forceAutnn criteria are not met + if ($this->ticketFactory->isExpired($serviceTicket)) { + // the ticket has expired + $message = 'Ticket ' . var_export($ticket, true) . ' has expired'; + $failed = true; + } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { + // The service url we passed to the query parameters does not match the one in the ticket. + $message = 'Mismatching service parameters: expected ' . + var_export($serviceTicket['service'], true) . + ' but was: ' . var_export($serviceUrl, true); + $failed = true; + } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { + // If `forceAuthn` is required but not set in the ticket + $message = 'Ticket was issued from single sign on session'; + $failed = true; + } + + if ($failed) { + $finalMessage = 'casserver:validate: ' . $message; + Logger::error($finalMessage); + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), + Response::HTTP_BAD_REQUEST, + ); + } + + $attributes = $serviceTicket['attributes']; + $this->cas20Protocol->setAttributes($attributes); + + if (isset($pgtUrl)) { + $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); + if ( + $sessionTicket !== null + && $this->ticketFactory->isSessionTicket($sessionTicket) + && !$this->ticketFactory->isExpired($sessionTicket) + ) { + $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( + [ + 'userName' => $serviceTicket['userName'], + 'attributes' => $attributes, + 'forceAuthn' => false, + 'proxies' => array_merge( + [$serviceUrl], + $serviceTicket['proxies'], + ), + 'sessionId' => $serviceTicket['sessionId'], + ], + ); + try { + // Here we assume that the fetch will throw on any error. + // The generation of the proxy-granting-ticket or the corresponding proxy granting ticket IOU may + // fail due to the proxy callback url failing to meet the minimum security requirements such as + // failure to establish trust between peers or unresponsiveness of the endpoint, etc. + // In case of failure, no proxy-granting ticket will be issued and the CAS service response + // as described in Section 2.5.2 MUST NOT contain a block. + // At this point, the issuance of a proxy-granting ticket is halted and service ticket + // validation will fail. + $data = $this->httpUtils->fetch( + $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], + ); + Logger::debug(__METHOD__ . '::data: ' . var_export($data, true)); + $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); + $this->ticketStore->addTicket($proxyGrantingTicket); + } catch (\Exception $e) { + return new XmlResponse( + (string)$this->cas20Protocol->getValidateFailureResponse( + C::ERR_INVALID_SERVICE, + 'Proxy callback url is failing.', + ), + Response::HTTP_BAD_REQUEST, + ); + } + } + } + + return new XmlResponse( + (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), + Response::HTTP_OK, + ); + } +} diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 761ab477..217d5d8b 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -4,14 +4,10 @@ namespace SimpleSAML\Module\casserver\Controller\Traits; -use SimpleSAML\CAS\Constants as C; use SimpleSAML\Configuration; -use SimpleSAML\Logger; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\TicketValidator; -use SimpleSAML\Module\casserver\Http\XmlResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; trait UrlTrait { @@ -78,174 +74,4 @@ public function getRequestParam(Request $request, string $paramName): mixed { return $request->query->get($paramName) ?? $request->request->get($paramName) ?? null; } - - /** - * @param Request $request - * @param string $method - * @param bool $renew - * @param string|null $target - * @param string|null $ticket - * @param string|null $service - * @param string|null $pgtUrl - * - * @return XmlResponse - */ - public function validate( - Request $request, - string $method, - bool $renew = false, - ?string $target = null, - ?string $ticket = null, - ?string $service = null, - ?string $pgtUrl = null, - ): XmlResponse { - $forceAuthn = $renew; - $serviceUrl = $service ?? $target ?? null; - - // Check if any of the required query parameters are missing - if ($serviceUrl === null || $ticket === null) { - $messagePostfix = $serviceUrl === null ? 'service' : 'ticket'; - $message = "casserver: Missing {$messagePostfix} parameter: [{$messagePostfix}]"; - Logger::debug($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_BAD_REQUEST, - ); - } - - try { - // Get the service ticket - // `getTicket` uses the unserializable method and Objects may throw Throwables in their - // un-serialization handlers. - $serviceTicket = $this->ticketStore->getTicket($ticket); - } catch (\Exception $e) { - $messagePostfix = ''; - if (!empty($e->getMessage())) { - $messagePostfix = ': ' . var_export($e->getMessage(), true); - } - $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; - Logger::error($message); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_INTERNAL_SERVER_ERROR, - ); - } - - $failed = false; - $message = ''; - // Below, we do not have a ticket or the ticket does not meet the very basic criteria that allow - // any further handling - if (empty($serviceTicket)) { - // No ticket - $message = 'Ticket ' . var_export($ticket, true) . ' not recognized'; - $failed = true; - } elseif ($method === 'proxyValidate' && !$this->ticketFactory->isProxyTicket($serviceTicket)) { - // proxyValidate but not a proxy ticket - $message = 'Ticket ' . var_export($ticket, true) . ' is not a proxy ticket.'; - $failed = true; - } elseif ($method === 'serviceValidate' && !$this->ticketFactory->isServiceTicket($serviceTicket)) { - // serviceValidate but not a service ticket - $message = 'Ticket ' . var_export($ticket, true) . ' is not a service ticket.'; - $failed = true; - } - - if ($failed) { - $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_BAD_REQUEST, - ); - } - - // Delete the ticket - $this->ticketStore->deleteTicket($ticket); - - // Check if the ticket - // - has expired - // - does not pass sanitization - // - forceAutnn criteria are not met - if ($this->ticketFactory->isExpired($serviceTicket)) { - // the ticket has expired - $message = 'Ticket ' . var_export($ticket, true) . ' has expired'; - $failed = true; - } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($serviceUrl)) { - // The service url we passed to the query parameters does not match the one in the ticket. - $message = 'Mismatching service parameters: expected ' . - var_export($serviceTicket['service'], true) . - ' but was: ' . var_export($serviceUrl, true); - $failed = true; - } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) { - // If `forceAuthn` is required but not set in the ticket - $message = 'Ticket was issued from single sign on session'; - $failed = true; - } - - if ($failed) { - $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), - Response::HTTP_BAD_REQUEST, - ); - } - - $attributes = $serviceTicket['attributes']; - $this->cas20Protocol->setAttributes($attributes); - - if (isset($pgtUrl)) { - $sessionTicket = $this->ticketStore->getTicket($serviceTicket['sessionId']); - if ( - $sessionTicket !== null - && $this->ticketFactory->isSessionTicket($sessionTicket) - && !$this->ticketFactory->isExpired($sessionTicket) - ) { - $proxyGrantingTicket = $this->ticketFactory->createProxyGrantingTicket( - [ - 'userName' => $serviceTicket['userName'], - 'attributes' => $attributes, - 'forceAuthn' => false, - 'proxies' => array_merge( - [$serviceUrl], - $serviceTicket['proxies'], - ), - 'sessionId' => $serviceTicket['sessionId'], - ], - ); - try { - // Here we assume that the fetch will throw on any error. - // The generation of the proxy-granting-ticket or the corresponding proxy granting ticket IOU may - // fail due to the proxy callback url failing to meet the minimum security requirements such as - // failure to establish trust between peers or unresponsiveness of the endpoint, etc. - // In case of failure, no proxy-granting ticket will be issued and the CAS service response - // as described in Section 2.5.2 MUST NOT contain a block. - // At this point, the issuance of a proxy-granting ticket is halted and service ticket - // validation will fail. - $data = $this->httpUtils->fetch( - $pgtUrl . '?pgtIou=' . $proxyGrantingTicket['iou'] . '&pgtId=' . $proxyGrantingTicket['id'], - ); - Logger::debug(__METHOD__ . '::data: ' . var_export($data, true)); - $this->cas20Protocol->setProxyGrantingTicketIOU($proxyGrantingTicket['iou']); - $this->ticketStore->addTicket($proxyGrantingTicket); - } catch (\Exception $e) { - return new XmlResponse( - (string)$this->cas20Protocol->getValidateFailureResponse( - C::ERR_INVALID_SERVICE, - 'Proxy callback url is failing.', - ), - Response::HTTP_BAD_REQUEST, - ); - } - } - } - - return new XmlResponse( - (string)$this->cas20Protocol->getValidateSuccessResponse($serviceTicket['userName']), - Response::HTTP_OK, - ); - } } diff --git a/tests/src/Controller/Cas20ControllerTest.php b/tests/src/Controller/Cas20ControllerTest.php index f7ff434e..679c275c 100644 --- a/tests/src/Controller/Cas20ControllerTest.php +++ b/tests/src/Controller/Cas20ControllerTest.php @@ -433,7 +433,7 @@ public function getTicket(string $ticketId): ?array $xml->registerXPathNamespace('cas', 'serviceResponse'); $this->assertEquals('serviceResponse', $xml->getName()); $this->assertEquals( - C::ERR_INVALID_SERVICE, + C::ERR_INTERNAL_ERROR, $xml->xpath('//cas:authenticationFailure')[0]->attributes()['code'], ); } From d1478182b31564a3a05ba3716d127762785e180d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 15:23:42 +0200 Subject: [PATCH 44/57] Create an enum list of configuration options that are allowed to be overriden. --- src/Cas/ServiceValidator.php | 11 ++++++++++- src/Controller/LoginController.php | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Cas/ServiceValidator.php b/src/Cas/ServiceValidator.php index cb968caf..d3cb6732 100644 --- a/src/Cas/ServiceValidator.php +++ b/src/Cas/ServiceValidator.php @@ -6,6 +6,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Logger; +use SimpleSAML\Module\casserver\Codebooks\OverrideConfigPropertiesEnum; /** * Validates if a CAS service can use server @@ -93,7 +94,15 @@ public function checkServiceURL(string $service): ?Configuration 'matchingUrl' => $legalUrl, 'serviceUrl' => $service, ]; - if ($configOverride) { + if ($configOverride !== null) { + // We need to remove all the unsupported configuration keys + $supportedProperties = array_column(OverrideConfigPropertiesEnum::cases(), 'value'); + $configOverride = array_filter( + $configOverride, + static fn($property) => \in_array($property, $supportedProperties, true), + ARRAY_FILTER_USE_KEY, + ); + // Merge the configurations $serviceConfig = array_merge($serviceConfig, $configOverride); } return Configuration::loadFromArray($serviceConfig); diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 0bd1eccc..d1a0ab34 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -91,7 +91,7 @@ public function __construct( ? Configuration::getConfig('module_casserver.php') : $casConfig; // Saml Validate Responsder $this->samlValidateResponder = new SamlValidateResponder(); - // Service Validator needs the generic casserver configuration. We do not need + // Service Validator needs the generic casserver configuration. $this->serviceValidator = new ServiceValidator($this->casConfig); $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource')); $this->httpUtils = $httpUtils ?? new Utils\HTTP(); From 1a7e2dcfe6db08fc1e8c57124022839f5abf20f2 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 15:47:57 +0200 Subject: [PATCH 45/57] Add missing enum file --- composer.json | 2 +- config/module_casserver.php.dist | 5 +++-- src/Codebooks/OverrideConfigPropertiesEnum.php | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 src/Codebooks/OverrideConfigPropertiesEnum.php diff --git a/composer.json b/composer.json index 400bdac3..5e677e42 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ "validate": [ "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", - "vendor/bin/composer-require-checker check composer.json", + "vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json", "vendor/bin/psalm -c psalm-dev.xml", "vendor/bin/composer-unused" ], diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 4dcc3d7a..6c9fd075 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,11 +26,12 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // The FOLLOWING configuration options can be overridden + // ONLY the FOLLOWING configuration options can be overridden 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], - //'authproc' => [] + //'authproc' => [], + //'service_ticket_expire_time' => 5, ], ], diff --git a/src/Codebooks/OverrideConfigPropertiesEnum.php b/src/Codebooks/OverrideConfigPropertiesEnum.php new file mode 100644 index 00000000..f0868119 --- /dev/null +++ b/src/Codebooks/OverrideConfigPropertiesEnum.php @@ -0,0 +1,13 @@ + Date: Thu, 9 Jan 2025 16:10:11 +0200 Subject: [PATCH 46/57] fix saml11 versioning in composer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e677e42..132e7dde 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "simplesamlphp/xml-soap": "^1.5", "symfony/http-foundation": "^6.4", "symfony/http-kernel": "^6.4", - "simplesamlphp/saml11": "^1.2" + "simplesamlphp/saml11": "~1.2.4" }, "require-dev": { "simplesamlphp/simplesamlphp-test-framework": "^1.7", From 8fb020d57a3c06f75ccde3b08e4e8d26ead8f7c7 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jan 2025 16:25:55 +0200 Subject: [PATCH 47/57] minor fixes --- src/Controller/Cas20Controller.php | 8 +++++--- src/Controller/Traits/TicketValidatorTrait.php | 8 ++++---- src/Controller/Traits/UrlTrait.php | 9 +++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Controller/Cas20Controller.php b/src/Controller/Cas20Controller.php index a0bbac4c..6b875ed5 100644 --- a/src/Controller/Cas20Controller.php +++ b/src/Controller/Cas20Controller.php @@ -112,17 +112,19 @@ public function serviceValidate( * acquired proxy-granting tickets and will be proxying authentication to back-end services. * * @param Request $request - * @param string|null $targetService [REQUIRED] - the service identifier of the back-end service. - * @param string|null $pgt [REQUIRED] - the proxy-granting ticket acquired by the service - * during service ticket or proxy ticket validation. + * @param string|null $targetService [REQUIRED] - the service identifier of the back-end service. + * @param string|null $pgt [REQUIRED] - the proxy-granting ticket acquired by the service + * during service ticket or proxy ticket validation. * * @return XmlResponse + * @throws \ErrorException */ public function proxy( Request $request, #[MapQueryParameter] ?string $targetService = null, #[MapQueryParameter] ?string $pgt = null, ): XmlResponse { + // NOTE: Here we do not override the configuration $legal_target_service_urls = $this->casConfig->getOptionalValue('legal_target_service_urls', []); // Fail if $message = match (true) { diff --git a/src/Controller/Traits/TicketValidatorTrait.php b/src/Controller/Traits/TicketValidatorTrait.php index 8bad39cc..f4d7709c 100644 --- a/src/Controller/Traits/TicketValidatorTrait.php +++ b/src/Controller/Traits/TicketValidatorTrait.php @@ -49,7 +49,7 @@ public function validate( try { // Get the service ticket - // `getTicket` uses the unserializable method and Objects may throw Throwables in their + // `getTicket` uses the unserializable method and Objects may throw "Throwables" in their // un-serialization handlers. $serviceTicket = $this->ticketStore->getTicket($ticket); } catch (\Exception $e) { @@ -58,7 +58,7 @@ public function validate( $messagePostfix = ': ' . var_export($e->getMessage(), true); } $message = 'casserver:serviceValidate: internal server error' . $messagePostfix; - Logger::error($message); + Logger::error(__METHOD__ . '::' . $message); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INTERNAL_ERROR, $message), @@ -86,7 +86,7 @@ public function validate( if ($failed) { $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); + Logger::error(__METHOD__ . '::' . $finalMessage); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), @@ -119,7 +119,7 @@ public function validate( if ($failed) { $finalMessage = 'casserver:validate: ' . $message; - Logger::error($finalMessage); + Logger::error(__METHOD__ . '::' . $finalMessage); return new XmlResponse( (string)$this->cas20Protocol->getValidateFailureResponse(C::ERR_INVALID_SERVICE, $message), diff --git a/src/Controller/Traits/UrlTrait.php b/src/Controller/Traits/UrlTrait.php index 217d5d8b..a2192f1d 100644 --- a/src/Controller/Traits/UrlTrait.php +++ b/src/Controller/Traits/UrlTrait.php @@ -12,11 +12,12 @@ trait UrlTrait { /** - * @deprecated - * @see ServiceValidator - * @param string $service - * @param array $legal_service_urls + * @param string $service + * @param array $legal_service_urls + * * @return bool + * @throws \ErrorException + * @see ServiceValidator */ public function checkServiceURL(string $service, array $legal_service_urls): bool { From 416f2b1d69ac728d7c1b1cc3fe40a4553e7dd70a Mon Sep 17 00:00:00 2001 From: Tim van Dijen Date: Thu, 9 Jan 2025 18:28:57 +0100 Subject: [PATCH 48/57] Remove PDO as a hard dependency. It's optional when using SQLTicketStore --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 132e7dde..dbc25e0e 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,6 @@ "ext-filter": "*", "ext-libxml": "*", "ext-SimpleXML": "*", - "ext-pdo": "*", "ext-session": "*", "simplesamlphp/assert": "^1.1", From b67bc3f566fe07c652b008ff38ab3520d8968964 Mon Sep 17 00:00:00 2001 From: Patrick Radtke Date: Wed, 22 Jan 2025 07:41:36 -0800 Subject: [PATCH 49/57] Add more hints for running with docker --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2538915e..26096bac 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ To explore the module using docker run the below command. This will run an SSP i of the `casserver` module mounted in the container, along with some configuration files. Any code changes you make to your git checkout are "live" in the container, allowing you to test and iterate different things. +Sometimes when working with a dev version of the module you will need a newer version of a dependency than what SSP is +locked to. In that case you can add an additional dependency to the `COMPOSER_REQUIRE` line (e.g ="simplesamlphp/assert:1.8 ") + ```bash docker run --name ssp-casserver-dev \ --mount type=bind,source="$(pwd)",target=/var/simplesamlphp/staging-modules/casserver,readonly \ From 05ea8a834f4da129b5888d22e3bfabd2300e6f17 Mon Sep 17 00:00:00 2001 From: Patrick Radtke Date: Wed, 22 Jan 2025 07:45:01 -0800 Subject: [PATCH 50/57] Allow `attributes` config item to be set per SP --- config/module_casserver.php.dist | 3 ++- src/Codebooks/OverrideConfigPropertiesEnum.php | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/config/module_casserver.php.dist b/config/module_casserver.php.dist index 6c9fd075..7e690160 100644 --- a/config/module_casserver.php.dist +++ b/config/module_casserver.php.dist @@ -26,10 +26,11 @@ $config = [ 'https://host2.domain:5678/path2/path3', // So is regex '|^https://.*\.domain.com/|', - // ONLY the FOLLOWING configuration options can be overridden + // ONLY the FOLLOWING configuration options can be overridden. See OverrideConfigPropertiesEnum. 'https://override.example.com' => [ 'attrname' => 'uid', 'attributes_to_transfer' => ['cn'], + //'attributes' => false, //'authproc' => [], //'service_ticket_expire_time' => 5, ], diff --git a/src/Codebooks/OverrideConfigPropertiesEnum.php b/src/Codebooks/OverrideConfigPropertiesEnum.php index f0868119..30c7be5b 100644 --- a/src/Codebooks/OverrideConfigPropertiesEnum.php +++ b/src/Codebooks/OverrideConfigPropertiesEnum.php @@ -6,6 +6,7 @@ enum OverrideConfigPropertiesEnum: string { + case Attributes = 'attributes'; case Attrname = 'attrname'; case AttributesToTransfer = 'attributes_to_transfer'; case Authproc = 'authproc'; From ab8b39dd218072cc0578583de3c76a3ed4cdbdf4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 22 Jan 2025 18:20:53 +0200 Subject: [PATCH 51/57] Add missing trait dependency --- src/Controller/LoginController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index d1a0ab34..a8e3dc06 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -17,6 +17,7 @@ use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder; use SimpleSAML\Module\casserver\Cas\ServiceValidator; use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore; +use SimpleSAML\Module\casserver\Controller\Traits\TicketValidatorTrait; use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait; use SimpleSAML\Module\casserver\Http\XmlResponse; use SimpleSAML\Session; @@ -30,6 +31,7 @@ class LoginController { use UrlTrait; + use TicketValidatorTrait; /** @var Logger */ protected Logger $logger; From ef5605b5f9ac4cee929e40cfb8bd86ef930d9ae8 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 23 Jan 2025 11:48:36 +0200 Subject: [PATCH 52/57] Fix debug mode validation response rendering --- composer.json | 4 +- locales/en/LC_MESSAGES/casserver.po | 12 + phpcs.xml | 4 + public/.keep | 0 .../assets/bootstrap/bootstrap-5.3.3.min.css | 6 + .../bootstrap/bootstrap-bundle-5.3.3.min.js | 7 + public/assets/css/casserver.css | 11 + public/assets/highlight/DIGESTS.md | 37 + public/assets/highlight/LICENSE | 29 + public/assets/highlight/README.md | 45 + public/assets/highlight/es/core.js | 2600 +++++++++++ public/assets/highlight/es/core.min.js | 307 ++ public/assets/highlight/es/highlight.js | 2600 +++++++++++ public/assets/highlight/es/highlight.min.js | 307 ++ public/assets/highlight/es/languages/bash.js | 415 ++ .../assets/highlight/es/languages/bash.min.js | 21 + public/assets/highlight/es/languages/php.js | 621 +++ .../assets/highlight/es/languages/php.min.js | 58 + public/assets/highlight/es/languages/xml.js | 249 ++ .../assets/highlight/es/languages/xml.min.js | 29 + public/assets/highlight/es/package.json | 1 + public/assets/highlight/highlight.js | 3895 +++++++++++++++++ public/assets/highlight/highlight.min.js | 414 ++ public/assets/highlight/languages/bash.js | 417 ++ public/assets/highlight/languages/bash.min.js | 21 + public/assets/highlight/languages/php.js | 623 +++ public/assets/highlight/languages/php.min.js | 58 + public/assets/highlight/languages/xml.js | 251 ++ public/assets/highlight/languages/xml.min.js | 29 + public/assets/highlight/package.json | 93 + public/assets/highlight/styles/1c-light.css | 107 + .../assets/highlight/styles/1c-light.min.css | 9 + public/assets/highlight/styles/a11y-dark.css | 94 + .../assets/highlight/styles/a11y-dark.min.css | 7 + public/assets/highlight/styles/a11y-light.css | 94 + .../highlight/styles/a11y-light.min.css | 7 + public/assets/highlight/styles/agate.css | 127 + public/assets/highlight/styles/agate.min.css | 20 + .../assets/highlight/styles/an-old-hope.css | 75 + .../highlight/styles/an-old-hope.min.css | 9 + .../assets/highlight/styles/androidstudio.css | 60 + .../highlight/styles/androidstudio.min.css | 1 + .../assets/highlight/styles/arduino-light.css | 78 + .../highlight/styles/arduino-light.min.css | 1 + public/assets/highlight/styles/arta.css | 66 + public/assets/highlight/styles/arta.min.css | 1 + public/assets/highlight/styles/ascetic.css | 45 + .../assets/highlight/styles/ascetic.min.css | 1 + .../styles/atom-one-dark-reasonable.css | 105 + .../styles/atom-one-dark-reasonable.min.css | 1 + .../assets/highlight/styles/atom-one-dark.css | 90 + .../highlight/styles/atom-one-dark.min.css | 1 + .../highlight/styles/atom-one-light.css | 90 + .../highlight/styles/atom-one-light.min.css | 1 + .../assets/highlight/styles/base16/3024.css | 163 + .../highlight/styles/base16/3024.min.css | 7 + .../assets/highlight/styles/base16/apathy.css | 163 + .../highlight/styles/base16/apathy.min.css | 7 + .../highlight/styles/base16/apprentice.css | 163 + .../styles/base16/apprentice.min.css | 7 + .../assets/highlight/styles/base16/ashes.css | 163 + .../highlight/styles/base16/ashes.min.css | 7 + .../styles/base16/atelier-cave-light.css | 163 + .../styles/base16/atelier-cave-light.min.css | 7 + .../highlight/styles/base16/atelier-cave.css | 163 + .../styles/base16/atelier-cave.min.css | 7 + .../styles/base16/atelier-dune-light.css | 163 + .../styles/base16/atelier-dune-light.min.css | 7 + .../highlight/styles/base16/atelier-dune.css | 163 + .../styles/base16/atelier-dune.min.css | 7 + .../styles/base16/atelier-estuary-light.css | 163 + .../base16/atelier-estuary-light.min.css | 7 + .../styles/base16/atelier-estuary.css | 163 + .../styles/base16/atelier-estuary.min.css | 7 + .../styles/base16/atelier-forest-light.css | 163 + .../base16/atelier-forest-light.min.css | 7 + .../styles/base16/atelier-forest.css | 163 + .../styles/base16/atelier-forest.min.css | 7 + .../styles/base16/atelier-heath-light.css | 163 + .../styles/base16/atelier-heath-light.min.css | 7 + .../highlight/styles/base16/atelier-heath.css | 163 + .../styles/base16/atelier-heath.min.css | 7 + .../styles/base16/atelier-lakeside-light.css | 163 + .../base16/atelier-lakeside-light.min.css | 7 + .../styles/base16/atelier-lakeside.css | 163 + .../styles/base16/atelier-lakeside.min.css | 7 + .../styles/base16/atelier-plateau-light.css | 163 + .../base16/atelier-plateau-light.min.css | 7 + .../styles/base16/atelier-plateau.css | 163 + .../styles/base16/atelier-plateau.min.css | 7 + .../styles/base16/atelier-savanna-light.css | 163 + .../base16/atelier-savanna-light.min.css | 7 + .../styles/base16/atelier-savanna.css | 163 + .../styles/base16/atelier-savanna.min.css | 7 + .../styles/base16/atelier-seaside-light.css | 163 + .../base16/atelier-seaside-light.min.css | 7 + .../styles/base16/atelier-seaside.css | 163 + .../styles/base16/atelier-seaside.min.css | 7 + .../base16/atelier-sulphurpool-light.css | 163 + .../base16/atelier-sulphurpool-light.min.css | 7 + .../styles/base16/atelier-sulphurpool.css | 163 + .../styles/base16/atelier-sulphurpool.min.css | 7 + .../assets/highlight/styles/base16/atlas.css | 163 + .../highlight/styles/base16/atlas.min.css | 7 + .../assets/highlight/styles/base16/bespin.css | 163 + .../highlight/styles/base16/bespin.min.css | 7 + .../styles/base16/black-metal-bathory.css | 163 + .../styles/base16/black-metal-bathory.min.css | 7 + .../styles/base16/black-metal-burzum.css | 163 + .../styles/base16/black-metal-burzum.min.css | 7 + .../base16/black-metal-dark-funeral.css | 163 + .../base16/black-metal-dark-funeral.min.css | 7 + .../styles/base16/black-metal-gorgoroth.css | 163 + .../base16/black-metal-gorgoroth.min.css | 7 + .../styles/base16/black-metal-immortal.css | 163 + .../base16/black-metal-immortal.min.css | 7 + .../styles/base16/black-metal-khold.css | 163 + .../styles/base16/black-metal-khold.min.css | 7 + .../styles/base16/black-metal-marduk.css | 163 + .../styles/base16/black-metal-marduk.min.css | 7 + .../styles/base16/black-metal-mayhem.css | 163 + .../styles/base16/black-metal-mayhem.min.css | 7 + .../styles/base16/black-metal-nile.css | 163 + .../styles/base16/black-metal-nile.min.css | 7 + .../styles/base16/black-metal-venom.css | 163 + .../styles/base16/black-metal-venom.min.css | 7 + .../highlight/styles/base16/black-metal.css | 163 + .../styles/base16/black-metal.min.css | 7 + .../assets/highlight/styles/base16/brewer.css | 163 + .../highlight/styles/base16/brewer.min.css | 7 + .../assets/highlight/styles/base16/bright.css | 163 + .../highlight/styles/base16/bright.min.css | 7 + .../highlight/styles/base16/brogrammer.css | 163 + .../styles/base16/brogrammer.min.css | 7 + .../styles/base16/brush-trees-dark.css | 163 + .../styles/base16/brush-trees-dark.min.css | 7 + .../highlight/styles/base16/brush-trees.css | 163 + .../styles/base16/brush-trees.min.css | 7 + .../assets/highlight/styles/base16/chalk.css | 163 + .../highlight/styles/base16/chalk.min.css | 7 + .../assets/highlight/styles/base16/circus.css | 163 + .../highlight/styles/base16/circus.min.css | 7 + .../highlight/styles/base16/classic-dark.css | 163 + .../styles/base16/classic-dark.min.css | 7 + .../highlight/styles/base16/classic-light.css | 163 + .../styles/base16/classic-light.min.css | 7 + .../highlight/styles/base16/codeschool.css | 163 + .../styles/base16/codeschool.min.css | 7 + .../assets/highlight/styles/base16/colors.css | 163 + .../highlight/styles/base16/colors.min.css | 7 + .../highlight/styles/base16/cupcake.css | 163 + .../highlight/styles/base16/cupcake.min.css | 7 + .../highlight/styles/base16/cupertino.css | 163 + .../highlight/styles/base16/cupertino.min.css | 7 + .../highlight/styles/base16/danqing.css | 163 + .../highlight/styles/base16/danqing.min.css | 7 + .../highlight/styles/base16/darcula.css | 163 + .../highlight/styles/base16/darcula.min.css | 7 + .../highlight/styles/base16/dark-violet.css | 163 + .../styles/base16/dark-violet.min.css | 7 + .../highlight/styles/base16/darkmoss.css | 163 + .../highlight/styles/base16/darkmoss.min.css | 7 + .../highlight/styles/base16/darktooth.css | 163 + .../highlight/styles/base16/darktooth.min.css | 7 + .../assets/highlight/styles/base16/decaf.css | 163 + .../highlight/styles/base16/decaf.min.css | 7 + .../highlight/styles/base16/default-dark.css | 163 + .../styles/base16/default-dark.min.css | 7 + .../highlight/styles/base16/default-light.css | 163 + .../styles/base16/default-light.min.css | 7 + .../highlight/styles/base16/dirtysea.css | 163 + .../highlight/styles/base16/dirtysea.min.css | 7 + .../highlight/styles/base16/dracula.css | 163 + .../highlight/styles/base16/dracula.min.css | 7 + .../highlight/styles/base16/edge-dark.css | 163 + .../highlight/styles/base16/edge-dark.min.css | 7 + .../highlight/styles/base16/edge-light.css | 163 + .../styles/base16/edge-light.min.css | 7 + .../highlight/styles/base16/eighties.css | 163 + .../highlight/styles/base16/eighties.min.css | 7 + .../assets/highlight/styles/base16/embers.css | 163 + .../highlight/styles/base16/embers.min.css | 7 + .../styles/base16/equilibrium-dark.css | 163 + .../styles/base16/equilibrium-dark.min.css | 7 + .../styles/base16/equilibrium-gray-dark.css | 163 + .../base16/equilibrium-gray-dark.min.css | 7 + .../styles/base16/equilibrium-gray-light.css | 163 + .../base16/equilibrium-gray-light.min.css | 7 + .../styles/base16/equilibrium-light.css | 163 + .../styles/base16/equilibrium-light.min.css | 7 + .../highlight/styles/base16/espresso.css | 163 + .../highlight/styles/base16/espresso.min.css | 7 + .../highlight/styles/base16/eva-dim.css | 163 + .../highlight/styles/base16/eva-dim.min.css | 7 + public/assets/highlight/styles/base16/eva.css | 163 + .../highlight/styles/base16/eva.min.css | 7 + .../assets/highlight/styles/base16/flat.css | 163 + .../highlight/styles/base16/flat.min.css | 7 + .../assets/highlight/styles/base16/framer.css | 163 + .../highlight/styles/base16/framer.min.css | 7 + .../highlight/styles/base16/fruit-soda.css | 163 + .../styles/base16/fruit-soda.min.css | 7 + .../highlight/styles/base16/gigavolt.css | 163 + .../highlight/styles/base16/gigavolt.min.css | 7 + .../assets/highlight/styles/base16/github.css | 163 + .../highlight/styles/base16/github.min.css | 7 + .../highlight/styles/base16/google-dark.css | 163 + .../styles/base16/google-dark.min.css | 7 + .../highlight/styles/base16/google-light.css | 163 + .../styles/base16/google-light.min.css | 7 + .../styles/base16/grayscale-dark.css | 163 + .../styles/base16/grayscale-dark.min.css | 7 + .../styles/base16/grayscale-light.css | 163 + .../styles/base16/grayscale-light.min.css | 7 + .../highlight/styles/base16/green-screen.css | 163 + .../styles/base16/green-screen.min.css | 7 + .../styles/base16/gruvbox-dark-hard.css | 163 + .../styles/base16/gruvbox-dark-hard.min.css | 7 + .../styles/base16/gruvbox-dark-medium.css | 163 + .../styles/base16/gruvbox-dark-medium.min.css | 7 + .../styles/base16/gruvbox-dark-pale.css | 163 + .../styles/base16/gruvbox-dark-pale.min.css | 7 + .../styles/base16/gruvbox-dark-soft.css | 163 + .../styles/base16/gruvbox-dark-soft.min.css | 7 + .../styles/base16/gruvbox-light-hard.css | 163 + .../styles/base16/gruvbox-light-hard.min.css | 7 + .../styles/base16/gruvbox-light-medium.css | 163 + .../base16/gruvbox-light-medium.min.css | 7 + .../styles/base16/gruvbox-light-soft.css | 163 + .../styles/base16/gruvbox-light-soft.min.css | 7 + .../highlight/styles/base16/hardcore.css | 163 + .../highlight/styles/base16/hardcore.min.css | 7 + .../styles/base16/harmonic16-dark.css | 163 + .../styles/base16/harmonic16-dark.min.css | 7 + .../styles/base16/harmonic16-light.css | 163 + .../styles/base16/harmonic16-light.min.css | 7 + .../highlight/styles/base16/heetch-dark.css | 163 + .../styles/base16/heetch-dark.min.css | 7 + .../highlight/styles/base16/heetch-light.css | 163 + .../styles/base16/heetch-light.min.css | 7 + .../assets/highlight/styles/base16/helios.css | 163 + .../highlight/styles/base16/helios.min.css | 7 + .../highlight/styles/base16/hopscotch.css | 163 + .../highlight/styles/base16/hopscotch.min.css | 7 + .../highlight/styles/base16/horizon-dark.css | 163 + .../styles/base16/horizon-dark.min.css | 7 + .../highlight/styles/base16/horizon-light.css | 163 + .../styles/base16/horizon-light.min.css | 7 + .../highlight/styles/base16/humanoid-dark.css | 163 + .../styles/base16/humanoid-dark.min.css | 7 + .../styles/base16/humanoid-light.css | 163 + .../styles/base16/humanoid-light.min.css | 7 + .../highlight/styles/base16/ia-dark.css | 163 + .../highlight/styles/base16/ia-dark.min.css | 7 + .../highlight/styles/base16/ia-light.css | 163 + .../highlight/styles/base16/ia-light.min.css | 7 + .../highlight/styles/base16/icy-dark.css | 163 + .../highlight/styles/base16/icy-dark.min.css | 7 + .../highlight/styles/base16/ir-black.css | 163 + .../highlight/styles/base16/ir-black.min.css | 7 + .../highlight/styles/base16/isotope.css | 163 + .../highlight/styles/base16/isotope.min.css | 7 + .../assets/highlight/styles/base16/kimber.css | 163 + .../highlight/styles/base16/kimber.min.css | 7 + .../highlight/styles/base16/london-tube.css | 163 + .../styles/base16/london-tube.min.css | 7 + .../highlight/styles/base16/macintosh.css | 163 + .../highlight/styles/base16/macintosh.min.css | 7 + .../highlight/styles/base16/marrakesh.css | 163 + .../highlight/styles/base16/marrakesh.min.css | 7 + .../highlight/styles/base16/materia.css | 163 + .../highlight/styles/base16/materia.min.css | 7 + .../styles/base16/material-darker.css | 163 + .../styles/base16/material-darker.min.css | 7 + .../styles/base16/material-lighter.css | 163 + .../styles/base16/material-lighter.min.css | 7 + .../styles/base16/material-palenight.css | 163 + .../styles/base16/material-palenight.min.css | 7 + .../styles/base16/material-vivid.css | 163 + .../styles/base16/material-vivid.min.css | 7 + .../highlight/styles/base16/material.css | 163 + .../highlight/styles/base16/material.min.css | 7 + .../highlight/styles/base16/mellow-purple.css | 163 + .../styles/base16/mellow-purple.min.css | 7 + .../highlight/styles/base16/mexico-light.css | 163 + .../styles/base16/mexico-light.min.css | 7 + .../assets/highlight/styles/base16/mocha.css | 163 + .../highlight/styles/base16/mocha.min.css | 7 + .../highlight/styles/base16/monokai.css | 163 + .../highlight/styles/base16/monokai.min.css | 7 + .../assets/highlight/styles/base16/nebula.css | 163 + .../highlight/styles/base16/nebula.min.css | 7 + .../assets/highlight/styles/base16/nord.css | 163 + .../highlight/styles/base16/nord.min.css | 7 + .../assets/highlight/styles/base16/nova.css | 163 + .../highlight/styles/base16/nova.min.css | 7 + .../assets/highlight/styles/base16/ocean.css | 163 + .../highlight/styles/base16/ocean.min.css | 7 + .../highlight/styles/base16/oceanicnext.css | 163 + .../styles/base16/oceanicnext.min.css | 7 + .../highlight/styles/base16/one-light.css | 163 + .../highlight/styles/base16/one-light.min.css | 7 + .../highlight/styles/base16/onedark.css | 163 + .../highlight/styles/base16/onedark.min.css | 7 + .../highlight/styles/base16/outrun-dark.css | 163 + .../styles/base16/outrun-dark.min.css | 7 + .../styles/base16/papercolor-dark.css | 163 + .../styles/base16/papercolor-dark.min.css | 7 + .../styles/base16/papercolor-light.css | 163 + .../styles/base16/papercolor-light.min.css | 7 + .../highlight/styles/base16/paraiso.css | 163 + .../highlight/styles/base16/paraiso.min.css | 7 + .../assets/highlight/styles/base16/pasque.css | 163 + .../highlight/styles/base16/pasque.min.css | 7 + public/assets/highlight/styles/base16/phd.css | 163 + .../highlight/styles/base16/phd.min.css | 7 + .../assets/highlight/styles/base16/pico.css | 163 + .../highlight/styles/base16/pico.min.css | 7 + public/assets/highlight/styles/base16/pop.css | 163 + .../highlight/styles/base16/pop.min.css | 7 + .../assets/highlight/styles/base16/porple.css | 163 + .../highlight/styles/base16/porple.min.css | 7 + .../assets/highlight/styles/base16/qualia.css | 163 + .../highlight/styles/base16/qualia.min.css | 7 + .../highlight/styles/base16/railscasts.css | 163 + .../styles/base16/railscasts.min.css | 7 + .../highlight/styles/base16/rebecca.css | 163 + .../highlight/styles/base16/rebecca.min.css | 7 + .../highlight/styles/base16/ros-pine-dawn.css | 163 + .../styles/base16/ros-pine-dawn.min.css | 7 + .../highlight/styles/base16/ros-pine-moon.css | 163 + .../styles/base16/ros-pine-moon.min.css | 7 + .../highlight/styles/base16/ros-pine.css | 163 + .../highlight/styles/base16/ros-pine.min.css | 7 + .../highlight/styles/base16/sagelight.css | 163 + .../highlight/styles/base16/sagelight.min.css | 7 + .../highlight/styles/base16/sandcastle.css | 163 + .../styles/base16/sandcastle.min.css | 7 + .../highlight/styles/base16/seti-ui.css | 163 + .../highlight/styles/base16/seti-ui.min.css | 7 + .../highlight/styles/base16/shapeshifter.css | 163 + .../styles/base16/shapeshifter.min.css | 7 + .../highlight/styles/base16/silk-dark.css | 163 + .../highlight/styles/base16/silk-dark.min.css | 7 + .../highlight/styles/base16/silk-light.css | 163 + .../styles/base16/silk-light.min.css | 7 + .../assets/highlight/styles/base16/snazzy.css | 163 + .../highlight/styles/base16/snazzy.min.css | 7 + .../styles/base16/solar-flare-light.css | 163 + .../styles/base16/solar-flare-light.min.css | 7 + .../highlight/styles/base16/solar-flare.css | 163 + .../styles/base16/solar-flare.min.css | 7 + .../styles/base16/solarized-dark.css | 163 + .../styles/base16/solarized-dark.min.css | 7 + .../styles/base16/solarized-light.css | 163 + .../styles/base16/solarized-light.min.css | 7 + .../highlight/styles/base16/spacemacs.css | 163 + .../highlight/styles/base16/spacemacs.min.css | 7 + .../highlight/styles/base16/summercamp.css | 163 + .../styles/base16/summercamp.min.css | 7 + .../styles/base16/summerfruit-dark.css | 163 + .../styles/base16/summerfruit-dark.min.css | 7 + .../styles/base16/summerfruit-light.css | 163 + .../styles/base16/summerfruit-light.min.css | 7 + .../base16/synth-midnight-terminal-dark.css | 163 + .../synth-midnight-terminal-dark.min.css | 7 + .../base16/synth-midnight-terminal-light.css | 163 + .../synth-midnight-terminal-light.min.css | 7 + .../assets/highlight/styles/base16/tango.css | 163 + .../highlight/styles/base16/tango.min.css | 7 + .../assets/highlight/styles/base16/tender.css | 163 + .../highlight/styles/base16/tender.min.css | 7 + .../styles/base16/tomorrow-night.css | 163 + .../styles/base16/tomorrow-night.min.css | 7 + .../highlight/styles/base16/tomorrow.css | 163 + .../highlight/styles/base16/tomorrow.min.css | 7 + .../highlight/styles/base16/twilight.css | 163 + .../highlight/styles/base16/twilight.min.css | 7 + .../highlight/styles/base16/unikitty-dark.css | 163 + .../styles/base16/unikitty-dark.min.css | 7 + .../styles/base16/unikitty-light.css | 163 + .../styles/base16/unikitty-light.min.css | 7 + .../assets/highlight/styles/base16/vulcan.css | 163 + .../highlight/styles/base16/vulcan.min.css | 7 + .../styles/base16/windows-10-light.css | 163 + .../styles/base16/windows-10-light.min.css | 7 + .../highlight/styles/base16/windows-10.css | 163 + .../styles/base16/windows-10.min.css | 7 + .../styles/base16/windows-95-light.css | 163 + .../styles/base16/windows-95-light.min.css | 7 + .../highlight/styles/base16/windows-95.css | 163 + .../styles/base16/windows-95.min.css | 7 + .../base16/windows-high-contrast-light.css | 163 + .../windows-high-contrast-light.min.css | 7 + .../styles/base16/windows-high-contrast.css | 163 + .../base16/windows-high-contrast.min.css | 7 + .../styles/base16/windows-nt-light.css | 163 + .../styles/base16/windows-nt-light.min.css | 7 + .../highlight/styles/base16/windows-nt.css | 163 + .../styles/base16/windows-nt.min.css | 7 + .../highlight/styles/base16/woodland.css | 163 + .../highlight/styles/base16/woodland.min.css | 7 + .../highlight/styles/base16/xcode-dusk.css | 163 + .../styles/base16/xcode-dusk.min.css | 7 + .../highlight/styles/base16/zenburn.css | 163 + .../highlight/styles/base16/zenburn.min.css | 7 + .../assets/highlight/styles/brown-paper.css | 63 + .../highlight/styles/brown-paper.min.css | 1 + .../assets/highlight/styles/brown-papersq.png | Bin 0 -> 18198 bytes .../assets/highlight/styles/codepen-embed.css | 57 + .../highlight/styles/codepen-embed.min.css | 1 + .../assets/highlight/styles/color-brewer.css | 66 + .../highlight/styles/color-brewer.min.css | 1 + public/assets/highlight/styles/dark.css | 62 + public/assets/highlight/styles/dark.min.css | 1 + public/assets/highlight/styles/default.css | 117 + .../assets/highlight/styles/default.min.css | 9 + public/assets/highlight/styles/devibeans.css | 90 + .../assets/highlight/styles/devibeans.min.css | 7 + public/assets/highlight/styles/docco.css | 83 + public/assets/highlight/styles/docco.min.css | 1 + public/assets/highlight/styles/far.css | 67 + public/assets/highlight/styles/far.min.css | 1 + public/assets/highlight/styles/felipec.css | 94 + .../assets/highlight/styles/felipec.min.css | 7 + public/assets/highlight/styles/foundation.css | 80 + .../highlight/styles/foundation.min.css | 1 + .../highlight/styles/github-dark-dimmed.css | 117 + .../styles/github-dark-dimmed.min.css | 9 + .../assets/highlight/styles/github-dark.css | 118 + .../highlight/styles/github-dark.min.css | 10 + public/assets/highlight/styles/github.css | 118 + public/assets/highlight/styles/github.min.css | 10 + public/assets/highlight/styles/gml.css | 72 + public/assets/highlight/styles/gml.min.css | 1 + public/assets/highlight/styles/googlecode.css | 79 + .../highlight/styles/googlecode.min.css | 1 + .../assets/highlight/styles/gradient-dark.css | 90 + .../highlight/styles/gradient-dark.min.css | 1 + .../highlight/styles/gradient-light.css | 90 + .../highlight/styles/gradient-light.min.css | 1 + public/assets/highlight/styles/grayscale.css | 89 + .../assets/highlight/styles/grayscale.min.css | 1 + public/assets/highlight/styles/hybrid.css | 88 + public/assets/highlight/styles/hybrid.min.css | 1 + public/assets/highlight/styles/idea.css | 86 + public/assets/highlight/styles/idea.min.css | 1 + .../highlight/styles/intellij-light.css | 107 + .../highlight/styles/intellij-light.min.css | 1 + public/assets/highlight/styles/ir-black.css | 66 + .../assets/highlight/styles/ir-black.min.css | 1 + .../highlight/styles/isbl-editor-dark.css | 94 + .../highlight/styles/isbl-editor-dark.min.css | 1 + .../highlight/styles/isbl-editor-light.css | 93 + .../styles/isbl-editor-light.min.css | 1 + .../assets/highlight/styles/kimbie-dark.css | 69 + .../highlight/styles/kimbie-dark.min.css | 1 + .../assets/highlight/styles/kimbie-light.css | 69 + .../highlight/styles/kimbie-light.min.css | 1 + public/assets/highlight/styles/lightfair.css | 81 + .../assets/highlight/styles/lightfair.min.css | 1 + public/assets/highlight/styles/lioshi.css | 76 + public/assets/highlight/styles/lioshi.min.css | 1 + public/assets/highlight/styles/magula.css | 66 + public/assets/highlight/styles/magula.min.css | 1 + public/assets/highlight/styles/mono-blue.css | 56 + .../assets/highlight/styles/mono-blue.min.css | 1 + .../highlight/styles/monokai-sublime.css | 76 + .../highlight/styles/monokai-sublime.min.css | 1 + public/assets/highlight/styles/monokai.css | 70 + .../assets/highlight/styles/monokai.min.css | 1 + public/assets/highlight/styles/night-owl.css | 174 + .../assets/highlight/styles/night-owl.min.css | 1 + public/assets/highlight/styles/nnfx-dark.css | 104 + .../assets/highlight/styles/nnfx-dark.min.css | 10 + public/assets/highlight/styles/nnfx-light.css | 104 + .../highlight/styles/nnfx-light.min.css | 10 + public/assets/highlight/styles/nord.css | 275 ++ public/assets/highlight/styles/nord.min.css | 1 + public/assets/highlight/styles/obsidian.css | 79 + .../assets/highlight/styles/obsidian.min.css | 1 + .../highlight/styles/panda-syntax-dark.css | 92 + .../styles/panda-syntax-dark.min.css | 1 + .../highlight/styles/panda-syntax-light.css | 89 + .../styles/panda-syntax-light.min.css | 1 + .../assets/highlight/styles/paraiso-dark.css | 67 + .../highlight/styles/paraiso-dark.min.css | 1 + .../assets/highlight/styles/paraiso-light.css | 67 + .../highlight/styles/paraiso-light.min.css | 1 + public/assets/highlight/styles/pojoaque.css | 76 + public/assets/highlight/styles/pojoaque.jpg | Bin 0 -> 1186 bytes .../assets/highlight/styles/pojoaque.min.css | 1 + public/assets/highlight/styles/purebasic.css | 103 + .../assets/highlight/styles/purebasic.min.css | 1 + .../highlight/styles/qtcreator-dark.css | 76 + .../highlight/styles/qtcreator-dark.min.css | 1 + .../highlight/styles/qtcreator-light.css | 74 + .../highlight/styles/qtcreator-light.min.css | 1 + public/assets/highlight/styles/rainbow.css | 77 + .../assets/highlight/styles/rainbow.min.css | 1 + public/assets/highlight/styles/routeros.css | 86 + .../assets/highlight/styles/routeros.min.css | 1 + .../assets/highlight/styles/school-book.css | 62 + .../highlight/styles/school-book.min.css | 1 + .../highlight/styles/shades-of-purple.css | 84 + .../highlight/styles/shades-of-purple.min.css | 1 + public/assets/highlight/styles/srcery.css | 89 + public/assets/highlight/styles/srcery.min.css | 1 + .../highlight/styles/stackoverflow-dark.css | 117 + .../styles/stackoverflow-dark.min.css | 13 + .../highlight/styles/stackoverflow-light.css | 117 + .../styles/stackoverflow-light.min.css | 13 + public/assets/highlight/styles/sunburst.css | 89 + .../assets/highlight/styles/sunburst.min.css | 1 + .../highlight/styles/tokyo-night-dark.css | 114 + .../highlight/styles/tokyo-night-dark.min.css | 8 + .../highlight/styles/tokyo-night-light.css | 114 + .../styles/tokyo-night-light.min.css | 8 + .../highlight/styles/tomorrow-night-blue.css | 69 + .../styles/tomorrow-night-blue.min.css | 1 + .../styles/tomorrow-night-bright.css | 68 + .../styles/tomorrow-night-bright.min.css | 1 + public/assets/highlight/styles/vs.css | 63 + public/assets/highlight/styles/vs.min.css | 1 + public/assets/highlight/styles/vs2015.css | 100 + public/assets/highlight/styles/vs2015.min.css | 1 + public/assets/highlight/styles/xcode.css | 90 + public/assets/highlight/styles/xcode.min.css | 1 + public/assets/highlight/styles/xt256.css | 79 + public/assets/highlight/styles/xt256.min.css | 1 + public/assets/jquery/LICENSE | 36 + public/assets/jquery/jquery-3.6.4.min.js | 2 + public/assets/js/casserver-converter.js | 8 + public/assets/js/vkbeautify.js | 357 ++ src/Controller/LoginController.php | 41 +- templates/error.twig | 8 + templates/validate.twig | 37 + 537 files changed, 50257 insertions(+), 16 deletions(-) delete mode 100644 public/.keep create mode 100644 public/assets/bootstrap/bootstrap-5.3.3.min.css create mode 100644 public/assets/bootstrap/bootstrap-bundle-5.3.3.min.js create mode 100644 public/assets/css/casserver.css create mode 100644 public/assets/highlight/DIGESTS.md create mode 100644 public/assets/highlight/LICENSE create mode 100644 public/assets/highlight/README.md create mode 100644 public/assets/highlight/es/core.js create mode 100644 public/assets/highlight/es/core.min.js create mode 100644 public/assets/highlight/es/highlight.js create mode 100644 public/assets/highlight/es/highlight.min.js create mode 100644 public/assets/highlight/es/languages/bash.js create mode 100644 public/assets/highlight/es/languages/bash.min.js create mode 100644 public/assets/highlight/es/languages/php.js create mode 100644 public/assets/highlight/es/languages/php.min.js create mode 100644 public/assets/highlight/es/languages/xml.js create mode 100644 public/assets/highlight/es/languages/xml.min.js create mode 100644 public/assets/highlight/es/package.json create mode 100644 public/assets/highlight/highlight.js create mode 100644 public/assets/highlight/highlight.min.js create mode 100644 public/assets/highlight/languages/bash.js create mode 100644 public/assets/highlight/languages/bash.min.js create mode 100644 public/assets/highlight/languages/php.js create mode 100644 public/assets/highlight/languages/php.min.js create mode 100644 public/assets/highlight/languages/xml.js create mode 100644 public/assets/highlight/languages/xml.min.js create mode 100644 public/assets/highlight/package.json create mode 100644 public/assets/highlight/styles/1c-light.css create mode 100644 public/assets/highlight/styles/1c-light.min.css create mode 100644 public/assets/highlight/styles/a11y-dark.css create mode 100644 public/assets/highlight/styles/a11y-dark.min.css create mode 100644 public/assets/highlight/styles/a11y-light.css create mode 100644 public/assets/highlight/styles/a11y-light.min.css create mode 100644 public/assets/highlight/styles/agate.css create mode 100644 public/assets/highlight/styles/agate.min.css create mode 100644 public/assets/highlight/styles/an-old-hope.css create mode 100644 public/assets/highlight/styles/an-old-hope.min.css create mode 100644 public/assets/highlight/styles/androidstudio.css create mode 100644 public/assets/highlight/styles/androidstudio.min.css create mode 100644 public/assets/highlight/styles/arduino-light.css create mode 100644 public/assets/highlight/styles/arduino-light.min.css create mode 100644 public/assets/highlight/styles/arta.css create mode 100644 public/assets/highlight/styles/arta.min.css create mode 100644 public/assets/highlight/styles/ascetic.css create mode 100644 public/assets/highlight/styles/ascetic.min.css create mode 100644 public/assets/highlight/styles/atom-one-dark-reasonable.css create mode 100644 public/assets/highlight/styles/atom-one-dark-reasonable.min.css create mode 100644 public/assets/highlight/styles/atom-one-dark.css create mode 100644 public/assets/highlight/styles/atom-one-dark.min.css create mode 100644 public/assets/highlight/styles/atom-one-light.css create mode 100644 public/assets/highlight/styles/atom-one-light.min.css create mode 100644 public/assets/highlight/styles/base16/3024.css create mode 100644 public/assets/highlight/styles/base16/3024.min.css create mode 100644 public/assets/highlight/styles/base16/apathy.css create mode 100644 public/assets/highlight/styles/base16/apathy.min.css create mode 100644 public/assets/highlight/styles/base16/apprentice.css create mode 100644 public/assets/highlight/styles/base16/apprentice.min.css create mode 100644 public/assets/highlight/styles/base16/ashes.css create mode 100644 public/assets/highlight/styles/base16/ashes.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-cave-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-cave-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-cave.css create mode 100644 public/assets/highlight/styles/base16/atelier-cave.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-dune-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-dune-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-dune.css create mode 100644 public/assets/highlight/styles/base16/atelier-dune.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-estuary-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-estuary-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-estuary.css create mode 100644 public/assets/highlight/styles/base16/atelier-estuary.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-forest-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-forest-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-forest.css create mode 100644 public/assets/highlight/styles/base16/atelier-forest.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-heath-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-heath-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-heath.css create mode 100644 public/assets/highlight/styles/base16/atelier-heath.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-lakeside-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-lakeside-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-lakeside.css create mode 100644 public/assets/highlight/styles/base16/atelier-lakeside.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-plateau-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-plateau-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-plateau.css create mode 100644 public/assets/highlight/styles/base16/atelier-plateau.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-savanna-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-savanna-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-savanna.css create mode 100644 public/assets/highlight/styles/base16/atelier-savanna.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-seaside-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-seaside-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-seaside.css create mode 100644 public/assets/highlight/styles/base16/atelier-seaside.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-sulphurpool-light.css create mode 100644 public/assets/highlight/styles/base16/atelier-sulphurpool-light.min.css create mode 100644 public/assets/highlight/styles/base16/atelier-sulphurpool.css create mode 100644 public/assets/highlight/styles/base16/atelier-sulphurpool.min.css create mode 100644 public/assets/highlight/styles/base16/atlas.css create mode 100644 public/assets/highlight/styles/base16/atlas.min.css create mode 100644 public/assets/highlight/styles/base16/bespin.css create mode 100644 public/assets/highlight/styles/base16/bespin.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-bathory.css create mode 100644 public/assets/highlight/styles/base16/black-metal-bathory.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-burzum.css create mode 100644 public/assets/highlight/styles/base16/black-metal-burzum.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-dark-funeral.css create mode 100644 public/assets/highlight/styles/base16/black-metal-dark-funeral.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-gorgoroth.css create mode 100644 public/assets/highlight/styles/base16/black-metal-gorgoroth.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-immortal.css create mode 100644 public/assets/highlight/styles/base16/black-metal-immortal.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-khold.css create mode 100644 public/assets/highlight/styles/base16/black-metal-khold.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-marduk.css create mode 100644 public/assets/highlight/styles/base16/black-metal-marduk.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-mayhem.css create mode 100644 public/assets/highlight/styles/base16/black-metal-mayhem.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-nile.css create mode 100644 public/assets/highlight/styles/base16/black-metal-nile.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal-venom.css create mode 100644 public/assets/highlight/styles/base16/black-metal-venom.min.css create mode 100644 public/assets/highlight/styles/base16/black-metal.css create mode 100644 public/assets/highlight/styles/base16/black-metal.min.css create mode 100644 public/assets/highlight/styles/base16/brewer.css create mode 100644 public/assets/highlight/styles/base16/brewer.min.css create mode 100644 public/assets/highlight/styles/base16/bright.css create mode 100644 public/assets/highlight/styles/base16/bright.min.css create mode 100644 public/assets/highlight/styles/base16/brogrammer.css create mode 100644 public/assets/highlight/styles/base16/brogrammer.min.css create mode 100644 public/assets/highlight/styles/base16/brush-trees-dark.css create mode 100644 public/assets/highlight/styles/base16/brush-trees-dark.min.css create mode 100644 public/assets/highlight/styles/base16/brush-trees.css create mode 100644 public/assets/highlight/styles/base16/brush-trees.min.css create mode 100644 public/assets/highlight/styles/base16/chalk.css create mode 100644 public/assets/highlight/styles/base16/chalk.min.css create mode 100644 public/assets/highlight/styles/base16/circus.css create mode 100644 public/assets/highlight/styles/base16/circus.min.css create mode 100644 public/assets/highlight/styles/base16/classic-dark.css create mode 100644 public/assets/highlight/styles/base16/classic-dark.min.css create mode 100644 public/assets/highlight/styles/base16/classic-light.css create mode 100644 public/assets/highlight/styles/base16/classic-light.min.css create mode 100644 public/assets/highlight/styles/base16/codeschool.css create mode 100644 public/assets/highlight/styles/base16/codeschool.min.css create mode 100644 public/assets/highlight/styles/base16/colors.css create mode 100644 public/assets/highlight/styles/base16/colors.min.css create mode 100644 public/assets/highlight/styles/base16/cupcake.css create mode 100644 public/assets/highlight/styles/base16/cupcake.min.css create mode 100644 public/assets/highlight/styles/base16/cupertino.css create mode 100644 public/assets/highlight/styles/base16/cupertino.min.css create mode 100644 public/assets/highlight/styles/base16/danqing.css create mode 100644 public/assets/highlight/styles/base16/danqing.min.css create mode 100644 public/assets/highlight/styles/base16/darcula.css create mode 100644 public/assets/highlight/styles/base16/darcula.min.css create mode 100644 public/assets/highlight/styles/base16/dark-violet.css create mode 100644 public/assets/highlight/styles/base16/dark-violet.min.css create mode 100644 public/assets/highlight/styles/base16/darkmoss.css create mode 100644 public/assets/highlight/styles/base16/darkmoss.min.css create mode 100644 public/assets/highlight/styles/base16/darktooth.css create mode 100644 public/assets/highlight/styles/base16/darktooth.min.css create mode 100644 public/assets/highlight/styles/base16/decaf.css create mode 100644 public/assets/highlight/styles/base16/decaf.min.css create mode 100644 public/assets/highlight/styles/base16/default-dark.css create mode 100644 public/assets/highlight/styles/base16/default-dark.min.css create mode 100644 public/assets/highlight/styles/base16/default-light.css create mode 100644 public/assets/highlight/styles/base16/default-light.min.css create mode 100644 public/assets/highlight/styles/base16/dirtysea.css create mode 100644 public/assets/highlight/styles/base16/dirtysea.min.css create mode 100644 public/assets/highlight/styles/base16/dracula.css create mode 100644 public/assets/highlight/styles/base16/dracula.min.css create mode 100644 public/assets/highlight/styles/base16/edge-dark.css create mode 100644 public/assets/highlight/styles/base16/edge-dark.min.css create mode 100644 public/assets/highlight/styles/base16/edge-light.css create mode 100644 public/assets/highlight/styles/base16/edge-light.min.css create mode 100644 public/assets/highlight/styles/base16/eighties.css create mode 100644 public/assets/highlight/styles/base16/eighties.min.css create mode 100644 public/assets/highlight/styles/base16/embers.css create mode 100644 public/assets/highlight/styles/base16/embers.min.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-dark.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-dark.min.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-gray-dark.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-gray-dark.min.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-gray-light.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-gray-light.min.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-light.css create mode 100644 public/assets/highlight/styles/base16/equilibrium-light.min.css create mode 100644 public/assets/highlight/styles/base16/espresso.css create mode 100644 public/assets/highlight/styles/base16/espresso.min.css create mode 100644 public/assets/highlight/styles/base16/eva-dim.css create mode 100644 public/assets/highlight/styles/base16/eva-dim.min.css create mode 100644 public/assets/highlight/styles/base16/eva.css create mode 100644 public/assets/highlight/styles/base16/eva.min.css create mode 100644 public/assets/highlight/styles/base16/flat.css create mode 100644 public/assets/highlight/styles/base16/flat.min.css create mode 100644 public/assets/highlight/styles/base16/framer.css create mode 100644 public/assets/highlight/styles/base16/framer.min.css create mode 100644 public/assets/highlight/styles/base16/fruit-soda.css create mode 100644 public/assets/highlight/styles/base16/fruit-soda.min.css create mode 100644 public/assets/highlight/styles/base16/gigavolt.css create mode 100644 public/assets/highlight/styles/base16/gigavolt.min.css create mode 100644 public/assets/highlight/styles/base16/github.css create mode 100644 public/assets/highlight/styles/base16/github.min.css create mode 100644 public/assets/highlight/styles/base16/google-dark.css create mode 100644 public/assets/highlight/styles/base16/google-dark.min.css create mode 100644 public/assets/highlight/styles/base16/google-light.css create mode 100644 public/assets/highlight/styles/base16/google-light.min.css create mode 100644 public/assets/highlight/styles/base16/grayscale-dark.css create mode 100644 public/assets/highlight/styles/base16/grayscale-dark.min.css create mode 100644 public/assets/highlight/styles/base16/grayscale-light.css create mode 100644 public/assets/highlight/styles/base16/grayscale-light.min.css create mode 100644 public/assets/highlight/styles/base16/green-screen.css create mode 100644 public/assets/highlight/styles/base16/green-screen.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-hard.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-hard.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-medium.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-medium.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-pale.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-pale.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-soft.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-dark-soft.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-hard.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-hard.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-medium.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-medium.min.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-soft.css create mode 100644 public/assets/highlight/styles/base16/gruvbox-light-soft.min.css create mode 100644 public/assets/highlight/styles/base16/hardcore.css create mode 100644 public/assets/highlight/styles/base16/hardcore.min.css create mode 100644 public/assets/highlight/styles/base16/harmonic16-dark.css create mode 100644 public/assets/highlight/styles/base16/harmonic16-dark.min.css create mode 100644 public/assets/highlight/styles/base16/harmonic16-light.css create mode 100644 public/assets/highlight/styles/base16/harmonic16-light.min.css create mode 100644 public/assets/highlight/styles/base16/heetch-dark.css create mode 100644 public/assets/highlight/styles/base16/heetch-dark.min.css create mode 100644 public/assets/highlight/styles/base16/heetch-light.css create mode 100644 public/assets/highlight/styles/base16/heetch-light.min.css create mode 100644 public/assets/highlight/styles/base16/helios.css create mode 100644 public/assets/highlight/styles/base16/helios.min.css create mode 100644 public/assets/highlight/styles/base16/hopscotch.css create mode 100644 public/assets/highlight/styles/base16/hopscotch.min.css create mode 100644 public/assets/highlight/styles/base16/horizon-dark.css create mode 100644 public/assets/highlight/styles/base16/horizon-dark.min.css create mode 100644 public/assets/highlight/styles/base16/horizon-light.css create mode 100644 public/assets/highlight/styles/base16/horizon-light.min.css create mode 100644 public/assets/highlight/styles/base16/humanoid-dark.css create mode 100644 public/assets/highlight/styles/base16/humanoid-dark.min.css create mode 100644 public/assets/highlight/styles/base16/humanoid-light.css create mode 100644 public/assets/highlight/styles/base16/humanoid-light.min.css create mode 100644 public/assets/highlight/styles/base16/ia-dark.css create mode 100644 public/assets/highlight/styles/base16/ia-dark.min.css create mode 100644 public/assets/highlight/styles/base16/ia-light.css create mode 100644 public/assets/highlight/styles/base16/ia-light.min.css create mode 100644 public/assets/highlight/styles/base16/icy-dark.css create mode 100644 public/assets/highlight/styles/base16/icy-dark.min.css create mode 100644 public/assets/highlight/styles/base16/ir-black.css create mode 100644 public/assets/highlight/styles/base16/ir-black.min.css create mode 100644 public/assets/highlight/styles/base16/isotope.css create mode 100644 public/assets/highlight/styles/base16/isotope.min.css create mode 100644 public/assets/highlight/styles/base16/kimber.css create mode 100644 public/assets/highlight/styles/base16/kimber.min.css create mode 100644 public/assets/highlight/styles/base16/london-tube.css create mode 100644 public/assets/highlight/styles/base16/london-tube.min.css create mode 100644 public/assets/highlight/styles/base16/macintosh.css create mode 100644 public/assets/highlight/styles/base16/macintosh.min.css create mode 100644 public/assets/highlight/styles/base16/marrakesh.css create mode 100644 public/assets/highlight/styles/base16/marrakesh.min.css create mode 100644 public/assets/highlight/styles/base16/materia.css create mode 100644 public/assets/highlight/styles/base16/materia.min.css create mode 100644 public/assets/highlight/styles/base16/material-darker.css create mode 100644 public/assets/highlight/styles/base16/material-darker.min.css create mode 100644 public/assets/highlight/styles/base16/material-lighter.css create mode 100644 public/assets/highlight/styles/base16/material-lighter.min.css create mode 100644 public/assets/highlight/styles/base16/material-palenight.css create mode 100644 public/assets/highlight/styles/base16/material-palenight.min.css create mode 100644 public/assets/highlight/styles/base16/material-vivid.css create mode 100644 public/assets/highlight/styles/base16/material-vivid.min.css create mode 100644 public/assets/highlight/styles/base16/material.css create mode 100644 public/assets/highlight/styles/base16/material.min.css create mode 100644 public/assets/highlight/styles/base16/mellow-purple.css create mode 100644 public/assets/highlight/styles/base16/mellow-purple.min.css create mode 100644 public/assets/highlight/styles/base16/mexico-light.css create mode 100644 public/assets/highlight/styles/base16/mexico-light.min.css create mode 100644 public/assets/highlight/styles/base16/mocha.css create mode 100644 public/assets/highlight/styles/base16/mocha.min.css create mode 100644 public/assets/highlight/styles/base16/monokai.css create mode 100644 public/assets/highlight/styles/base16/monokai.min.css create mode 100644 public/assets/highlight/styles/base16/nebula.css create mode 100644 public/assets/highlight/styles/base16/nebula.min.css create mode 100644 public/assets/highlight/styles/base16/nord.css create mode 100644 public/assets/highlight/styles/base16/nord.min.css create mode 100644 public/assets/highlight/styles/base16/nova.css create mode 100644 public/assets/highlight/styles/base16/nova.min.css create mode 100644 public/assets/highlight/styles/base16/ocean.css create mode 100644 public/assets/highlight/styles/base16/ocean.min.css create mode 100644 public/assets/highlight/styles/base16/oceanicnext.css create mode 100644 public/assets/highlight/styles/base16/oceanicnext.min.css create mode 100644 public/assets/highlight/styles/base16/one-light.css create mode 100644 public/assets/highlight/styles/base16/one-light.min.css create mode 100644 public/assets/highlight/styles/base16/onedark.css create mode 100644 public/assets/highlight/styles/base16/onedark.min.css create mode 100644 public/assets/highlight/styles/base16/outrun-dark.css create mode 100644 public/assets/highlight/styles/base16/outrun-dark.min.css create mode 100644 public/assets/highlight/styles/base16/papercolor-dark.css create mode 100644 public/assets/highlight/styles/base16/papercolor-dark.min.css create mode 100644 public/assets/highlight/styles/base16/papercolor-light.css create mode 100644 public/assets/highlight/styles/base16/papercolor-light.min.css create mode 100644 public/assets/highlight/styles/base16/paraiso.css create mode 100644 public/assets/highlight/styles/base16/paraiso.min.css create mode 100644 public/assets/highlight/styles/base16/pasque.css create mode 100644 public/assets/highlight/styles/base16/pasque.min.css create mode 100644 public/assets/highlight/styles/base16/phd.css create mode 100644 public/assets/highlight/styles/base16/phd.min.css create mode 100644 public/assets/highlight/styles/base16/pico.css create mode 100644 public/assets/highlight/styles/base16/pico.min.css create mode 100644 public/assets/highlight/styles/base16/pop.css create mode 100644 public/assets/highlight/styles/base16/pop.min.css create mode 100644 public/assets/highlight/styles/base16/porple.css create mode 100644 public/assets/highlight/styles/base16/porple.min.css create mode 100644 public/assets/highlight/styles/base16/qualia.css create mode 100644 public/assets/highlight/styles/base16/qualia.min.css create mode 100644 public/assets/highlight/styles/base16/railscasts.css create mode 100644 public/assets/highlight/styles/base16/railscasts.min.css create mode 100644 public/assets/highlight/styles/base16/rebecca.css create mode 100644 public/assets/highlight/styles/base16/rebecca.min.css create mode 100644 public/assets/highlight/styles/base16/ros-pine-dawn.css create mode 100644 public/assets/highlight/styles/base16/ros-pine-dawn.min.css create mode 100644 public/assets/highlight/styles/base16/ros-pine-moon.css create mode 100644 public/assets/highlight/styles/base16/ros-pine-moon.min.css create mode 100644 public/assets/highlight/styles/base16/ros-pine.css create mode 100644 public/assets/highlight/styles/base16/ros-pine.min.css create mode 100644 public/assets/highlight/styles/base16/sagelight.css create mode 100644 public/assets/highlight/styles/base16/sagelight.min.css create mode 100644 public/assets/highlight/styles/base16/sandcastle.css create mode 100644 public/assets/highlight/styles/base16/sandcastle.min.css create mode 100644 public/assets/highlight/styles/base16/seti-ui.css create mode 100644 public/assets/highlight/styles/base16/seti-ui.min.css create mode 100644 public/assets/highlight/styles/base16/shapeshifter.css create mode 100644 public/assets/highlight/styles/base16/shapeshifter.min.css create mode 100644 public/assets/highlight/styles/base16/silk-dark.css create mode 100644 public/assets/highlight/styles/base16/silk-dark.min.css create mode 100644 public/assets/highlight/styles/base16/silk-light.css create mode 100644 public/assets/highlight/styles/base16/silk-light.min.css create mode 100644 public/assets/highlight/styles/base16/snazzy.css create mode 100644 public/assets/highlight/styles/base16/snazzy.min.css create mode 100644 public/assets/highlight/styles/base16/solar-flare-light.css create mode 100644 public/assets/highlight/styles/base16/solar-flare-light.min.css create mode 100644 public/assets/highlight/styles/base16/solar-flare.css create mode 100644 public/assets/highlight/styles/base16/solar-flare.min.css create mode 100644 public/assets/highlight/styles/base16/solarized-dark.css create mode 100644 public/assets/highlight/styles/base16/solarized-dark.min.css create mode 100644 public/assets/highlight/styles/base16/solarized-light.css create mode 100644 public/assets/highlight/styles/base16/solarized-light.min.css create mode 100644 public/assets/highlight/styles/base16/spacemacs.css create mode 100644 public/assets/highlight/styles/base16/spacemacs.min.css create mode 100644 public/assets/highlight/styles/base16/summercamp.css create mode 100644 public/assets/highlight/styles/base16/summercamp.min.css create mode 100644 public/assets/highlight/styles/base16/summerfruit-dark.css create mode 100644 public/assets/highlight/styles/base16/summerfruit-dark.min.css create mode 100644 public/assets/highlight/styles/base16/summerfruit-light.css create mode 100644 public/assets/highlight/styles/base16/summerfruit-light.min.css create mode 100644 public/assets/highlight/styles/base16/synth-midnight-terminal-dark.css create mode 100644 public/assets/highlight/styles/base16/synth-midnight-terminal-dark.min.css create mode 100644 public/assets/highlight/styles/base16/synth-midnight-terminal-light.css create mode 100644 public/assets/highlight/styles/base16/synth-midnight-terminal-light.min.css create mode 100644 public/assets/highlight/styles/base16/tango.css create mode 100644 public/assets/highlight/styles/base16/tango.min.css create mode 100644 public/assets/highlight/styles/base16/tender.css create mode 100644 public/assets/highlight/styles/base16/tender.min.css create mode 100644 public/assets/highlight/styles/base16/tomorrow-night.css create mode 100644 public/assets/highlight/styles/base16/tomorrow-night.min.css create mode 100644 public/assets/highlight/styles/base16/tomorrow.css create mode 100644 public/assets/highlight/styles/base16/tomorrow.min.css create mode 100644 public/assets/highlight/styles/base16/twilight.css create mode 100644 public/assets/highlight/styles/base16/twilight.min.css create mode 100644 public/assets/highlight/styles/base16/unikitty-dark.css create mode 100644 public/assets/highlight/styles/base16/unikitty-dark.min.css create mode 100644 public/assets/highlight/styles/base16/unikitty-light.css create mode 100644 public/assets/highlight/styles/base16/unikitty-light.min.css create mode 100644 public/assets/highlight/styles/base16/vulcan.css create mode 100644 public/assets/highlight/styles/base16/vulcan.min.css create mode 100644 public/assets/highlight/styles/base16/windows-10-light.css create mode 100644 public/assets/highlight/styles/base16/windows-10-light.min.css create mode 100644 public/assets/highlight/styles/base16/windows-10.css create mode 100644 public/assets/highlight/styles/base16/windows-10.min.css create mode 100644 public/assets/highlight/styles/base16/windows-95-light.css create mode 100644 public/assets/highlight/styles/base16/windows-95-light.min.css create mode 100644 public/assets/highlight/styles/base16/windows-95.css create mode 100644 public/assets/highlight/styles/base16/windows-95.min.css create mode 100644 public/assets/highlight/styles/base16/windows-high-contrast-light.css create mode 100644 public/assets/highlight/styles/base16/windows-high-contrast-light.min.css create mode 100644 public/assets/highlight/styles/base16/windows-high-contrast.css create mode 100644 public/assets/highlight/styles/base16/windows-high-contrast.min.css create mode 100644 public/assets/highlight/styles/base16/windows-nt-light.css create mode 100644 public/assets/highlight/styles/base16/windows-nt-light.min.css create mode 100644 public/assets/highlight/styles/base16/windows-nt.css create mode 100644 public/assets/highlight/styles/base16/windows-nt.min.css create mode 100644 public/assets/highlight/styles/base16/woodland.css create mode 100644 public/assets/highlight/styles/base16/woodland.min.css create mode 100644 public/assets/highlight/styles/base16/xcode-dusk.css create mode 100644 public/assets/highlight/styles/base16/xcode-dusk.min.css create mode 100644 public/assets/highlight/styles/base16/zenburn.css create mode 100644 public/assets/highlight/styles/base16/zenburn.min.css create mode 100644 public/assets/highlight/styles/brown-paper.css create mode 100644 public/assets/highlight/styles/brown-paper.min.css create mode 100644 public/assets/highlight/styles/brown-papersq.png create mode 100644 public/assets/highlight/styles/codepen-embed.css create mode 100644 public/assets/highlight/styles/codepen-embed.min.css create mode 100644 public/assets/highlight/styles/color-brewer.css create mode 100644 public/assets/highlight/styles/color-brewer.min.css create mode 100644 public/assets/highlight/styles/dark.css create mode 100644 public/assets/highlight/styles/dark.min.css create mode 100644 public/assets/highlight/styles/default.css create mode 100644 public/assets/highlight/styles/default.min.css create mode 100644 public/assets/highlight/styles/devibeans.css create mode 100644 public/assets/highlight/styles/devibeans.min.css create mode 100644 public/assets/highlight/styles/docco.css create mode 100644 public/assets/highlight/styles/docco.min.css create mode 100644 public/assets/highlight/styles/far.css create mode 100644 public/assets/highlight/styles/far.min.css create mode 100644 public/assets/highlight/styles/felipec.css create mode 100644 public/assets/highlight/styles/felipec.min.css create mode 100644 public/assets/highlight/styles/foundation.css create mode 100644 public/assets/highlight/styles/foundation.min.css create mode 100644 public/assets/highlight/styles/github-dark-dimmed.css create mode 100644 public/assets/highlight/styles/github-dark-dimmed.min.css create mode 100644 public/assets/highlight/styles/github-dark.css create mode 100644 public/assets/highlight/styles/github-dark.min.css create mode 100644 public/assets/highlight/styles/github.css create mode 100644 public/assets/highlight/styles/github.min.css create mode 100644 public/assets/highlight/styles/gml.css create mode 100644 public/assets/highlight/styles/gml.min.css create mode 100644 public/assets/highlight/styles/googlecode.css create mode 100644 public/assets/highlight/styles/googlecode.min.css create mode 100644 public/assets/highlight/styles/gradient-dark.css create mode 100644 public/assets/highlight/styles/gradient-dark.min.css create mode 100644 public/assets/highlight/styles/gradient-light.css create mode 100644 public/assets/highlight/styles/gradient-light.min.css create mode 100644 public/assets/highlight/styles/grayscale.css create mode 100644 public/assets/highlight/styles/grayscale.min.css create mode 100644 public/assets/highlight/styles/hybrid.css create mode 100644 public/assets/highlight/styles/hybrid.min.css create mode 100644 public/assets/highlight/styles/idea.css create mode 100644 public/assets/highlight/styles/idea.min.css create mode 100644 public/assets/highlight/styles/intellij-light.css create mode 100644 public/assets/highlight/styles/intellij-light.min.css create mode 100644 public/assets/highlight/styles/ir-black.css create mode 100644 public/assets/highlight/styles/ir-black.min.css create mode 100644 public/assets/highlight/styles/isbl-editor-dark.css create mode 100644 public/assets/highlight/styles/isbl-editor-dark.min.css create mode 100644 public/assets/highlight/styles/isbl-editor-light.css create mode 100644 public/assets/highlight/styles/isbl-editor-light.min.css create mode 100644 public/assets/highlight/styles/kimbie-dark.css create mode 100644 public/assets/highlight/styles/kimbie-dark.min.css create mode 100644 public/assets/highlight/styles/kimbie-light.css create mode 100644 public/assets/highlight/styles/kimbie-light.min.css create mode 100644 public/assets/highlight/styles/lightfair.css create mode 100644 public/assets/highlight/styles/lightfair.min.css create mode 100644 public/assets/highlight/styles/lioshi.css create mode 100644 public/assets/highlight/styles/lioshi.min.css create mode 100644 public/assets/highlight/styles/magula.css create mode 100644 public/assets/highlight/styles/magula.min.css create mode 100644 public/assets/highlight/styles/mono-blue.css create mode 100644 public/assets/highlight/styles/mono-blue.min.css create mode 100644 public/assets/highlight/styles/monokai-sublime.css create mode 100644 public/assets/highlight/styles/monokai-sublime.min.css create mode 100644 public/assets/highlight/styles/monokai.css create mode 100644 public/assets/highlight/styles/monokai.min.css create mode 100644 public/assets/highlight/styles/night-owl.css create mode 100644 public/assets/highlight/styles/night-owl.min.css create mode 100644 public/assets/highlight/styles/nnfx-dark.css create mode 100644 public/assets/highlight/styles/nnfx-dark.min.css create mode 100644 public/assets/highlight/styles/nnfx-light.css create mode 100644 public/assets/highlight/styles/nnfx-light.min.css create mode 100644 public/assets/highlight/styles/nord.css create mode 100644 public/assets/highlight/styles/nord.min.css create mode 100644 public/assets/highlight/styles/obsidian.css create mode 100644 public/assets/highlight/styles/obsidian.min.css create mode 100644 public/assets/highlight/styles/panda-syntax-dark.css create mode 100644 public/assets/highlight/styles/panda-syntax-dark.min.css create mode 100644 public/assets/highlight/styles/panda-syntax-light.css create mode 100644 public/assets/highlight/styles/panda-syntax-light.min.css create mode 100644 public/assets/highlight/styles/paraiso-dark.css create mode 100644 public/assets/highlight/styles/paraiso-dark.min.css create mode 100644 public/assets/highlight/styles/paraiso-light.css create mode 100644 public/assets/highlight/styles/paraiso-light.min.css create mode 100644 public/assets/highlight/styles/pojoaque.css create mode 100644 public/assets/highlight/styles/pojoaque.jpg create mode 100644 public/assets/highlight/styles/pojoaque.min.css create mode 100644 public/assets/highlight/styles/purebasic.css create mode 100644 public/assets/highlight/styles/purebasic.min.css create mode 100644 public/assets/highlight/styles/qtcreator-dark.css create mode 100644 public/assets/highlight/styles/qtcreator-dark.min.css create mode 100644 public/assets/highlight/styles/qtcreator-light.css create mode 100644 public/assets/highlight/styles/qtcreator-light.min.css create mode 100644 public/assets/highlight/styles/rainbow.css create mode 100644 public/assets/highlight/styles/rainbow.min.css create mode 100644 public/assets/highlight/styles/routeros.css create mode 100644 public/assets/highlight/styles/routeros.min.css create mode 100644 public/assets/highlight/styles/school-book.css create mode 100644 public/assets/highlight/styles/school-book.min.css create mode 100644 public/assets/highlight/styles/shades-of-purple.css create mode 100644 public/assets/highlight/styles/shades-of-purple.min.css create mode 100644 public/assets/highlight/styles/srcery.css create mode 100644 public/assets/highlight/styles/srcery.min.css create mode 100644 public/assets/highlight/styles/stackoverflow-dark.css create mode 100644 public/assets/highlight/styles/stackoverflow-dark.min.css create mode 100644 public/assets/highlight/styles/stackoverflow-light.css create mode 100644 public/assets/highlight/styles/stackoverflow-light.min.css create mode 100644 public/assets/highlight/styles/sunburst.css create mode 100644 public/assets/highlight/styles/sunburst.min.css create mode 100644 public/assets/highlight/styles/tokyo-night-dark.css create mode 100644 public/assets/highlight/styles/tokyo-night-dark.min.css create mode 100644 public/assets/highlight/styles/tokyo-night-light.css create mode 100644 public/assets/highlight/styles/tokyo-night-light.min.css create mode 100644 public/assets/highlight/styles/tomorrow-night-blue.css create mode 100644 public/assets/highlight/styles/tomorrow-night-blue.min.css create mode 100644 public/assets/highlight/styles/tomorrow-night-bright.css create mode 100644 public/assets/highlight/styles/tomorrow-night-bright.min.css create mode 100644 public/assets/highlight/styles/vs.css create mode 100644 public/assets/highlight/styles/vs.min.css create mode 100644 public/assets/highlight/styles/vs2015.css create mode 100644 public/assets/highlight/styles/vs2015.min.css create mode 100644 public/assets/highlight/styles/xcode.css create mode 100644 public/assets/highlight/styles/xcode.min.css create mode 100644 public/assets/highlight/styles/xt256.css create mode 100644 public/assets/highlight/styles/xt256.min.css create mode 100644 public/assets/jquery/LICENSE create mode 100644 public/assets/jquery/jquery-3.6.4.min.js create mode 100644 public/assets/js/casserver-converter.js create mode 100644 public/assets/js/vkbeautify.js create mode 100644 templates/error.twig create mode 100644 templates/validate.twig diff --git a/composer.json b/composer.json index dbc25e0e..b536eb69 100644 --- a/composer.json +++ b/composer.json @@ -61,11 +61,11 @@ }, "scripts": { "validate": [ - "vendor/bin/phpunit --no-coverage --testdox", "vendor/bin/phpcs -p", "vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json", "vendor/bin/psalm -c psalm-dev.xml", - "vendor/bin/composer-unused" + "vendor/bin/composer-unused", + "vendor/bin/phpunit --no-coverage --testdox" ], "tests": [ "vendor/bin/phpunit --no-coverage" diff --git a/locales/en/LC_MESSAGES/casserver.po b/locales/en/LC_MESSAGES/casserver.po index 1c3d6161..c14fc3b5 100644 --- a/locales/en/LC_MESSAGES/casserver.po +++ b/locales/en/LC_MESSAGES/casserver.po @@ -30,3 +30,15 @@ msgstr "Logged in" msgid "You are logged in." msgstr "You are logged in." +msgid "{copy}" +msgstr "Copy" + +msgid "{copied}" +msgstr "Copied!" + +msgid "{continue}" +msgstr "Continue" + +msgid "{copyToClipboard}" +msgstr "Copy to clipboard" + diff --git a/phpcs.xml b/phpcs.xml index 86466c0d..3937d3e9 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -21,6 +21,10 @@ tests/config/* + public/assets/bootstrap/* + public/assets/jquery/* + public/assets/highlight/* + public/assets/js/vkbeautify.js diff --git a/public/.keep b/public/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/public/assets/bootstrap/bootstrap-5.3.3.min.css b/public/assets/bootstrap/bootstrap-5.3.3.min.css new file mode 100644 index 00000000..7d437539 --- /dev/null +++ b/public/assets/bootstrap/bootstrap-5.3.3.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/public/assets/bootstrap/bootstrap-bundle-5.3.3.min.js b/public/assets/bootstrap/bootstrap-bundle-5.3.3.min.js new file mode 100644 index 00000000..04e9185b --- /dev/null +++ b/public/assets/bootstrap/bootstrap-bundle-5.3.3.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/public/assets/css/casserver.css b/public/assets/css/casserver.css new file mode 100644 index 00000000..d9ee2f9f --- /dev/null +++ b/public/assets/css/casserver.css @@ -0,0 +1,11 @@ +code { + min-width: 100%; + height: 50vh; + width: 0px; + overflow: auto; /* Or scroll */ +} + +pre { + padding: unset; +} + diff --git a/public/assets/highlight/DIGESTS.md b/public/assets/highlight/DIGESTS.md new file mode 100644 index 00000000..f66ad432 --- /dev/null +++ b/public/assets/highlight/DIGESTS.md @@ -0,0 +1,37 @@ +## Subresource Integrity + +If you are loading Highlight.js via CDN you may wish to use [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) to guarantee that you are using a legimitate build of the library. + +To do this you simply need to add the `integrity` attribute for each JavaScript file you download via CDN. These digests are used by the browser to confirm the files downloaded have not been modified. + +```html + + + +``` + +The full list of digests for every file can be found below. + +### Digests + +``` +sha384-no5/zgQGupzPFGWV8VpJzfQau5/GI2v5b7I45l6nKc8gMOxzBHfgyxNdjQEnmW94 /es/languages/bash.js +sha384-u2nRnIxVxHkjnpxFScw/XgxNVuLz4dXXiT56xbp+Kk2b8AuStNgggHNpM9HO569A /es/languages/bash.min.js +sha384-BxojDi6ePBYN3unEc6aUEpUtUyx0Eq0i/UZPISuI2YQy6eAD5HzD0dtBC53uZ6R1 /es/languages/php.js +sha384-C28kAnknmyKQUME+2sa0tyQkFMN1S/aUSB1gQ+PKUf0+ewgxNh8CwAmd+C0UUaZ7 /es/languages/php.min.js +sha384-Tdx2DY9ZTHx3KhVXYqOVKx3q1zOboDGlTTv8sgMlER8y4WETtqL+C4VQ7B4A0OGq /es/languages/xml.js +sha384-n9ZezaAVj8pK1BIFZQxmC1BM9yGuBNRgvsOxHMHPCXzqYd1gSYIu9KjgGEm8K57w /es/languages/xml.min.js +sha384-4SbTAv3AX2fuPCpSv6HW3p07YgA7hFfcwG2zJHtYv0ATIt1juD3IXj2NSYwTeIpm /languages/bash.js +sha384-83HvQQdGTWqnRLmgm19NjCb1+/awKJGytUX9sm3HJb2ddZ9Fs1Bst2bZogFjt9rr /languages/bash.min.js +sha384-swGDgtGOmzrsbFAaQRjzvGs0hhe0N86mfHIuisr3W9AT0hiheGyRORSGrbMDGOw5 /languages/php.js +sha384-Xd0AQIkWCEjBL8BNMtDMGVqFXkf445J6PNUJdXbh334ckjeSa90wtSUjXySuz+rt /languages/php.min.js +sha384-QAL2h4IMgQaJUJjUy0dSWdAut7o/A272ai8qOsJ8SSm9KMxkdLgH7oGfLGft/EJ0 /languages/xml.js +sha384-CN3No+n1UZXCFYyl+ge5yAPGTNGuH23BdIsFJxntDmEYL94AmoZlNBHGSdjVSjKG /languages/xml.min.js +sha384-hdHa09JPBmAChFAcnM9vOrZVSTdAXz5tLRf6AvcszDlV4V4JLnbF4I16Iw1MH1c/ /highlight.js +sha384-3dR6ERhkzUCvAqDAdUxAFvA2hItRcIPYedbUDiaVf4SqKKwuy+76eLWNBuowSkMK /highlight.min.js +``` + diff --git a/public/assets/highlight/LICENSE b/public/assets/highlight/LICENSE new file mode 100644 index 00000000..2250cc7e --- /dev/null +++ b/public/assets/highlight/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/assets/highlight/README.md b/public/assets/highlight/README.md new file mode 100644 index 00000000..30d84b95 --- /dev/null +++ b/public/assets/highlight/README.md @@ -0,0 +1,45 @@ +# Highlight.js CDN Assets + +[![install size](https://packagephobia.now.sh/badge?p=highlight.js)](https://packagephobia.now.sh/result?p=highlight.js) + +**This package contains only the CDN build assets of highlight.js.** + +This may be what you want if you'd like to install the pre-built distributable highlight.js client-side assets via NPM. If you're wanting to use highlight.js mainly on the server-side you likely want the [highlight.js][1] package instead. + +To access these files via CDN:
+https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/ + +**If you just want a single .js file with the common languages built-in: +** + +--- + +## Highlight.js + +Highlight.js is a syntax highlighter written in JavaScript. It works in +the browser as well as on the server. It works with pretty much any +markup, doesn’t depend on any framework, and has automatic language +detection. + +If you'd like to read the full README:
+ + +## License + +Highlight.js is released under the BSD License. See [LICENSE][7] file +for details. + +## Links + +The official site for the library is at . + +The Github project may be found at: + +Further in-depth documentation for the API and other topics is at +. + +A list of the Core Team and contributors can be found in the [CONTRIBUTORS.md][8] file. + +[1]: https://www.npmjs.com/package/highlight.js +[7]: https://github.com/highlightjs/highlight.js/blob/main/LICENSE +[8]: https://github.com/highlightjs/highlight.js/blob/main/CONTRIBUTORS.md diff --git a/public/assets/highlight/es/core.js b/public/assets/highlight/es/core.js new file mode 100644 index 00000000..1558390b --- /dev/null +++ b/public/assets/highlight/es/core.js @@ -0,0 +1,2600 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +/* eslint-disable no-multi-assign */ + +function deepFreeze(obj) { + if (obj instanceof Map) { + obj.clear = + obj.delete = + obj.set = + function () { + throw new Error('map is read-only'); + }; + } else if (obj instanceof Set) { + obj.add = + obj.clear = + obj.delete = + function () { + throw new Error('set is read-only'); + }; + } + + // Freeze self + Object.freeze(obj); + + Object.getOwnPropertyNames(obj).forEach((name) => { + const prop = obj[name]; + const type = typeof prop; + + // Freeze prop if it is an object or function and also not already frozen + if ((type === 'object' || type === 'function') && !Object.isFrozen(prop)) { + deepFreeze(prop); + } + }); + + return obj; +} + +/** @typedef {import('highlight.js').CallbackResponse} CallbackResponse */ +/** @typedef {import('highlight.js').CompiledMode} CompiledMode */ +/** @implements CallbackResponse */ + +class Response { + /** + * @param {CompiledMode} mode + */ + constructor(mode) { + // eslint-disable-next-line no-undefined + if (mode.data === undefined) mode.data = {}; + + this.data = mode.data; + this.isMatchIgnored = false; + } + + ignoreMatch() { + this.isMatchIgnored = true; + } +} + +/** + * @param {string} value + * @returns {string} + */ +function escapeHTML(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * performs a shallow merge of multiple objects into one + * + * @template T + * @param {T} original + * @param {Record[]} objects + * @returns {T} a single new object + */ +function inherit$1(original, ...objects) { + /** @type Record */ + const result = Object.create(null); + + for (const key in original) { + result[key] = original[key]; + } + objects.forEach(function(obj) { + for (const key in obj) { + result[key] = obj[key]; + } + }); + return /** @type {T} */ (result); +} + +/** + * @typedef {object} Renderer + * @property {(text: string) => void} addText + * @property {(node: Node) => void} openNode + * @property {(node: Node) => void} closeNode + * @property {() => string} value + */ + +/** @typedef {{scope?: string, language?: string, sublanguage?: boolean}} Node */ +/** @typedef {{walk: (r: Renderer) => void}} Tree */ +/** */ + +const SPAN_CLOSE = ''; + +/** + * Determines if a node needs to be wrapped in + * + * @param {Node} node */ +const emitsWrappingTags = (node) => { + // rarely we can have a sublanguage where language is undefined + // TODO: track down why + return !!node.scope; +}; + +/** + * + * @param {string} name + * @param {{prefix:string}} options + */ +const scopeToCSSClass = (name, { prefix }) => { + // sub-language + if (name.startsWith("language:")) { + return name.replace("language:", "language-"); + } + // tiered scope: comment.line + if (name.includes(".")) { + const pieces = name.split("."); + return [ + `${prefix}${pieces.shift()}`, + ...(pieces.map((x, i) => `${x}${"_".repeat(i + 1)}`)) + ].join(" "); + } + // simple scope + return `${prefix}${name}`; +}; + +/** @type {Renderer} */ +class HTMLRenderer { + /** + * Creates a new HTMLRenderer + * + * @param {Tree} parseTree - the parse tree (must support `walk` API) + * @param {{classPrefix: string}} options + */ + constructor(parseTree, options) { + this.buffer = ""; + this.classPrefix = options.classPrefix; + parseTree.walk(this); + } + + /** + * Adds texts to the output stream + * + * @param {string} text */ + addText(text) { + this.buffer += escapeHTML(text); + } + + /** + * Adds a node open to the output stream (if needed) + * + * @param {Node} node */ + openNode(node) { + if (!emitsWrappingTags(node)) return; + + const className = scopeToCSSClass(node.scope, + { prefix: this.classPrefix }); + this.span(className); + } + + /** + * Adds a node close to the output stream (if needed) + * + * @param {Node} node */ + closeNode(node) { + if (!emitsWrappingTags(node)) return; + + this.buffer += SPAN_CLOSE; + } + + /** + * returns the accumulated buffer + */ + value() { + return this.buffer; + } + + // helpers + + /** + * Builds a span element + * + * @param {string} className */ + span(className) { + this.buffer += ``; + } +} + +/** @typedef {{scope?: string, language?: string, children: Node[]} | string} Node */ +/** @typedef {{scope?: string, language?: string, children: Node[]} } DataNode */ +/** @typedef {import('highlight.js').Emitter} Emitter */ +/** */ + +/** @returns {DataNode} */ +const newNode = (opts = {}) => { + /** @type DataNode */ + const result = { children: [] }; + Object.assign(result, opts); + return result; +}; + +class TokenTree { + constructor() { + /** @type DataNode */ + this.rootNode = newNode(); + this.stack = [this.rootNode]; + } + + get top() { + return this.stack[this.stack.length - 1]; + } + + get root() { return this.rootNode; } + + /** @param {Node} node */ + add(node) { + this.top.children.push(node); + } + + /** @param {string} scope */ + openNode(scope) { + /** @type Node */ + const node = newNode({ scope }); + this.add(node); + this.stack.push(node); + } + + closeNode() { + if (this.stack.length > 1) { + return this.stack.pop(); + } + // eslint-disable-next-line no-undefined + return undefined; + } + + closeAllNodes() { + while (this.closeNode()); + } + + toJSON() { + return JSON.stringify(this.rootNode, null, 4); + } + + /** + * @typedef { import("./html_renderer").Renderer } Renderer + * @param {Renderer} builder + */ + walk(builder) { + // this does not + return this.constructor._walk(builder, this.rootNode); + // this works + // return TokenTree._walk(builder, this.rootNode); + } + + /** + * @param {Renderer} builder + * @param {Node} node + */ + static _walk(builder, node) { + if (typeof node === "string") { + builder.addText(node); + } else if (node.children) { + builder.openNode(node); + node.children.forEach((child) => this._walk(builder, child)); + builder.closeNode(node); + } + return builder; + } + + /** + * @param {Node} node + */ + static _collapse(node) { + if (typeof node === "string") return; + if (!node.children) return; + + if (node.children.every(el => typeof el === "string")) { + // node.text = node.children.join(""); + // delete node.children; + node.children = [node.children.join("")]; + } else { + node.children.forEach((child) => { + TokenTree._collapse(child); + }); + } + } +} + +/** + Currently this is all private API, but this is the minimal API necessary + that an Emitter must implement to fully support the parser. + + Minimal interface: + + - addText(text) + - __addSublanguage(emitter, subLanguageName) + - startScope(scope) + - endScope() + - finalize() + - toHTML() + +*/ + +/** + * @implements {Emitter} + */ +class TokenTreeEmitter extends TokenTree { + /** + * @param {*} options + */ + constructor(options) { + super(); + this.options = options; + } + + /** + * @param {string} text + */ + addText(text) { + if (text === "") { return; } + + this.add(text); + } + + /** @param {string} scope */ + startScope(scope) { + this.openNode(scope); + } + + endScope() { + this.closeNode(); + } + + /** + * @param {Emitter & {root: DataNode}} emitter + * @param {string} name + */ + __addSublanguage(emitter, name) { + /** @type DataNode */ + const node = emitter.root; + if (name) node.scope = `language:${name}`; + + this.add(node); + } + + toHTML() { + const renderer = new HTMLRenderer(this, this.options); + return renderer.value(); + } + + finalize() { + this.closeAllNodes(); + return true; + } +} + +/** + * @param {string} value + * @returns {RegExp} + * */ + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function source(re) { + if (!re) return null; + if (typeof re === "string") return re; + + return re.source; +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function lookahead(re) { + return concat('(?=', re, ')'); +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function anyNumberOfTimes(re) { + return concat('(?:', re, ')*'); +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function optional(re) { + return concat('(?:', re, ')?'); +} + +/** + * @param {...(RegExp | string) } args + * @returns {string} + */ +function concat(...args) { + const joined = args.map((x) => source(x)).join(""); + return joined; +} + +/** + * @param { Array } args + * @returns {object} + */ +function stripOptionsFromArgs(args) { + const opts = args[args.length - 1]; + + if (typeof opts === 'object' && opts.constructor === Object) { + args.splice(args.length - 1, 1); + return opts; + } else { + return {}; + } +} + +/** @typedef { {capture?: boolean} } RegexEitherOptions */ + +/** + * Any of the passed expresssions may match + * + * Creates a huge this | this | that | that match + * @param {(RegExp | string)[] | [...(RegExp | string)[], RegexEitherOptions]} args + * @returns {string} + */ +function either(...args) { + /** @type { object & {capture?: boolean} } */ + const opts = stripOptionsFromArgs(args); + const joined = '(' + + (opts.capture ? "" : "?:") + + args.map((x) => source(x)).join("|") + ")"; + return joined; +} + +/** + * @param {RegExp | string} re + * @returns {number} + */ +function countMatchGroups(re) { + return (new RegExp(re.toString() + '|')).exec('').length - 1; +} + +/** + * Does lexeme start with a regular expression match at the beginning + * @param {RegExp} re + * @param {string} lexeme + */ +function startsWith(re, lexeme) { + const match = re && re.exec(lexeme); + return match && match.index === 0; +} + +// BACKREF_RE matches an open parenthesis or backreference. To avoid +// an incorrect parse, it additionally matches the following: +// - [...] elements, where the meaning of parentheses and escapes change +// - other escape sequences, so we do not misparse escape sequences as +// interesting elements +// - non-matching or lookahead parentheses, which do not capture. These +// follow the '(' with a '?'. +const BACKREF_RE = /\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./; + +// **INTERNAL** Not intended for outside usage +// join logically computes regexps.join(separator), but fixes the +// backreferences so they continue to match. +// it also places each individual regular expression into it's own +// match group, keeping track of the sequencing of those match groups +// is currently an exercise for the caller. :-) +/** + * @param {(string | RegExp)[]} regexps + * @param {{joinWith: string}} opts + * @returns {string} + */ +function _rewriteBackreferences(regexps, { joinWith }) { + let numCaptures = 0; + + return regexps.map((regex) => { + numCaptures += 1; + const offset = numCaptures; + let re = source(regex); + let out = ''; + + while (re.length > 0) { + const match = BACKREF_RE.exec(re); + if (!match) { + out += re; + break; + } + out += re.substring(0, match.index); + re = re.substring(match.index + match[0].length); + if (match[0][0] === '\\' && match[1]) { + // Adjust the backreference. + out += '\\' + String(Number(match[1]) + offset); + } else { + out += match[0]; + if (match[0] === '(') { + numCaptures++; + } + } + } + return out; + }).map(re => `(${re})`).join(joinWith); +} + +/** @typedef {import('highlight.js').Mode} Mode */ +/** @typedef {import('highlight.js').ModeCallback} ModeCallback */ + +// Common regexps +const MATCH_NOTHING_RE = /\b\B/; +const IDENT_RE = '[a-zA-Z]\\w*'; +const UNDERSCORE_IDENT_RE = '[a-zA-Z_]\\w*'; +const NUMBER_RE = '\\b\\d+(\\.\\d+)?'; +const C_NUMBER_RE = '(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)'; // 0x..., 0..., decimal, float +const BINARY_NUMBER_RE = '\\b(0b[01]+)'; // 0b... +const RE_STARTERS_RE = '!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~'; + +/** +* @param { Partial & {binary?: string | RegExp} } opts +*/ +const SHEBANG = (opts = {}) => { + const beginShebang = /^#![ ]*\//; + if (opts.binary) { + opts.begin = concat( + beginShebang, + /.*\b/, + opts.binary, + /\b.*/); + } + return inherit$1({ + scope: 'meta', + begin: beginShebang, + end: /$/, + relevance: 0, + /** @type {ModeCallback} */ + "on:begin": (m, resp) => { + if (m.index !== 0) resp.ignoreMatch(); + } + }, opts); +}; + +// Common modes +const BACKSLASH_ESCAPE = { + begin: '\\\\[\\s\\S]', relevance: 0 +}; +const APOS_STRING_MODE = { + scope: 'string', + begin: '\'', + end: '\'', + illegal: '\\n', + contains: [BACKSLASH_ESCAPE] +}; +const QUOTE_STRING_MODE = { + scope: 'string', + begin: '"', + end: '"', + illegal: '\\n', + contains: [BACKSLASH_ESCAPE] +}; +const PHRASAL_WORDS_MODE = { + begin: /\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +}; +/** + * Creates a comment mode + * + * @param {string | RegExp} begin + * @param {string | RegExp} end + * @param {Mode | {}} [modeOptions] + * @returns {Partial} + */ +const COMMENT = function(begin, end, modeOptions = {}) { + const mode = inherit$1( + { + scope: 'comment', + begin, + end, + contains: [] + }, + modeOptions + ); + mode.contains.push({ + scope: 'doctag', + // hack to avoid the space from being included. the space is necessary to + // match here to prevent the plain text rule below from gobbling up doctags + begin: '[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)', + end: /(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/, + excludeBegin: true, + relevance: 0 + }); + const ENGLISH_WORD = either( + // list of common 1 and 2 letter words in English + "I", + "a", + "is", + "so", + "us", + "to", + "at", + "if", + "in", + "it", + "on", + // note: this is not an exhaustive list of contractions, just popular ones + /[A-Za-z]+['](d|ve|re|ll|t|s|n)/, // contractions - can't we'd they're let's, etc + /[A-Za-z]+[-][a-z]+/, // `no-way`, etc. + /[A-Za-z][a-z]{2,}/ // allow capitalized words at beginning of sentences + ); + // looking like plain text, more likely to be a comment + mode.contains.push( + { + // TODO: how to include ", (, ) without breaking grammars that use these for + // comment delimiters? + // begin: /[ ]+([()"]?([A-Za-z'-]{3,}|is|a|I|so|us|[tT][oO]|at|if|in|it|on)[.]?[()":]?([.][ ]|[ ]|\))){3}/ + // --- + + // this tries to find sequences of 3 english words in a row (without any + // "programming" type syntax) this gives us a strong signal that we've + // TRULY found a comment - vs perhaps scanning with the wrong language. + // It's possible to find something that LOOKS like the start of the + // comment - but then if there is no readable text - good chance it is a + // false match and not a comment. + // + // for a visual example please see: + // https://github.com/highlightjs/highlight.js/issues/2827 + + begin: concat( + /[ ]+/, // necessary to prevent us gobbling up doctags like /* @author Bob Mcgill */ + '(', + ENGLISH_WORD, + /[.]?[:]?([.][ ]|[ ])/, + '){3}') // look for 3 words in a row + } + ); + return mode; +}; +const C_LINE_COMMENT_MODE = COMMENT('//', '$'); +const C_BLOCK_COMMENT_MODE = COMMENT('/\\*', '\\*/'); +const HASH_COMMENT_MODE = COMMENT('#', '$'); +const NUMBER_MODE = { + scope: 'number', + begin: NUMBER_RE, + relevance: 0 +}; +const C_NUMBER_MODE = { + scope: 'number', + begin: C_NUMBER_RE, + relevance: 0 +}; +const BINARY_NUMBER_MODE = { + scope: 'number', + begin: BINARY_NUMBER_RE, + relevance: 0 +}; +const REGEXP_MODE = { + scope: "regexp", + begin: /\/(?=[^/\n]*\/)/, + end: /\/[gimuy]*/, + contains: [ + BACKSLASH_ESCAPE, + { + begin: /\[/, + end: /\]/, + relevance: 0, + contains: [BACKSLASH_ESCAPE] + } + ] +}; +const TITLE_MODE = { + scope: 'title', + begin: IDENT_RE, + relevance: 0 +}; +const UNDERSCORE_TITLE_MODE = { + scope: 'title', + begin: UNDERSCORE_IDENT_RE, + relevance: 0 +}; +const METHOD_GUARD = { + // excludes method names from keyword processing + begin: '\\.\\s*' + UNDERSCORE_IDENT_RE, + relevance: 0 +}; + +/** + * Adds end same as begin mechanics to a mode + * + * Your mode must include at least a single () match group as that first match + * group is what is used for comparison + * @param {Partial} mode + */ +const END_SAME_AS_BEGIN = function(mode) { + return Object.assign(mode, + { + /** @type {ModeCallback} */ + 'on:begin': (m, resp) => { resp.data._beginMatch = m[1]; }, + /** @type {ModeCallback} */ + 'on:end': (m, resp) => { if (resp.data._beginMatch !== m[1]) resp.ignoreMatch(); } + }); +}; + +var MODES = /*#__PURE__*/Object.freeze({ + __proto__: null, + APOS_STRING_MODE: APOS_STRING_MODE, + BACKSLASH_ESCAPE: BACKSLASH_ESCAPE, + BINARY_NUMBER_MODE: BINARY_NUMBER_MODE, + BINARY_NUMBER_RE: BINARY_NUMBER_RE, + COMMENT: COMMENT, + C_BLOCK_COMMENT_MODE: C_BLOCK_COMMENT_MODE, + C_LINE_COMMENT_MODE: C_LINE_COMMENT_MODE, + C_NUMBER_MODE: C_NUMBER_MODE, + C_NUMBER_RE: C_NUMBER_RE, + END_SAME_AS_BEGIN: END_SAME_AS_BEGIN, + HASH_COMMENT_MODE: HASH_COMMENT_MODE, + IDENT_RE: IDENT_RE, + MATCH_NOTHING_RE: MATCH_NOTHING_RE, + METHOD_GUARD: METHOD_GUARD, + NUMBER_MODE: NUMBER_MODE, + NUMBER_RE: NUMBER_RE, + PHRASAL_WORDS_MODE: PHRASAL_WORDS_MODE, + QUOTE_STRING_MODE: QUOTE_STRING_MODE, + REGEXP_MODE: REGEXP_MODE, + RE_STARTERS_RE: RE_STARTERS_RE, + SHEBANG: SHEBANG, + TITLE_MODE: TITLE_MODE, + UNDERSCORE_IDENT_RE: UNDERSCORE_IDENT_RE, + UNDERSCORE_TITLE_MODE: UNDERSCORE_TITLE_MODE +}); + +/** +@typedef {import('highlight.js').CallbackResponse} CallbackResponse +@typedef {import('highlight.js').CompilerExt} CompilerExt +*/ + +// Grammar extensions / plugins +// See: https://github.com/highlightjs/highlight.js/issues/2833 + +// Grammar extensions allow "syntactic sugar" to be added to the grammar modes +// without requiring any underlying changes to the compiler internals. + +// `compileMatch` being the perfect small example of now allowing a grammar +// author to write `match` when they desire to match a single expression rather +// than being forced to use `begin`. The extension then just moves `match` into +// `begin` when it runs. Ie, no features have been added, but we've just made +// the experience of writing (and reading grammars) a little bit nicer. + +// ------ + +// TODO: We need negative look-behind support to do this properly +/** + * Skip a match if it has a preceding dot + * + * This is used for `beginKeywords` to prevent matching expressions such as + * `bob.keyword.do()`. The mode compiler automatically wires this up as a + * special _internal_ 'on:begin' callback for modes with `beginKeywords` + * @param {RegExpMatchArray} match + * @param {CallbackResponse} response + */ +function skipIfHasPrecedingDot(match, response) { + const before = match.input[match.index - 1]; + if (before === ".") { + response.ignoreMatch(); + } +} + +/** + * + * @type {CompilerExt} + */ +function scopeClassName(mode, _parent) { + // eslint-disable-next-line no-undefined + if (mode.className !== undefined) { + mode.scope = mode.className; + delete mode.className; + } +} + +/** + * `beginKeywords` syntactic sugar + * @type {CompilerExt} + */ +function beginKeywords(mode, parent) { + if (!parent) return; + if (!mode.beginKeywords) return; + + // for languages with keywords that include non-word characters checking for + // a word boundary is not sufficient, so instead we check for a word boundary + // or whitespace - this does no harm in any case since our keyword engine + // doesn't allow spaces in keywords anyways and we still check for the boundary + // first + mode.begin = '\\b(' + mode.beginKeywords.split(' ').join('|') + ')(?!\\.)(?=\\b|\\s)'; + mode.__beforeBegin = skipIfHasPrecedingDot; + mode.keywords = mode.keywords || mode.beginKeywords; + delete mode.beginKeywords; + + // prevents double relevance, the keywords themselves provide + // relevance, the mode doesn't need to double it + // eslint-disable-next-line no-undefined + if (mode.relevance === undefined) mode.relevance = 0; +} + +/** + * Allow `illegal` to contain an array of illegal values + * @type {CompilerExt} + */ +function compileIllegal(mode, _parent) { + if (!Array.isArray(mode.illegal)) return; + + mode.illegal = either(...mode.illegal); +} + +/** + * `match` to match a single expression for readability + * @type {CompilerExt} + */ +function compileMatch(mode, _parent) { + if (!mode.match) return; + if (mode.begin || mode.end) throw new Error("begin & end are not supported with match"); + + mode.begin = mode.match; + delete mode.match; +} + +/** + * provides the default 1 relevance to all modes + * @type {CompilerExt} + */ +function compileRelevance(mode, _parent) { + // eslint-disable-next-line no-undefined + if (mode.relevance === undefined) mode.relevance = 1; +} + +// allow beforeMatch to act as a "qualifier" for the match +// the full match begin must be [beforeMatch][begin] +const beforeMatchExt = (mode, parent) => { + if (!mode.beforeMatch) return; + // starts conflicts with endsParent which we need to make sure the child + // rule is not matched multiple times + if (mode.starts) throw new Error("beforeMatch cannot be used with starts"); + + const originalMode = Object.assign({}, mode); + Object.keys(mode).forEach((key) => { delete mode[key]; }); + + mode.keywords = originalMode.keywords; + mode.begin = concat(originalMode.beforeMatch, lookahead(originalMode.begin)); + mode.starts = { + relevance: 0, + contains: [ + Object.assign(originalMode, { endsParent: true }) + ] + }; + mode.relevance = 0; + + delete originalMode.beforeMatch; +}; + +// keywords that should have no default relevance value +const COMMON_KEYWORDS = [ + 'of', + 'and', + 'for', + 'in', + 'not', + 'or', + 'if', + 'then', + 'parent', // common variable name + 'list', // common variable name + 'value' // common variable name +]; + +const DEFAULT_KEYWORD_SCOPE = "keyword"; + +/** + * Given raw keywords from a language definition, compile them. + * + * @param {string | Record | Array} rawKeywords + * @param {boolean} caseInsensitive + */ +function compileKeywords(rawKeywords, caseInsensitive, scopeName = DEFAULT_KEYWORD_SCOPE) { + /** @type {import("highlight.js/private").KeywordDict} */ + const compiledKeywords = Object.create(null); + + // input can be a string of keywords, an array of keywords, or a object with + // named keys representing scopeName (which can then point to a string or array) + if (typeof rawKeywords === 'string') { + compileList(scopeName, rawKeywords.split(" ")); + } else if (Array.isArray(rawKeywords)) { + compileList(scopeName, rawKeywords); + } else { + Object.keys(rawKeywords).forEach(function(scopeName) { + // collapse all our objects back into the parent object + Object.assign( + compiledKeywords, + compileKeywords(rawKeywords[scopeName], caseInsensitive, scopeName) + ); + }); + } + return compiledKeywords; + + // --- + + /** + * Compiles an individual list of keywords + * + * Ex: "for if when while|5" + * + * @param {string} scopeName + * @param {Array} keywordList + */ + function compileList(scopeName, keywordList) { + if (caseInsensitive) { + keywordList = keywordList.map(x => x.toLowerCase()); + } + keywordList.forEach(function(keyword) { + const pair = keyword.split('|'); + compiledKeywords[pair[0]] = [scopeName, scoreForKeyword(pair[0], pair[1])]; + }); + } +} + +/** + * Returns the proper score for a given keyword + * + * Also takes into account comment keywords, which will be scored 0 UNLESS + * another score has been manually assigned. + * @param {string} keyword + * @param {string} [providedScore] + */ +function scoreForKeyword(keyword, providedScore) { + // manual scores always win over common keywords + // so you can force a score of 1 if you really insist + if (providedScore) { + return Number(providedScore); + } + + return commonKeyword(keyword) ? 0 : 1; +} + +/** + * Determines if a given keyword is common or not + * + * @param {string} keyword */ +function commonKeyword(keyword) { + return COMMON_KEYWORDS.includes(keyword.toLowerCase()); +} + +/* + +For the reasoning behind this please see: +https://github.com/highlightjs/highlight.js/issues/2880#issuecomment-747275419 + +*/ + +/** + * @type {Record} + */ +const seenDeprecations = {}; + +/** + * @param {string} message + */ +const error = (message) => { + console.error(message); +}; + +/** + * @param {string} message + * @param {any} args + */ +const warn = (message, ...args) => { + console.log(`WARN: ${message}`, ...args); +}; + +/** + * @param {string} version + * @param {string} message + */ +const deprecated = (version, message) => { + if (seenDeprecations[`${version}/${message}`]) return; + + console.log(`Deprecated as of ${version}. ${message}`); + seenDeprecations[`${version}/${message}`] = true; +}; + +/* eslint-disable no-throw-literal */ + +/** +@typedef {import('highlight.js').CompiledMode} CompiledMode +*/ + +const MultiClassError = new Error(); + +/** + * Renumbers labeled scope names to account for additional inner match + * groups that otherwise would break everything. + * + * Lets say we 3 match scopes: + * + * { 1 => ..., 2 => ..., 3 => ... } + * + * So what we need is a clean match like this: + * + * (a)(b)(c) => [ "a", "b", "c" ] + * + * But this falls apart with inner match groups: + * + * (a)(((b)))(c) => ["a", "b", "b", "b", "c" ] + * + * Our scopes are now "out of alignment" and we're repeating `b` 3 times. + * What needs to happen is the numbers are remapped: + * + * { 1 => ..., 2 => ..., 5 => ... } + * + * We also need to know that the ONLY groups that should be output + * are 1, 2, and 5. This function handles this behavior. + * + * @param {CompiledMode} mode + * @param {Array} regexes + * @param {{key: "beginScope"|"endScope"}} opts + */ +function remapScopeNames(mode, regexes, { key }) { + let offset = 0; + const scopeNames = mode[key]; + /** @type Record */ + const emit = {}; + /** @type Record */ + const positions = {}; + + for (let i = 1; i <= regexes.length; i++) { + positions[i + offset] = scopeNames[i]; + emit[i + offset] = true; + offset += countMatchGroups(regexes[i - 1]); + } + // we use _emit to keep track of which match groups are "top-level" to avoid double + // output from inside match groups + mode[key] = positions; + mode[key]._emit = emit; + mode[key]._multi = true; +} + +/** + * @param {CompiledMode} mode + */ +function beginMultiClass(mode) { + if (!Array.isArray(mode.begin)) return; + + if (mode.skip || mode.excludeBegin || mode.returnBegin) { + error("skip, excludeBegin, returnBegin not compatible with beginScope: {}"); + throw MultiClassError; + } + + if (typeof mode.beginScope !== "object" || mode.beginScope === null) { + error("beginScope must be object"); + throw MultiClassError; + } + + remapScopeNames(mode, mode.begin, { key: "beginScope" }); + mode.begin = _rewriteBackreferences(mode.begin, { joinWith: "" }); +} + +/** + * @param {CompiledMode} mode + */ +function endMultiClass(mode) { + if (!Array.isArray(mode.end)) return; + + if (mode.skip || mode.excludeEnd || mode.returnEnd) { + error("skip, excludeEnd, returnEnd not compatible with endScope: {}"); + throw MultiClassError; + } + + if (typeof mode.endScope !== "object" || mode.endScope === null) { + error("endScope must be object"); + throw MultiClassError; + } + + remapScopeNames(mode, mode.end, { key: "endScope" }); + mode.end = _rewriteBackreferences(mode.end, { joinWith: "" }); +} + +/** + * this exists only to allow `scope: {}` to be used beside `match:` + * Otherwise `beginScope` would necessary and that would look weird + + { + match: [ /def/, /\w+/ ] + scope: { 1: "keyword" , 2: "title" } + } + + * @param {CompiledMode} mode + */ +function scopeSugar(mode) { + if (mode.scope && typeof mode.scope === "object" && mode.scope !== null) { + mode.beginScope = mode.scope; + delete mode.scope; + } +} + +/** + * @param {CompiledMode} mode + */ +function MultiClass(mode) { + scopeSugar(mode); + + if (typeof mode.beginScope === "string") { + mode.beginScope = { _wrap: mode.beginScope }; + } + if (typeof mode.endScope === "string") { + mode.endScope = { _wrap: mode.endScope }; + } + + beginMultiClass(mode); + endMultiClass(mode); +} + +/** +@typedef {import('highlight.js').Mode} Mode +@typedef {import('highlight.js').CompiledMode} CompiledMode +@typedef {import('highlight.js').Language} Language +@typedef {import('highlight.js').HLJSPlugin} HLJSPlugin +@typedef {import('highlight.js').CompiledLanguage} CompiledLanguage +*/ + +// compilation + +/** + * Compiles a language definition result + * + * Given the raw result of a language definition (Language), compiles this so + * that it is ready for highlighting code. + * @param {Language} language + * @returns {CompiledLanguage} + */ +function compileLanguage(language) { + /** + * Builds a regex with the case sensitivity of the current language + * + * @param {RegExp | string} value + * @param {boolean} [global] + */ + function langRe(value, global) { + return new RegExp( + source(value), + 'm' + + (language.case_insensitive ? 'i' : '') + + (language.unicodeRegex ? 'u' : '') + + (global ? 'g' : '') + ); + } + + /** + Stores multiple regular expressions and allows you to quickly search for + them all in a string simultaneously - returning the first match. It does + this by creating a huge (a|b|c) regex - each individual item wrapped with () + and joined by `|` - using match groups to track position. When a match is + found checking which position in the array has content allows us to figure + out which of the original regexes / match groups triggered the match. + + The match object itself (the result of `Regex.exec`) is returned but also + enhanced by merging in any meta-data that was registered with the regex. + This is how we keep track of which mode matched, and what type of rule + (`illegal`, `begin`, end, etc). + */ + class MultiRegex { + constructor() { + this.matchIndexes = {}; + // @ts-ignore + this.regexes = []; + this.matchAt = 1; + this.position = 0; + } + + // @ts-ignore + addRule(re, opts) { + opts.position = this.position++; + // @ts-ignore + this.matchIndexes[this.matchAt] = opts; + this.regexes.push([opts, re]); + this.matchAt += countMatchGroups(re) + 1; + } + + compile() { + if (this.regexes.length === 0) { + // avoids the need to check length every time exec is called + // @ts-ignore + this.exec = () => null; + } + const terminators = this.regexes.map(el => el[1]); + this.matcherRe = langRe(_rewriteBackreferences(terminators, { joinWith: '|' }), true); + this.lastIndex = 0; + } + + /** @param {string} s */ + exec(s) { + this.matcherRe.lastIndex = this.lastIndex; + const match = this.matcherRe.exec(s); + if (!match) { return null; } + + // eslint-disable-next-line no-undefined + const i = match.findIndex((el, i) => i > 0 && el !== undefined); + // @ts-ignore + const matchData = this.matchIndexes[i]; + // trim off any earlier non-relevant match groups (ie, the other regex + // match groups that make up the multi-matcher) + match.splice(0, i); + + return Object.assign(match, matchData); + } + } + + /* + Created to solve the key deficiently with MultiRegex - there is no way to + test for multiple matches at a single location. Why would we need to do + that? In the future a more dynamic engine will allow certain matches to be + ignored. An example: if we matched say the 3rd regex in a large group but + decided to ignore it - we'd need to started testing again at the 4th + regex... but MultiRegex itself gives us no real way to do that. + + So what this class creates MultiRegexs on the fly for whatever search + position they are needed. + + NOTE: These additional MultiRegex objects are created dynamically. For most + grammars most of the time we will never actually need anything more than the + first MultiRegex - so this shouldn't have too much overhead. + + Say this is our search group, and we match regex3, but wish to ignore it. + + regex1 | regex2 | regex3 | regex4 | regex5 ' ie, startAt = 0 + + What we need is a new MultiRegex that only includes the remaining + possibilities: + + regex4 | regex5 ' ie, startAt = 3 + + This class wraps all that complexity up in a simple API... `startAt` decides + where in the array of expressions to start doing the matching. It + auto-increments, so if a match is found at position 2, then startAt will be + set to 3. If the end is reached startAt will return to 0. + + MOST of the time the parser will be setting startAt manually to 0. + */ + class ResumableMultiRegex { + constructor() { + // @ts-ignore + this.rules = []; + // @ts-ignore + this.multiRegexes = []; + this.count = 0; + + this.lastIndex = 0; + this.regexIndex = 0; + } + + // @ts-ignore + getMatcher(index) { + if (this.multiRegexes[index]) return this.multiRegexes[index]; + + const matcher = new MultiRegex(); + this.rules.slice(index).forEach(([re, opts]) => matcher.addRule(re, opts)); + matcher.compile(); + this.multiRegexes[index] = matcher; + return matcher; + } + + resumingScanAtSamePosition() { + return this.regexIndex !== 0; + } + + considerAll() { + this.regexIndex = 0; + } + + // @ts-ignore + addRule(re, opts) { + this.rules.push([re, opts]); + if (opts.type === "begin") this.count++; + } + + /** @param {string} s */ + exec(s) { + const m = this.getMatcher(this.regexIndex); + m.lastIndex = this.lastIndex; + let result = m.exec(s); + + // The following is because we have no easy way to say "resume scanning at the + // existing position but also skip the current rule ONLY". What happens is + // all prior rules are also skipped which can result in matching the wrong + // thing. Example of matching "booger": + + // our matcher is [string, "booger", number] + // + // ....booger.... + + // if "booger" is ignored then we'd really need a regex to scan from the + // SAME position for only: [string, number] but ignoring "booger" (if it + // was the first match), a simple resume would scan ahead who knows how + // far looking only for "number", ignoring potential string matches (or + // future "booger" matches that might be valid.) + + // So what we do: We execute two matchers, one resuming at the same + // position, but the second full matcher starting at the position after: + + // /--- resume first regex match here (for [number]) + // |/---- full match here for [string, "booger", number] + // vv + // ....booger.... + + // Which ever results in a match first is then used. So this 3-4 step + // process essentially allows us to say "match at this position, excluding + // a prior rule that was ignored". + // + // 1. Match "booger" first, ignore. Also proves that [string] does non match. + // 2. Resume matching for [number] + // 3. Match at index + 1 for [string, "booger", number] + // 4. If #2 and #3 result in matches, which came first? + if (this.resumingScanAtSamePosition()) { + if (result && result.index === this.lastIndex) ; else { // use the second matcher result + const m2 = this.getMatcher(0); + m2.lastIndex = this.lastIndex + 1; + result = m2.exec(s); + } + } + + if (result) { + this.regexIndex += result.position + 1; + if (this.regexIndex === this.count) { + // wrap-around to considering all matches again + this.considerAll(); + } + } + + return result; + } + } + + /** + * Given a mode, builds a huge ResumableMultiRegex that can be used to walk + * the content and find matches. + * + * @param {CompiledMode} mode + * @returns {ResumableMultiRegex} + */ + function buildModeRegex(mode) { + const mm = new ResumableMultiRegex(); + + mode.contains.forEach(term => mm.addRule(term.begin, { rule: term, type: "begin" })); + + if (mode.terminatorEnd) { + mm.addRule(mode.terminatorEnd, { type: "end" }); + } + if (mode.illegal) { + mm.addRule(mode.illegal, { type: "illegal" }); + } + + return mm; + } + + /** skip vs abort vs ignore + * + * @skip - The mode is still entered and exited normally (and contains rules apply), + * but all content is held and added to the parent buffer rather than being + * output when the mode ends. Mostly used with `sublanguage` to build up + * a single large buffer than can be parsed by sublanguage. + * + * - The mode begin ands ends normally. + * - Content matched is added to the parent mode buffer. + * - The parser cursor is moved forward normally. + * + * @abort - A hack placeholder until we have ignore. Aborts the mode (as if it + * never matched) but DOES NOT continue to match subsequent `contains` + * modes. Abort is bad/suboptimal because it can result in modes + * farther down not getting applied because an earlier rule eats the + * content but then aborts. + * + * - The mode does not begin. + * - Content matched by `begin` is added to the mode buffer. + * - The parser cursor is moved forward accordingly. + * + * @ignore - Ignores the mode (as if it never matched) and continues to match any + * subsequent `contains` modes. Ignore isn't technically possible with + * the current parser implementation. + * + * - The mode does not begin. + * - Content matched by `begin` is ignored. + * - The parser cursor is not moved forward. + */ + + /** + * Compiles an individual mode + * + * This can raise an error if the mode contains certain detectable known logic + * issues. + * @param {Mode} mode + * @param {CompiledMode | null} [parent] + * @returns {CompiledMode | never} + */ + function compileMode(mode, parent) { + const cmode = /** @type CompiledMode */ (mode); + if (mode.isCompiled) return cmode; + + [ + scopeClassName, + // do this early so compiler extensions generally don't have to worry about + // the distinction between match/begin + compileMatch, + MultiClass, + beforeMatchExt + ].forEach(ext => ext(mode, parent)); + + language.compilerExtensions.forEach(ext => ext(mode, parent)); + + // __beforeBegin is considered private API, internal use only + mode.__beforeBegin = null; + + [ + beginKeywords, + // do this later so compiler extensions that come earlier have access to the + // raw array if they wanted to perhaps manipulate it, etc. + compileIllegal, + // default to 1 relevance if not specified + compileRelevance + ].forEach(ext => ext(mode, parent)); + + mode.isCompiled = true; + + let keywordPattern = null; + if (typeof mode.keywords === "object" && mode.keywords.$pattern) { + // we need a copy because keywords might be compiled multiple times + // so we can't go deleting $pattern from the original on the first + // pass + mode.keywords = Object.assign({}, mode.keywords); + keywordPattern = mode.keywords.$pattern; + delete mode.keywords.$pattern; + } + keywordPattern = keywordPattern || /\w+/; + + if (mode.keywords) { + mode.keywords = compileKeywords(mode.keywords, language.case_insensitive); + } + + cmode.keywordPatternRe = langRe(keywordPattern, true); + + if (parent) { + if (!mode.begin) mode.begin = /\B|\b/; + cmode.beginRe = langRe(cmode.begin); + if (!mode.end && !mode.endsWithParent) mode.end = /\B|\b/; + if (mode.end) cmode.endRe = langRe(cmode.end); + cmode.terminatorEnd = source(cmode.end) || ''; + if (mode.endsWithParent && parent.terminatorEnd) { + cmode.terminatorEnd += (mode.end ? '|' : '') + parent.terminatorEnd; + } + } + if (mode.illegal) cmode.illegalRe = langRe(/** @type {RegExp | string} */ (mode.illegal)); + if (!mode.contains) mode.contains = []; + + mode.contains = [].concat(...mode.contains.map(function(c) { + return expandOrCloneMode(c === 'self' ? mode : c); + })); + mode.contains.forEach(function(c) { compileMode(/** @type Mode */ (c), cmode); }); + + if (mode.starts) { + compileMode(mode.starts, parent); + } + + cmode.matcher = buildModeRegex(cmode); + return cmode; + } + + if (!language.compilerExtensions) language.compilerExtensions = []; + + // self is not valid at the top-level + if (language.contains && language.contains.includes('self')) { + throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation."); + } + + // we need a null object, which inherit will guarantee + language.classNameAliases = inherit$1(language.classNameAliases || {}); + + return compileMode(/** @type Mode */ (language)); +} + +/** + * Determines if a mode has a dependency on it's parent or not + * + * If a mode does have a parent dependency then often we need to clone it if + * it's used in multiple places so that each copy points to the correct parent, + * where-as modes without a parent can often safely be re-used at the bottom of + * a mode chain. + * + * @param {Mode | null} mode + * @returns {boolean} - is there a dependency on the parent? + * */ +function dependencyOnParent(mode) { + if (!mode) return false; + + return mode.endsWithParent || dependencyOnParent(mode.starts); +} + +/** + * Expands a mode or clones it if necessary + * + * This is necessary for modes with parental dependenceis (see notes on + * `dependencyOnParent`) and for nodes that have `variants` - which must then be + * exploded into their own individual modes at compile time. + * + * @param {Mode} mode + * @returns {Mode | Mode[]} + * */ +function expandOrCloneMode(mode) { + if (mode.variants && !mode.cachedVariants) { + mode.cachedVariants = mode.variants.map(function(variant) { + return inherit$1(mode, { variants: null }, variant); + }); + } + + // EXPAND + // if we have variants then essentially "replace" the mode with the variants + // this happens in compileMode, where this function is called from + if (mode.cachedVariants) { + return mode.cachedVariants; + } + + // CLONE + // if we have dependencies on parents then we need a unique + // instance of ourselves, so we can be reused with many + // different parents without issue + if (dependencyOnParent(mode)) { + return inherit$1(mode, { starts: mode.starts ? inherit$1(mode.starts) : null }); + } + + if (Object.isFrozen(mode)) { + return inherit$1(mode); + } + + // no special dependency issues, just return ourselves + return mode; +} + +var version = "11.10.0"; + +class HTMLInjectionError extends Error { + constructor(reason, html) { + super(reason); + this.name = "HTMLInjectionError"; + this.html = html; + } +} + +/* +Syntax highlighting with language autodetection. +https://highlightjs.org/ +*/ + + + +/** +@typedef {import('highlight.js').Mode} Mode +@typedef {import('highlight.js').CompiledMode} CompiledMode +@typedef {import('highlight.js').CompiledScope} CompiledScope +@typedef {import('highlight.js').Language} Language +@typedef {import('highlight.js').HLJSApi} HLJSApi +@typedef {import('highlight.js').HLJSPlugin} HLJSPlugin +@typedef {import('highlight.js').PluginEvent} PluginEvent +@typedef {import('highlight.js').HLJSOptions} HLJSOptions +@typedef {import('highlight.js').LanguageFn} LanguageFn +@typedef {import('highlight.js').HighlightedHTMLElement} HighlightedHTMLElement +@typedef {import('highlight.js').BeforeHighlightContext} BeforeHighlightContext +@typedef {import('highlight.js/private').MatchType} MatchType +@typedef {import('highlight.js/private').KeywordData} KeywordData +@typedef {import('highlight.js/private').EnhancedMatch} EnhancedMatch +@typedef {import('highlight.js/private').AnnotatedError} AnnotatedError +@typedef {import('highlight.js').AutoHighlightResult} AutoHighlightResult +@typedef {import('highlight.js').HighlightOptions} HighlightOptions +@typedef {import('highlight.js').HighlightResult} HighlightResult +*/ + + +const escape = escapeHTML; +const inherit = inherit$1; +const NO_MATCH = Symbol("nomatch"); +const MAX_KEYWORD_HITS = 7; + +/** + * @param {any} hljs - object that is extended (legacy) + * @returns {HLJSApi} + */ +const HLJS = function(hljs) { + // Global internal variables used within the highlight.js library. + /** @type {Record} */ + const languages = Object.create(null); + /** @type {Record} */ + const aliases = Object.create(null); + /** @type {HLJSPlugin[]} */ + const plugins = []; + + // safe/production mode - swallows more errors, tries to keep running + // even if a single syntax or parse hits a fatal error + let SAFE_MODE = true; + const LANGUAGE_NOT_FOUND = "Could not find the language '{}', did you forget to load/include a language module?"; + /** @type {Language} */ + const PLAINTEXT_LANGUAGE = { disableAutodetect: true, name: 'Plain text', contains: [] }; + + // Global options used when within external APIs. This is modified when + // calling the `hljs.configure` function. + /** @type HLJSOptions */ + let options = { + ignoreUnescapedHTML: false, + throwUnescapedHTML: false, + noHighlightRe: /^(no-?highlight)$/i, + languageDetectRe: /\blang(?:uage)?-([\w-]+)\b/i, + classPrefix: 'hljs-', + cssSelector: 'pre code', + languages: null, + // beta configuration options, subject to change, welcome to discuss + // https://github.com/highlightjs/highlight.js/issues/1086 + __emitter: TokenTreeEmitter + }; + + /* Utility functions */ + + /** + * Tests a language name to see if highlighting should be skipped + * @param {string} languageName + */ + function shouldNotHighlight(languageName) { + return options.noHighlightRe.test(languageName); + } + + /** + * @param {HighlightedHTMLElement} block - the HTML element to determine language for + */ + function blockLanguage(block) { + let classes = block.className + ' '; + + classes += block.parentNode ? block.parentNode.className : ''; + + // language-* takes precedence over non-prefixed class names. + const match = options.languageDetectRe.exec(classes); + if (match) { + const language = getLanguage(match[1]); + if (!language) { + warn(LANGUAGE_NOT_FOUND.replace("{}", match[1])); + warn("Falling back to no-highlight mode for this block.", block); + } + return language ? match[1] : 'no-highlight'; + } + + return classes + .split(/\s+/) + .find((_class) => shouldNotHighlight(_class) || getLanguage(_class)); + } + + /** + * Core highlighting function. + * + * OLD API + * highlight(lang, code, ignoreIllegals, continuation) + * + * NEW API + * highlight(code, {lang, ignoreIllegals}) + * + * @param {string} codeOrLanguageName - the language to use for highlighting + * @param {string | HighlightOptions} optionsOrCode - the code to highlight + * @param {boolean} [ignoreIllegals] - whether to ignore illegal matches, default is to bail + * + * @returns {HighlightResult} Result - an object that represents the result + * @property {string} language - the language name + * @property {number} relevance - the relevance score + * @property {string} value - the highlighted HTML code + * @property {string} code - the original raw code + * @property {CompiledMode} top - top of the current mode stack + * @property {boolean} illegal - indicates whether any illegal matches were found + */ + function highlight(codeOrLanguageName, optionsOrCode, ignoreIllegals) { + let code = ""; + let languageName = ""; + if (typeof optionsOrCode === "object") { + code = codeOrLanguageName; + ignoreIllegals = optionsOrCode.ignoreIllegals; + languageName = optionsOrCode.language; + } else { + // old API + deprecated("10.7.0", "highlight(lang, code, ...args) has been deprecated."); + deprecated("10.7.0", "Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"); + languageName = codeOrLanguageName; + code = optionsOrCode; + } + + // https://github.com/highlightjs/highlight.js/issues/3149 + // eslint-disable-next-line no-undefined + if (ignoreIllegals === undefined) { ignoreIllegals = true; } + + /** @type {BeforeHighlightContext} */ + const context = { + code, + language: languageName + }; + // the plugin can change the desired language or the code to be highlighted + // just be changing the object it was passed + fire("before:highlight", context); + + // a before plugin can usurp the result completely by providing it's own + // in which case we don't even need to call highlight + const result = context.result + ? context.result + : _highlight(context.language, context.code, ignoreIllegals); + + result.code = context.code; + // the plugin can change anything in result to suite it + fire("after:highlight", result); + + return result; + } + + /** + * private highlight that's used internally and does not fire callbacks + * + * @param {string} languageName - the language to use for highlighting + * @param {string} codeToHighlight - the code to highlight + * @param {boolean?} [ignoreIllegals] - whether to ignore illegal matches, default is to bail + * @param {CompiledMode?} [continuation] - current continuation mode, if any + * @returns {HighlightResult} - result of the highlight operation + */ + function _highlight(languageName, codeToHighlight, ignoreIllegals, continuation) { + const keywordHits = Object.create(null); + + /** + * Return keyword data if a match is a keyword + * @param {CompiledMode} mode - current mode + * @param {string} matchText - the textual match + * @returns {KeywordData | false} + */ + function keywordData(mode, matchText) { + return mode.keywords[matchText]; + } + + function processKeywords() { + if (!top.keywords) { + emitter.addText(modeBuffer); + return; + } + + let lastIndex = 0; + top.keywordPatternRe.lastIndex = 0; + let match = top.keywordPatternRe.exec(modeBuffer); + let buf = ""; + + while (match) { + buf += modeBuffer.substring(lastIndex, match.index); + const word = language.case_insensitive ? match[0].toLowerCase() : match[0]; + const data = keywordData(top, word); + if (data) { + const [kind, keywordRelevance] = data; + emitter.addText(buf); + buf = ""; + + keywordHits[word] = (keywordHits[word] || 0) + 1; + if (keywordHits[word] <= MAX_KEYWORD_HITS) relevance += keywordRelevance; + if (kind.startsWith("_")) { + // _ implied for relevance only, do not highlight + // by applying a class name + buf += match[0]; + } else { + const cssClass = language.classNameAliases[kind] || kind; + emitKeyword(match[0], cssClass); + } + } else { + buf += match[0]; + } + lastIndex = top.keywordPatternRe.lastIndex; + match = top.keywordPatternRe.exec(modeBuffer); + } + buf += modeBuffer.substring(lastIndex); + emitter.addText(buf); + } + + function processSubLanguage() { + if (modeBuffer === "") return; + /** @type HighlightResult */ + let result = null; + + if (typeof top.subLanguage === 'string') { + if (!languages[top.subLanguage]) { + emitter.addText(modeBuffer); + return; + } + result = _highlight(top.subLanguage, modeBuffer, true, continuations[top.subLanguage]); + continuations[top.subLanguage] = /** @type {CompiledMode} */ (result._top); + } else { + result = highlightAuto(modeBuffer, top.subLanguage.length ? top.subLanguage : null); + } + + // Counting embedded language score towards the host language may be disabled + // with zeroing the containing mode relevance. Use case in point is Markdown that + // allows XML everywhere and makes every XML snippet to have a much larger Markdown + // score. + if (top.relevance > 0) { + relevance += result.relevance; + } + emitter.__addSublanguage(result._emitter, result.language); + } + + function processBuffer() { + if (top.subLanguage != null) { + processSubLanguage(); + } else { + processKeywords(); + } + modeBuffer = ''; + } + + /** + * @param {string} text + * @param {string} scope + */ + function emitKeyword(keyword, scope) { + if (keyword === "") return; + + emitter.startScope(scope); + emitter.addText(keyword); + emitter.endScope(); + } + + /** + * @param {CompiledScope} scope + * @param {RegExpMatchArray} match + */ + function emitMultiClass(scope, match) { + let i = 1; + const max = match.length - 1; + while (i <= max) { + if (!scope._emit[i]) { i++; continue; } + const klass = language.classNameAliases[scope[i]] || scope[i]; + const text = match[i]; + if (klass) { + emitKeyword(text, klass); + } else { + modeBuffer = text; + processKeywords(); + modeBuffer = ""; + } + i++; + } + } + + /** + * @param {CompiledMode} mode - new mode to start + * @param {RegExpMatchArray} match + */ + function startNewMode(mode, match) { + if (mode.scope && typeof mode.scope === "string") { + emitter.openNode(language.classNameAliases[mode.scope] || mode.scope); + } + if (mode.beginScope) { + // beginScope just wraps the begin match itself in a scope + if (mode.beginScope._wrap) { + emitKeyword(modeBuffer, language.classNameAliases[mode.beginScope._wrap] || mode.beginScope._wrap); + modeBuffer = ""; + } else if (mode.beginScope._multi) { + // at this point modeBuffer should just be the match + emitMultiClass(mode.beginScope, match); + modeBuffer = ""; + } + } + + top = Object.create(mode, { parent: { value: top } }); + return top; + } + + /** + * @param {CompiledMode } mode - the mode to potentially end + * @param {RegExpMatchArray} match - the latest match + * @param {string} matchPlusRemainder - match plus remainder of content + * @returns {CompiledMode | void} - the next mode, or if void continue on in current mode + */ + function endOfMode(mode, match, matchPlusRemainder) { + let matched = startsWith(mode.endRe, matchPlusRemainder); + + if (matched) { + if (mode["on:end"]) { + const resp = new Response(mode); + mode["on:end"](match, resp); + if (resp.isMatchIgnored) matched = false; + } + + if (matched) { + while (mode.endsParent && mode.parent) { + mode = mode.parent; + } + return mode; + } + } + // even if on:end fires an `ignore` it's still possible + // that we might trigger the end node because of a parent mode + if (mode.endsWithParent) { + return endOfMode(mode.parent, match, matchPlusRemainder); + } + } + + /** + * Handle matching but then ignoring a sequence of text + * + * @param {string} lexeme - string containing full match text + */ + function doIgnore(lexeme) { + if (top.matcher.regexIndex === 0) { + // no more regexes to potentially match here, so we move the cursor forward one + // space + modeBuffer += lexeme[0]; + return 1; + } else { + // no need to move the cursor, we still have additional regexes to try and + // match at this very spot + resumeScanAtSamePosition = true; + return 0; + } + } + + /** + * Handle the start of a new potential mode match + * + * @param {EnhancedMatch} match - the current match + * @returns {number} how far to advance the parse cursor + */ + function doBeginMatch(match) { + const lexeme = match[0]; + const newMode = match.rule; + + const resp = new Response(newMode); + // first internal before callbacks, then the public ones + const beforeCallbacks = [newMode.__beforeBegin, newMode["on:begin"]]; + for (const cb of beforeCallbacks) { + if (!cb) continue; + cb(match, resp); + if (resp.isMatchIgnored) return doIgnore(lexeme); + } + + if (newMode.skip) { + modeBuffer += lexeme; + } else { + if (newMode.excludeBegin) { + modeBuffer += lexeme; + } + processBuffer(); + if (!newMode.returnBegin && !newMode.excludeBegin) { + modeBuffer = lexeme; + } + } + startNewMode(newMode, match); + return newMode.returnBegin ? 0 : lexeme.length; + } + + /** + * Handle the potential end of mode + * + * @param {RegExpMatchArray} match - the current match + */ + function doEndMatch(match) { + const lexeme = match[0]; + const matchPlusRemainder = codeToHighlight.substring(match.index); + + const endMode = endOfMode(top, match, matchPlusRemainder); + if (!endMode) { return NO_MATCH; } + + const origin = top; + if (top.endScope && top.endScope._wrap) { + processBuffer(); + emitKeyword(lexeme, top.endScope._wrap); + } else if (top.endScope && top.endScope._multi) { + processBuffer(); + emitMultiClass(top.endScope, match); + } else if (origin.skip) { + modeBuffer += lexeme; + } else { + if (!(origin.returnEnd || origin.excludeEnd)) { + modeBuffer += lexeme; + } + processBuffer(); + if (origin.excludeEnd) { + modeBuffer = lexeme; + } + } + do { + if (top.scope) { + emitter.closeNode(); + } + if (!top.skip && !top.subLanguage) { + relevance += top.relevance; + } + top = top.parent; + } while (top !== endMode.parent); + if (endMode.starts) { + startNewMode(endMode.starts, match); + } + return origin.returnEnd ? 0 : lexeme.length; + } + + function processContinuations() { + const list = []; + for (let current = top; current !== language; current = current.parent) { + if (current.scope) { + list.unshift(current.scope); + } + } + list.forEach(item => emitter.openNode(item)); + } + + /** @type {{type?: MatchType, index?: number, rule?: Mode}}} */ + let lastMatch = {}; + + /** + * Process an individual match + * + * @param {string} textBeforeMatch - text preceding the match (since the last match) + * @param {EnhancedMatch} [match] - the match itself + */ + function processLexeme(textBeforeMatch, match) { + const lexeme = match && match[0]; + + // add non-matched text to the current mode buffer + modeBuffer += textBeforeMatch; + + if (lexeme == null) { + processBuffer(); + return 0; + } + + // we've found a 0 width match and we're stuck, so we need to advance + // this happens when we have badly behaved rules that have optional matchers to the degree that + // sometimes they can end up matching nothing at all + // Ref: https://github.com/highlightjs/highlight.js/issues/2140 + if (lastMatch.type === "begin" && match.type === "end" && lastMatch.index === match.index && lexeme === "") { + // spit the "skipped" character that our regex choked on back into the output sequence + modeBuffer += codeToHighlight.slice(match.index, match.index + 1); + if (!SAFE_MODE) { + /** @type {AnnotatedError} */ + const err = new Error(`0 width match regex (${languageName})`); + err.languageName = languageName; + err.badRule = lastMatch.rule; + throw err; + } + return 1; + } + lastMatch = match; + + if (match.type === "begin") { + return doBeginMatch(match); + } else if (match.type === "illegal" && !ignoreIllegals) { + // illegal match, we do not continue processing + /** @type {AnnotatedError} */ + const err = new Error('Illegal lexeme "' + lexeme + '" for mode "' + (top.scope || '') + '"'); + err.mode = top; + throw err; + } else if (match.type === "end") { + const processed = doEndMatch(match); + if (processed !== NO_MATCH) { + return processed; + } + } + + // edge case for when illegal matches $ (end of line) which is technically + // a 0 width match but not a begin/end match so it's not caught by the + // first handler (when ignoreIllegals is true) + if (match.type === "illegal" && lexeme === "") { + // advance so we aren't stuck in an infinite loop + return 1; + } + + // infinite loops are BAD, this is a last ditch catch all. if we have a + // decent number of iterations yet our index (cursor position in our + // parsing) still 3x behind our index then something is very wrong + // so we bail + if (iterations > 100000 && iterations > match.index * 3) { + const err = new Error('potential infinite loop, way more iterations than matches'); + throw err; + } + + /* + Why might be find ourselves here? An potential end match that was + triggered but could not be completed. IE, `doEndMatch` returned NO_MATCH. + (this could be because a callback requests the match be ignored, etc) + + This causes no real harm other than stopping a few times too many. + */ + + modeBuffer += lexeme; + return lexeme.length; + } + + const language = getLanguage(languageName); + if (!language) { + error(LANGUAGE_NOT_FOUND.replace("{}", languageName)); + throw new Error('Unknown language: "' + languageName + '"'); + } + + const md = compileLanguage(language); + let result = ''; + /** @type {CompiledMode} */ + let top = continuation || md; + /** @type Record */ + const continuations = {}; // keep continuations for sub-languages + const emitter = new options.__emitter(options); + processContinuations(); + let modeBuffer = ''; + let relevance = 0; + let index = 0; + let iterations = 0; + let resumeScanAtSamePosition = false; + + try { + if (!language.__emitTokens) { + top.matcher.considerAll(); + + for (;;) { + iterations++; + if (resumeScanAtSamePosition) { + // only regexes not matched previously will now be + // considered for a potential match + resumeScanAtSamePosition = false; + } else { + top.matcher.considerAll(); + } + top.matcher.lastIndex = index; + + const match = top.matcher.exec(codeToHighlight); + // console.log("match", match[0], match.rule && match.rule.begin) + + if (!match) break; + + const beforeMatch = codeToHighlight.substring(index, match.index); + const processedCount = processLexeme(beforeMatch, match); + index = match.index + processedCount; + } + processLexeme(codeToHighlight.substring(index)); + } else { + language.__emitTokens(codeToHighlight, emitter); + } + + emitter.finalize(); + result = emitter.toHTML(); + + return { + language: languageName, + value: result, + relevance, + illegal: false, + _emitter: emitter, + _top: top + }; + } catch (err) { + if (err.message && err.message.includes('Illegal')) { + return { + language: languageName, + value: escape(codeToHighlight), + illegal: true, + relevance: 0, + _illegalBy: { + message: err.message, + index, + context: codeToHighlight.slice(index - 100, index + 100), + mode: err.mode, + resultSoFar: result + }, + _emitter: emitter + }; + } else if (SAFE_MODE) { + return { + language: languageName, + value: escape(codeToHighlight), + illegal: false, + relevance: 0, + errorRaised: err, + _emitter: emitter, + _top: top + }; + } else { + throw err; + } + } + } + + /** + * returns a valid highlight result, without actually doing any actual work, + * auto highlight starts with this and it's possible for small snippets that + * auto-detection may not find a better match + * @param {string} code + * @returns {HighlightResult} + */ + function justTextHighlightResult(code) { + const result = { + value: escape(code), + illegal: false, + relevance: 0, + _top: PLAINTEXT_LANGUAGE, + _emitter: new options.__emitter(options) + }; + result._emitter.addText(code); + return result; + } + + /** + Highlighting with language detection. Accepts a string with the code to + highlight. Returns an object with the following properties: + + - language (detected language) + - relevance (int) + - value (an HTML string with highlighting markup) + - secondBest (object with the same structure for second-best heuristically + detected language, may be absent) + + @param {string} code + @param {Array} [languageSubset] + @returns {AutoHighlightResult} + */ + function highlightAuto(code, languageSubset) { + languageSubset = languageSubset || options.languages || Object.keys(languages); + const plaintext = justTextHighlightResult(code); + + const results = languageSubset.filter(getLanguage).filter(autoDetection).map(name => + _highlight(name, code, false) + ); + results.unshift(plaintext); // plaintext is always an option + + const sorted = results.sort((a, b) => { + // sort base on relevance + if (a.relevance !== b.relevance) return b.relevance - a.relevance; + + // always award the tie to the base language + // ie if C++ and Arduino are tied, it's more likely to be C++ + if (a.language && b.language) { + if (getLanguage(a.language).supersetOf === b.language) { + return 1; + } else if (getLanguage(b.language).supersetOf === a.language) { + return -1; + } + } + + // otherwise say they are equal, which has the effect of sorting on + // relevance while preserving the original ordering - which is how ties + // have historically been settled, ie the language that comes first always + // wins in the case of a tie + return 0; + }); + + const [best, secondBest] = sorted; + + /** @type {AutoHighlightResult} */ + const result = best; + result.secondBest = secondBest; + + return result; + } + + /** + * Builds new class name for block given the language name + * + * @param {HTMLElement} element + * @param {string} [currentLang] + * @param {string} [resultLang] + */ + function updateClassName(element, currentLang, resultLang) { + const language = (currentLang && aliases[currentLang]) || resultLang; + + element.classList.add("hljs"); + element.classList.add(`language-${language}`); + } + + /** + * Applies highlighting to a DOM node containing code. + * + * @param {HighlightedHTMLElement} element - the HTML element to highlight + */ + function highlightElement(element) { + /** @type HTMLElement */ + let node = null; + const language = blockLanguage(element); + + if (shouldNotHighlight(language)) return; + + fire("before:highlightElement", + { el: element, language }); + + if (element.dataset.highlighted) { + console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.", element); + return; + } + + // we should be all text, no child nodes (unescaped HTML) - this is possibly + // an HTML injection attack - it's likely too late if this is already in + // production (the code has likely already done its damage by the time + // we're seeing it)... but we yell loudly about this so that hopefully it's + // more likely to be caught in development before making it to production + if (element.children.length > 0) { + if (!options.ignoreUnescapedHTML) { + console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."); + console.warn("https://github.com/highlightjs/highlight.js/wiki/security"); + console.warn("The element with unescaped HTML:"); + console.warn(element); + } + if (options.throwUnescapedHTML) { + const err = new HTMLInjectionError( + "One of your code blocks includes unescaped HTML.", + element.innerHTML + ); + throw err; + } + } + + node = element; + const text = node.textContent; + const result = language ? highlight(text, { language, ignoreIllegals: true }) : highlightAuto(text); + + element.innerHTML = result.value; + element.dataset.highlighted = "yes"; + updateClassName(element, language, result.language); + element.result = { + language: result.language, + // TODO: remove with version 11.0 + re: result.relevance, + relevance: result.relevance + }; + if (result.secondBest) { + element.secondBest = { + language: result.secondBest.language, + relevance: result.secondBest.relevance + }; + } + + fire("after:highlightElement", { el: element, result, text }); + } + + /** + * Updates highlight.js global options with the passed options + * + * @param {Partial} userOptions + */ + function configure(userOptions) { + options = inherit(options, userOptions); + } + + // TODO: remove v12, deprecated + const initHighlighting = () => { + highlightAll(); + deprecated("10.6.0", "initHighlighting() deprecated. Use highlightAll() now."); + }; + + // TODO: remove v12, deprecated + function initHighlightingOnLoad() { + highlightAll(); + deprecated("10.6.0", "initHighlightingOnLoad() deprecated. Use highlightAll() now."); + } + + let wantsHighlight = false; + + /** + * auto-highlights all pre>code elements on the page + */ + function highlightAll() { + // if we are called too early in the loading process + if (document.readyState === "loading") { + wantsHighlight = true; + return; + } + + const blocks = document.querySelectorAll(options.cssSelector); + blocks.forEach(highlightElement); + } + + function boot() { + // if a highlight was requested before DOM was loaded, do now + if (wantsHighlight) highlightAll(); + } + + // make sure we are in the browser environment + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('DOMContentLoaded', boot, false); + } + + /** + * Register a language grammar module + * + * @param {string} languageName + * @param {LanguageFn} languageDefinition + */ + function registerLanguage(languageName, languageDefinition) { + let lang = null; + try { + lang = languageDefinition(hljs); + } catch (error$1) { + error("Language definition for '{}' could not be registered.".replace("{}", languageName)); + // hard or soft error + if (!SAFE_MODE) { throw error$1; } else { error(error$1); } + // languages that have serious errors are replaced with essentially a + // "plaintext" stand-in so that the code blocks will still get normal + // css classes applied to them - and one bad language won't break the + // entire highlighter + lang = PLAINTEXT_LANGUAGE; + } + // give it a temporary name if it doesn't have one in the meta-data + if (!lang.name) lang.name = languageName; + languages[languageName] = lang; + lang.rawDefinition = languageDefinition.bind(null, hljs); + + if (lang.aliases) { + registerAliases(lang.aliases, { languageName }); + } + } + + /** + * Remove a language grammar module + * + * @param {string} languageName + */ + function unregisterLanguage(languageName) { + delete languages[languageName]; + for (const alias of Object.keys(aliases)) { + if (aliases[alias] === languageName) { + delete aliases[alias]; + } + } + } + + /** + * @returns {string[]} List of language internal names + */ + function listLanguages() { + return Object.keys(languages); + } + + /** + * @param {string} name - name of the language to retrieve + * @returns {Language | undefined} + */ + function getLanguage(name) { + name = (name || '').toLowerCase(); + return languages[name] || languages[aliases[name]]; + } + + /** + * + * @param {string|string[]} aliasList - single alias or list of aliases + * @param {{languageName: string}} opts + */ + function registerAliases(aliasList, { languageName }) { + if (typeof aliasList === 'string') { + aliasList = [aliasList]; + } + aliasList.forEach(alias => { aliases[alias.toLowerCase()] = languageName; }); + } + + /** + * Determines if a given language has auto-detection enabled + * @param {string} name - name of the language + */ + function autoDetection(name) { + const lang = getLanguage(name); + return lang && !lang.disableAutodetect; + } + + /** + * Upgrades the old highlightBlock plugins to the new + * highlightElement API + * @param {HLJSPlugin} plugin + */ + function upgradePluginAPI(plugin) { + // TODO: remove with v12 + if (plugin["before:highlightBlock"] && !plugin["before:highlightElement"]) { + plugin["before:highlightElement"] = (data) => { + plugin["before:highlightBlock"]( + Object.assign({ block: data.el }, data) + ); + }; + } + if (plugin["after:highlightBlock"] && !plugin["after:highlightElement"]) { + plugin["after:highlightElement"] = (data) => { + plugin["after:highlightBlock"]( + Object.assign({ block: data.el }, data) + ); + }; + } + } + + /** + * @param {HLJSPlugin} plugin + */ + function addPlugin(plugin) { + upgradePluginAPI(plugin); + plugins.push(plugin); + } + + /** + * @param {HLJSPlugin} plugin + */ + function removePlugin(plugin) { + const index = plugins.indexOf(plugin); + if (index !== -1) { + plugins.splice(index, 1); + } + } + + /** + * + * @param {PluginEvent} event + * @param {any} args + */ + function fire(event, args) { + const cb = event; + plugins.forEach(function(plugin) { + if (plugin[cb]) { + plugin[cb](args); + } + }); + } + + /** + * DEPRECATED + * @param {HighlightedHTMLElement} el + */ + function deprecateHighlightBlock(el) { + deprecated("10.7.0", "highlightBlock will be removed entirely in v12.0"); + deprecated("10.7.0", "Please use highlightElement now."); + + return highlightElement(el); + } + + /* Interface definition */ + Object.assign(hljs, { + highlight, + highlightAuto, + highlightAll, + highlightElement, + // TODO: Remove with v12 API + highlightBlock: deprecateHighlightBlock, + configure, + initHighlighting, + initHighlightingOnLoad, + registerLanguage, + unregisterLanguage, + listLanguages, + getLanguage, + registerAliases, + autoDetection, + inherit, + addPlugin, + removePlugin + }); + + hljs.debugMode = function() { SAFE_MODE = false; }; + hljs.safeMode = function() { SAFE_MODE = true; }; + hljs.versionString = version; + + hljs.regex = { + concat: concat, + lookahead: lookahead, + either: either, + optional: optional, + anyNumberOfTimes: anyNumberOfTimes + }; + + for (const key in MODES) { + // @ts-ignore + if (typeof MODES[key] === "object") { + // @ts-ignore + deepFreeze(MODES[key]); + } + } + + // merge all the modes/regexes into our main object + Object.assign(hljs, MODES); + + return hljs; +}; + +// Other names for the variable may break build script +const highlight = HLJS({}); + +// returns a new instance of the highlighter to be used for extensions +// check https://github.com/wooorm/lowlight/issues/47 +highlight.newInstance = () => HLJS({}); + +export { highlight as default }; diff --git a/public/assets/highlight/es/core.min.js b/public/assets/highlight/es/core.min.js new file mode 100644 index 00000000..8e6dfb50 --- /dev/null +++ b/public/assets/highlight/es/core.min.js @@ -0,0 +1,307 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +function e(t){return t instanceof Map?t.clear=t.delete=t.set=()=>{ +throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{ +const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i) +})),t}class t{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function n(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] +;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope +;class r{constructor(e,t){ +this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ +this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const n=e.split(".") +;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ") +}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)} +closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const o=(e={})=>{const t={children:[]} +;return Object.assign(t,e),t};class a{constructor(){ +this.rootNode=o(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const t=o({scope:e}) +;this.add(t),this.stack.push(t)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ +return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), +t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,t){const n=e.root +;t&&(n.scope="language:"+t),this.add(n)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function l(e){ +return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")} +function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")} +function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{ +const t=e[e.length-1] +;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{} +})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n +;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break} +s+=i.substring(0,e.index), +i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0], +"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)} +const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",y="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_="\\b(0b[01]+)",O={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[O]},v={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t, +contains:[]},n);s.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return s.contains.push({begin:h(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s +},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var A=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{ +scope:"number",begin:_,relevance:0},BINARY_NUMBER_RE:_,COMMENT:N, +C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number", +begin:y,relevance:0},C_NUMBER_RE:y,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0}, +NUMBER_MODE:{scope:"number",begin:w,relevance:0},NUMBER_RE:w, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:v,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const t=/^#![ ]*\// +;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t, +end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function j(e,t){ +"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){ +t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=j,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function L(e,t){ +Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function P(e,t){ +void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t] +})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={ +relevance:0,contains:[Object.assign(n,{endsParent:!0})] +},e.relevance=0,delete n.beforeMatch +},H=["of","and","for","in","not","or","if","then","parent","list","value"],C="keyword" +;function $(e,t,n=C){const i=Object.create(null) +;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ +Object.assign(i,$(e[n],t,n))})),i;function s(e,n){ +t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") +;i[n[0]]=[e,U(n[0],n[1])]}))}}function U(e,t){ +return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const z={},W=e=>{ +console.error(e)},X=(e,...t)=>{console.log("WARN: "+e,...t)},G=(e,t)=>{ +z[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),z[`${e}/${t}`]=!0) +},K=Error();function F(e,t,{key:n}){let i=0;const s=e[n],r={},o={} +;for(let e=1;e<=t.length;e++)o[e+i]=s[e],r[e+i]=!0,i+=p(t[e-1]) +;e[n]=o,e[n]._emit=r,e[n]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw W("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +K +;if("object"!=typeof e.beginScope||null===e.beginScope)throw W("beginScope must be object"), +K;F(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw W("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +K +;if("object"!=typeof e.endScope||null===e.endScope)throw W("endScope must be object"), +K;F(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function V(e){ +function t(t,n){ +return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":"")) +}class n{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,t){ +t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const t=this.matcherRe.exec(e);if(!t)return null +;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] +;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n +;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), +t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ +this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ +const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex +;let n=t.exec(e) +;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ +const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} +return n&&(this.regexIndex+=n.position+1, +this.regexIndex===this.count&&this.considerAll()),n}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=i(e.classNameAliases||{}),function n(r,o){const a=r +;if(r.isCompiled)return a +;[I,B,Z,D].forEach((e=>e(r,o))),e.compilerExtensions.forEach((e=>e(r,o))), +r.__beforeBegin=null,[T,L,P].forEach((e=>e(r,o))),r.isCompiled=!0;let c=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +c=r.keywords.$pattern, +delete r.keywords.$pattern),c=c||/\w+/,r.keywords&&(r.keywords=$(r.keywords,e.case_insensitive)), +a.keywordPatternRe=t(c,!0), +o&&(r.begin||(r.begin=/\B|\b/),a.beginRe=t(a.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(a.endRe=t(a.end)), +a.terminatorEnd=l(a.end)||"",r.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)), +r.illegal&&(a.illegalRe=t(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{ +variants:null},t)))),e.cachedVariants?e.cachedVariants:q(e)?i(e,{ +starts:e.starts?i(e.starts):null +}):Object.isFrozen(e)?i(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{n(e,a) +})),r.starts&&n(r.starts,o),a.matcher=(e=>{const t=new s +;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function q(e){ +return!!e&&(e.endsWithParent||q(e.starts))}class J extends Error{ +constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}} +const Y=n,Q=i,ee=Symbol("nomatch"),te=n=>{ +const i=Object.create(null),s=Object.create(null),r=[];let o=!0 +;const a="Could not find the language '{}', did you forget to load/include a language module?",l={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:c};function b(e){ +return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s="" +;"object"==typeof t?(i=e, +n=t.ignoreIllegals,s=t.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."), +G("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +s=e,i=t),void 0===n&&(n=!0);const r={code:i,language:s};N("before:highlight",r) +;const o=r.result?r.result:E(r.language,r.code,n) +;return o.code=r.code,N("after:highlight",o),o}function E(e,n,s,r){ +const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R) +;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n="" +;for(;t;){n+=R.substring(e,t.index) +;const s=_.case_insensitive?t[0].toLowerCase():t[0],r=(i=s,N.keywords[i]);if(r){ +const[e,i]=r +;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(A+=i),e.startsWith("_"))n+=t[0];else{ +const n=_.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0] +;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i +;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{ +if(""===R)return;let e=null;if("string"==typeof N.subLanguage){ +if(!i[N.subLanguage])return void M.addText(R) +;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top +}else e=x(R,N.subLanguage.length?N.subLanguage:null) +;N.relevance>0&&(A+=e.relevance),M.__addSublanguage(e._emitter,e.language) +})():l(),R=""}function u(e,t){ +""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1 +;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue} +const i=_.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}} +function h(e,t){ +return e.scope&&"string"==typeof e.scope&&M.openNode(_.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(R,_.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{ +value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t) +;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e) +;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return f(e.parent,n,i)}function b(e){ +return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){ +const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return ee;const r=N +;N.endScope&&N.endScope._wrap?(g(), +u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(), +d(N.endScope,e)):r.skip?R+=t:(r.returnEnd||r.excludeEnd||(R+=t), +g(),r.excludeEnd&&(R=t));do{ +N.scope&&M.closeNode(),N.skip||N.subLanguage||(A+=N.relevance),N=N.parent +}while(N!==s.parent);return s.starts&&h(s.starts,e),r.returnEnd?0:t.length} +let w={};function y(i,r){const a=r&&r[0];if(R+=i,null==a)return g(),0 +;if("begin"===w.type&&"end"===r.type&&w.index===r.index&&""===a){ +if(R+=n.slice(r.index,r.index+1),!o){const t=Error(`0 width match regex (${e})`) +;throw t.languageName=e,t.badRule=w.rule,t}return 1} +if(w=r,"begin"===r.type)return(e=>{ +const n=e[0],i=e.rule,s=new t(i),r=[i.__beforeBegin,i["on:begin"]] +;for(const t of r)if(t&&(t(e,s),s.isMatchIgnored))return b(n) +;return i.skip?R+=n:(i.excludeBegin&&(R+=n), +g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(r) +;if("illegal"===r.type&&!s){ +const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"') +;throw e.mode=N,e}if("end"===r.type){const e=m(r);if(e!==ee)return e} +if("illegal"===r.type&&""===a)return 1 +;if(I>1e5&&I>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return R+=a,a.length}const _=O(e) +;if(!_)throw W(a.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=V(_);let v="",N=r||k;const S={},M=new p.__emitter(p);(()=>{const e=[] +;for(let t=N;t!==_;t=t.parent)t.scope&&e.unshift(t.scope) +;e.forEach((e=>M.openNode(e)))})();let R="",A=0,j=0,I=0,T=!1;try{ +if(_.__emitTokens)_.__emitTokens(n,M);else{for(N.matcher.considerAll();;){ +I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=j +;const e=N.matcher.exec(n);if(!e)break;const t=y(n.substring(j,e.index),e) +;j=e.index+t}y(n.substring(j))}return M.finalize(),v=M.toHTML(),{language:e, +value:v,relevance:A,illegal:!1,_emitter:M,_top:N}}catch(t){ +if(t.message&&t.message.includes("Illegal"))return{language:e,value:Y(n), +illegal:!0,relevance:0,_illegalBy:{message:t.message,index:j, +context:n.slice(j-100,j+100),mode:t.mode,resultSoFar:v},_emitter:M};if(o)return{ +language:e,value:Y(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N} +;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{ +const t={value:Y(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(v).map((t=>E(t,e,!1))) +;s.unshift(n);const r=s.sort(((e,t)=>{ +if(e.relevance!==t.relevance)return t.relevance-e.relevance +;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1 +;if(O(t.language).supersetOf===e.language)return-1}return 0})),[o,a]=r,c=o +;return c.secondBest=a,c}function w(e){let t=null;const n=(e=>{ +let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"" +;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1]) +;return t||(X(a.replace("{}",n[1])), +X("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"} +return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return +;if(N("before:highlightElement",{el:e,language:n +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new J("One of your code blocks includes unescaped HTML.",e.innerHTML) +;t=e;const i=t.textContent,r=n?m(i,{language:n,ignoreIllegals:!0}):x(i) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n +;e.classList.add("hljs"),e.classList.add("language-"+i) +})(e,n,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),N("after:highlightElement",{el:e,result:r,text:i})}let y=!1;function _(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(w):y=!0 +}function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]} +function k(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +s[e.toLowerCase()]=t}))}function v(e){const t=O(e) +;return t&&!t.disableAutodetect}function N(e,t){const n=e;r.forEach((e=>{ +e[n]&&e[n](t)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +y&&_()}),!1),Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:_, +highlightElement:w, +highlightBlock:e=>(G("10.7.0","highlightBlock will be removed entirely in v12.0"), +G("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{p=Q(p,e)}, +initHighlighting:()=>{ +_(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +_(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){ +if(W("Language definition for '{}' could not be registered.".replace("{}",e)), +!o)throw t;W(t),s=l} +s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&k(s.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete i[e] +;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, +listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:k, +autoDetection:v,inherit:Q,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ +e["before:highlightBlock"](Object.assign({block:t.el},t)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ +e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),r.push(e)}, +removePlugin:e=>{const t=r.indexOf(e);-1!==t&&r.splice(t,1)}}),n.debugMode=()=>{ +o=!1},n.safeMode=()=>{o=!0},n.versionString="11.10.0",n.regex={concat:h, +lookahead:g,either:f,optional:d,anyNumberOfTimes:u} +;for(const t in A)"object"==typeof A[t]&&e(A[t]);return Object.assign(n,A),n +},ne=te({});ne.newInstance=()=>te({});export{ne as default}; \ No newline at end of file diff --git a/public/assets/highlight/es/highlight.js b/public/assets/highlight/es/highlight.js new file mode 100644 index 00000000..1558390b --- /dev/null +++ b/public/assets/highlight/es/highlight.js @@ -0,0 +1,2600 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +/* eslint-disable no-multi-assign */ + +function deepFreeze(obj) { + if (obj instanceof Map) { + obj.clear = + obj.delete = + obj.set = + function () { + throw new Error('map is read-only'); + }; + } else if (obj instanceof Set) { + obj.add = + obj.clear = + obj.delete = + function () { + throw new Error('set is read-only'); + }; + } + + // Freeze self + Object.freeze(obj); + + Object.getOwnPropertyNames(obj).forEach((name) => { + const prop = obj[name]; + const type = typeof prop; + + // Freeze prop if it is an object or function and also not already frozen + if ((type === 'object' || type === 'function') && !Object.isFrozen(prop)) { + deepFreeze(prop); + } + }); + + return obj; +} + +/** @typedef {import('highlight.js').CallbackResponse} CallbackResponse */ +/** @typedef {import('highlight.js').CompiledMode} CompiledMode */ +/** @implements CallbackResponse */ + +class Response { + /** + * @param {CompiledMode} mode + */ + constructor(mode) { + // eslint-disable-next-line no-undefined + if (mode.data === undefined) mode.data = {}; + + this.data = mode.data; + this.isMatchIgnored = false; + } + + ignoreMatch() { + this.isMatchIgnored = true; + } +} + +/** + * @param {string} value + * @returns {string} + */ +function escapeHTML(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * performs a shallow merge of multiple objects into one + * + * @template T + * @param {T} original + * @param {Record[]} objects + * @returns {T} a single new object + */ +function inherit$1(original, ...objects) { + /** @type Record */ + const result = Object.create(null); + + for (const key in original) { + result[key] = original[key]; + } + objects.forEach(function(obj) { + for (const key in obj) { + result[key] = obj[key]; + } + }); + return /** @type {T} */ (result); +} + +/** + * @typedef {object} Renderer + * @property {(text: string) => void} addText + * @property {(node: Node) => void} openNode + * @property {(node: Node) => void} closeNode + * @property {() => string} value + */ + +/** @typedef {{scope?: string, language?: string, sublanguage?: boolean}} Node */ +/** @typedef {{walk: (r: Renderer) => void}} Tree */ +/** */ + +const SPAN_CLOSE = ''; + +/** + * Determines if a node needs to be wrapped in + * + * @param {Node} node */ +const emitsWrappingTags = (node) => { + // rarely we can have a sublanguage where language is undefined + // TODO: track down why + return !!node.scope; +}; + +/** + * + * @param {string} name + * @param {{prefix:string}} options + */ +const scopeToCSSClass = (name, { prefix }) => { + // sub-language + if (name.startsWith("language:")) { + return name.replace("language:", "language-"); + } + // tiered scope: comment.line + if (name.includes(".")) { + const pieces = name.split("."); + return [ + `${prefix}${pieces.shift()}`, + ...(pieces.map((x, i) => `${x}${"_".repeat(i + 1)}`)) + ].join(" "); + } + // simple scope + return `${prefix}${name}`; +}; + +/** @type {Renderer} */ +class HTMLRenderer { + /** + * Creates a new HTMLRenderer + * + * @param {Tree} parseTree - the parse tree (must support `walk` API) + * @param {{classPrefix: string}} options + */ + constructor(parseTree, options) { + this.buffer = ""; + this.classPrefix = options.classPrefix; + parseTree.walk(this); + } + + /** + * Adds texts to the output stream + * + * @param {string} text */ + addText(text) { + this.buffer += escapeHTML(text); + } + + /** + * Adds a node open to the output stream (if needed) + * + * @param {Node} node */ + openNode(node) { + if (!emitsWrappingTags(node)) return; + + const className = scopeToCSSClass(node.scope, + { prefix: this.classPrefix }); + this.span(className); + } + + /** + * Adds a node close to the output stream (if needed) + * + * @param {Node} node */ + closeNode(node) { + if (!emitsWrappingTags(node)) return; + + this.buffer += SPAN_CLOSE; + } + + /** + * returns the accumulated buffer + */ + value() { + return this.buffer; + } + + // helpers + + /** + * Builds a span element + * + * @param {string} className */ + span(className) { + this.buffer += ``; + } +} + +/** @typedef {{scope?: string, language?: string, children: Node[]} | string} Node */ +/** @typedef {{scope?: string, language?: string, children: Node[]} } DataNode */ +/** @typedef {import('highlight.js').Emitter} Emitter */ +/** */ + +/** @returns {DataNode} */ +const newNode = (opts = {}) => { + /** @type DataNode */ + const result = { children: [] }; + Object.assign(result, opts); + return result; +}; + +class TokenTree { + constructor() { + /** @type DataNode */ + this.rootNode = newNode(); + this.stack = [this.rootNode]; + } + + get top() { + return this.stack[this.stack.length - 1]; + } + + get root() { return this.rootNode; } + + /** @param {Node} node */ + add(node) { + this.top.children.push(node); + } + + /** @param {string} scope */ + openNode(scope) { + /** @type Node */ + const node = newNode({ scope }); + this.add(node); + this.stack.push(node); + } + + closeNode() { + if (this.stack.length > 1) { + return this.stack.pop(); + } + // eslint-disable-next-line no-undefined + return undefined; + } + + closeAllNodes() { + while (this.closeNode()); + } + + toJSON() { + return JSON.stringify(this.rootNode, null, 4); + } + + /** + * @typedef { import("./html_renderer").Renderer } Renderer + * @param {Renderer} builder + */ + walk(builder) { + // this does not + return this.constructor._walk(builder, this.rootNode); + // this works + // return TokenTree._walk(builder, this.rootNode); + } + + /** + * @param {Renderer} builder + * @param {Node} node + */ + static _walk(builder, node) { + if (typeof node === "string") { + builder.addText(node); + } else if (node.children) { + builder.openNode(node); + node.children.forEach((child) => this._walk(builder, child)); + builder.closeNode(node); + } + return builder; + } + + /** + * @param {Node} node + */ + static _collapse(node) { + if (typeof node === "string") return; + if (!node.children) return; + + if (node.children.every(el => typeof el === "string")) { + // node.text = node.children.join(""); + // delete node.children; + node.children = [node.children.join("")]; + } else { + node.children.forEach((child) => { + TokenTree._collapse(child); + }); + } + } +} + +/** + Currently this is all private API, but this is the minimal API necessary + that an Emitter must implement to fully support the parser. + + Minimal interface: + + - addText(text) + - __addSublanguage(emitter, subLanguageName) + - startScope(scope) + - endScope() + - finalize() + - toHTML() + +*/ + +/** + * @implements {Emitter} + */ +class TokenTreeEmitter extends TokenTree { + /** + * @param {*} options + */ + constructor(options) { + super(); + this.options = options; + } + + /** + * @param {string} text + */ + addText(text) { + if (text === "") { return; } + + this.add(text); + } + + /** @param {string} scope */ + startScope(scope) { + this.openNode(scope); + } + + endScope() { + this.closeNode(); + } + + /** + * @param {Emitter & {root: DataNode}} emitter + * @param {string} name + */ + __addSublanguage(emitter, name) { + /** @type DataNode */ + const node = emitter.root; + if (name) node.scope = `language:${name}`; + + this.add(node); + } + + toHTML() { + const renderer = new HTMLRenderer(this, this.options); + return renderer.value(); + } + + finalize() { + this.closeAllNodes(); + return true; + } +} + +/** + * @param {string} value + * @returns {RegExp} + * */ + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function source(re) { + if (!re) return null; + if (typeof re === "string") return re; + + return re.source; +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function lookahead(re) { + return concat('(?=', re, ')'); +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function anyNumberOfTimes(re) { + return concat('(?:', re, ')*'); +} + +/** + * @param {RegExp | string } re + * @returns {string} + */ +function optional(re) { + return concat('(?:', re, ')?'); +} + +/** + * @param {...(RegExp | string) } args + * @returns {string} + */ +function concat(...args) { + const joined = args.map((x) => source(x)).join(""); + return joined; +} + +/** + * @param { Array } args + * @returns {object} + */ +function stripOptionsFromArgs(args) { + const opts = args[args.length - 1]; + + if (typeof opts === 'object' && opts.constructor === Object) { + args.splice(args.length - 1, 1); + return opts; + } else { + return {}; + } +} + +/** @typedef { {capture?: boolean} } RegexEitherOptions */ + +/** + * Any of the passed expresssions may match + * + * Creates a huge this | this | that | that match + * @param {(RegExp | string)[] | [...(RegExp | string)[], RegexEitherOptions]} args + * @returns {string} + */ +function either(...args) { + /** @type { object & {capture?: boolean} } */ + const opts = stripOptionsFromArgs(args); + const joined = '(' + + (opts.capture ? "" : "?:") + + args.map((x) => source(x)).join("|") + ")"; + return joined; +} + +/** + * @param {RegExp | string} re + * @returns {number} + */ +function countMatchGroups(re) { + return (new RegExp(re.toString() + '|')).exec('').length - 1; +} + +/** + * Does lexeme start with a regular expression match at the beginning + * @param {RegExp} re + * @param {string} lexeme + */ +function startsWith(re, lexeme) { + const match = re && re.exec(lexeme); + return match && match.index === 0; +} + +// BACKREF_RE matches an open parenthesis or backreference. To avoid +// an incorrect parse, it additionally matches the following: +// - [...] elements, where the meaning of parentheses and escapes change +// - other escape sequences, so we do not misparse escape sequences as +// interesting elements +// - non-matching or lookahead parentheses, which do not capture. These +// follow the '(' with a '?'. +const BACKREF_RE = /\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./; + +// **INTERNAL** Not intended for outside usage +// join logically computes regexps.join(separator), but fixes the +// backreferences so they continue to match. +// it also places each individual regular expression into it's own +// match group, keeping track of the sequencing of those match groups +// is currently an exercise for the caller. :-) +/** + * @param {(string | RegExp)[]} regexps + * @param {{joinWith: string}} opts + * @returns {string} + */ +function _rewriteBackreferences(regexps, { joinWith }) { + let numCaptures = 0; + + return regexps.map((regex) => { + numCaptures += 1; + const offset = numCaptures; + let re = source(regex); + let out = ''; + + while (re.length > 0) { + const match = BACKREF_RE.exec(re); + if (!match) { + out += re; + break; + } + out += re.substring(0, match.index); + re = re.substring(match.index + match[0].length); + if (match[0][0] === '\\' && match[1]) { + // Adjust the backreference. + out += '\\' + String(Number(match[1]) + offset); + } else { + out += match[0]; + if (match[0] === '(') { + numCaptures++; + } + } + } + return out; + }).map(re => `(${re})`).join(joinWith); +} + +/** @typedef {import('highlight.js').Mode} Mode */ +/** @typedef {import('highlight.js').ModeCallback} ModeCallback */ + +// Common regexps +const MATCH_NOTHING_RE = /\b\B/; +const IDENT_RE = '[a-zA-Z]\\w*'; +const UNDERSCORE_IDENT_RE = '[a-zA-Z_]\\w*'; +const NUMBER_RE = '\\b\\d+(\\.\\d+)?'; +const C_NUMBER_RE = '(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)'; // 0x..., 0..., decimal, float +const BINARY_NUMBER_RE = '\\b(0b[01]+)'; // 0b... +const RE_STARTERS_RE = '!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~'; + +/** +* @param { Partial & {binary?: string | RegExp} } opts +*/ +const SHEBANG = (opts = {}) => { + const beginShebang = /^#![ ]*\//; + if (opts.binary) { + opts.begin = concat( + beginShebang, + /.*\b/, + opts.binary, + /\b.*/); + } + return inherit$1({ + scope: 'meta', + begin: beginShebang, + end: /$/, + relevance: 0, + /** @type {ModeCallback} */ + "on:begin": (m, resp) => { + if (m.index !== 0) resp.ignoreMatch(); + } + }, opts); +}; + +// Common modes +const BACKSLASH_ESCAPE = { + begin: '\\\\[\\s\\S]', relevance: 0 +}; +const APOS_STRING_MODE = { + scope: 'string', + begin: '\'', + end: '\'', + illegal: '\\n', + contains: [BACKSLASH_ESCAPE] +}; +const QUOTE_STRING_MODE = { + scope: 'string', + begin: '"', + end: '"', + illegal: '\\n', + contains: [BACKSLASH_ESCAPE] +}; +const PHRASAL_WORDS_MODE = { + begin: /\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +}; +/** + * Creates a comment mode + * + * @param {string | RegExp} begin + * @param {string | RegExp} end + * @param {Mode | {}} [modeOptions] + * @returns {Partial} + */ +const COMMENT = function(begin, end, modeOptions = {}) { + const mode = inherit$1( + { + scope: 'comment', + begin, + end, + contains: [] + }, + modeOptions + ); + mode.contains.push({ + scope: 'doctag', + // hack to avoid the space from being included. the space is necessary to + // match here to prevent the plain text rule below from gobbling up doctags + begin: '[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)', + end: /(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/, + excludeBegin: true, + relevance: 0 + }); + const ENGLISH_WORD = either( + // list of common 1 and 2 letter words in English + "I", + "a", + "is", + "so", + "us", + "to", + "at", + "if", + "in", + "it", + "on", + // note: this is not an exhaustive list of contractions, just popular ones + /[A-Za-z]+['](d|ve|re|ll|t|s|n)/, // contractions - can't we'd they're let's, etc + /[A-Za-z]+[-][a-z]+/, // `no-way`, etc. + /[A-Za-z][a-z]{2,}/ // allow capitalized words at beginning of sentences + ); + // looking like plain text, more likely to be a comment + mode.contains.push( + { + // TODO: how to include ", (, ) without breaking grammars that use these for + // comment delimiters? + // begin: /[ ]+([()"]?([A-Za-z'-]{3,}|is|a|I|so|us|[tT][oO]|at|if|in|it|on)[.]?[()":]?([.][ ]|[ ]|\))){3}/ + // --- + + // this tries to find sequences of 3 english words in a row (without any + // "programming" type syntax) this gives us a strong signal that we've + // TRULY found a comment - vs perhaps scanning with the wrong language. + // It's possible to find something that LOOKS like the start of the + // comment - but then if there is no readable text - good chance it is a + // false match and not a comment. + // + // for a visual example please see: + // https://github.com/highlightjs/highlight.js/issues/2827 + + begin: concat( + /[ ]+/, // necessary to prevent us gobbling up doctags like /* @author Bob Mcgill */ + '(', + ENGLISH_WORD, + /[.]?[:]?([.][ ]|[ ])/, + '){3}') // look for 3 words in a row + } + ); + return mode; +}; +const C_LINE_COMMENT_MODE = COMMENT('//', '$'); +const C_BLOCK_COMMENT_MODE = COMMENT('/\\*', '\\*/'); +const HASH_COMMENT_MODE = COMMENT('#', '$'); +const NUMBER_MODE = { + scope: 'number', + begin: NUMBER_RE, + relevance: 0 +}; +const C_NUMBER_MODE = { + scope: 'number', + begin: C_NUMBER_RE, + relevance: 0 +}; +const BINARY_NUMBER_MODE = { + scope: 'number', + begin: BINARY_NUMBER_RE, + relevance: 0 +}; +const REGEXP_MODE = { + scope: "regexp", + begin: /\/(?=[^/\n]*\/)/, + end: /\/[gimuy]*/, + contains: [ + BACKSLASH_ESCAPE, + { + begin: /\[/, + end: /\]/, + relevance: 0, + contains: [BACKSLASH_ESCAPE] + } + ] +}; +const TITLE_MODE = { + scope: 'title', + begin: IDENT_RE, + relevance: 0 +}; +const UNDERSCORE_TITLE_MODE = { + scope: 'title', + begin: UNDERSCORE_IDENT_RE, + relevance: 0 +}; +const METHOD_GUARD = { + // excludes method names from keyword processing + begin: '\\.\\s*' + UNDERSCORE_IDENT_RE, + relevance: 0 +}; + +/** + * Adds end same as begin mechanics to a mode + * + * Your mode must include at least a single () match group as that first match + * group is what is used for comparison + * @param {Partial} mode + */ +const END_SAME_AS_BEGIN = function(mode) { + return Object.assign(mode, + { + /** @type {ModeCallback} */ + 'on:begin': (m, resp) => { resp.data._beginMatch = m[1]; }, + /** @type {ModeCallback} */ + 'on:end': (m, resp) => { if (resp.data._beginMatch !== m[1]) resp.ignoreMatch(); } + }); +}; + +var MODES = /*#__PURE__*/Object.freeze({ + __proto__: null, + APOS_STRING_MODE: APOS_STRING_MODE, + BACKSLASH_ESCAPE: BACKSLASH_ESCAPE, + BINARY_NUMBER_MODE: BINARY_NUMBER_MODE, + BINARY_NUMBER_RE: BINARY_NUMBER_RE, + COMMENT: COMMENT, + C_BLOCK_COMMENT_MODE: C_BLOCK_COMMENT_MODE, + C_LINE_COMMENT_MODE: C_LINE_COMMENT_MODE, + C_NUMBER_MODE: C_NUMBER_MODE, + C_NUMBER_RE: C_NUMBER_RE, + END_SAME_AS_BEGIN: END_SAME_AS_BEGIN, + HASH_COMMENT_MODE: HASH_COMMENT_MODE, + IDENT_RE: IDENT_RE, + MATCH_NOTHING_RE: MATCH_NOTHING_RE, + METHOD_GUARD: METHOD_GUARD, + NUMBER_MODE: NUMBER_MODE, + NUMBER_RE: NUMBER_RE, + PHRASAL_WORDS_MODE: PHRASAL_WORDS_MODE, + QUOTE_STRING_MODE: QUOTE_STRING_MODE, + REGEXP_MODE: REGEXP_MODE, + RE_STARTERS_RE: RE_STARTERS_RE, + SHEBANG: SHEBANG, + TITLE_MODE: TITLE_MODE, + UNDERSCORE_IDENT_RE: UNDERSCORE_IDENT_RE, + UNDERSCORE_TITLE_MODE: UNDERSCORE_TITLE_MODE +}); + +/** +@typedef {import('highlight.js').CallbackResponse} CallbackResponse +@typedef {import('highlight.js').CompilerExt} CompilerExt +*/ + +// Grammar extensions / plugins +// See: https://github.com/highlightjs/highlight.js/issues/2833 + +// Grammar extensions allow "syntactic sugar" to be added to the grammar modes +// without requiring any underlying changes to the compiler internals. + +// `compileMatch` being the perfect small example of now allowing a grammar +// author to write `match` when they desire to match a single expression rather +// than being forced to use `begin`. The extension then just moves `match` into +// `begin` when it runs. Ie, no features have been added, but we've just made +// the experience of writing (and reading grammars) a little bit nicer. + +// ------ + +// TODO: We need negative look-behind support to do this properly +/** + * Skip a match if it has a preceding dot + * + * This is used for `beginKeywords` to prevent matching expressions such as + * `bob.keyword.do()`. The mode compiler automatically wires this up as a + * special _internal_ 'on:begin' callback for modes with `beginKeywords` + * @param {RegExpMatchArray} match + * @param {CallbackResponse} response + */ +function skipIfHasPrecedingDot(match, response) { + const before = match.input[match.index - 1]; + if (before === ".") { + response.ignoreMatch(); + } +} + +/** + * + * @type {CompilerExt} + */ +function scopeClassName(mode, _parent) { + // eslint-disable-next-line no-undefined + if (mode.className !== undefined) { + mode.scope = mode.className; + delete mode.className; + } +} + +/** + * `beginKeywords` syntactic sugar + * @type {CompilerExt} + */ +function beginKeywords(mode, parent) { + if (!parent) return; + if (!mode.beginKeywords) return; + + // for languages with keywords that include non-word characters checking for + // a word boundary is not sufficient, so instead we check for a word boundary + // or whitespace - this does no harm in any case since our keyword engine + // doesn't allow spaces in keywords anyways and we still check for the boundary + // first + mode.begin = '\\b(' + mode.beginKeywords.split(' ').join('|') + ')(?!\\.)(?=\\b|\\s)'; + mode.__beforeBegin = skipIfHasPrecedingDot; + mode.keywords = mode.keywords || mode.beginKeywords; + delete mode.beginKeywords; + + // prevents double relevance, the keywords themselves provide + // relevance, the mode doesn't need to double it + // eslint-disable-next-line no-undefined + if (mode.relevance === undefined) mode.relevance = 0; +} + +/** + * Allow `illegal` to contain an array of illegal values + * @type {CompilerExt} + */ +function compileIllegal(mode, _parent) { + if (!Array.isArray(mode.illegal)) return; + + mode.illegal = either(...mode.illegal); +} + +/** + * `match` to match a single expression for readability + * @type {CompilerExt} + */ +function compileMatch(mode, _parent) { + if (!mode.match) return; + if (mode.begin || mode.end) throw new Error("begin & end are not supported with match"); + + mode.begin = mode.match; + delete mode.match; +} + +/** + * provides the default 1 relevance to all modes + * @type {CompilerExt} + */ +function compileRelevance(mode, _parent) { + // eslint-disable-next-line no-undefined + if (mode.relevance === undefined) mode.relevance = 1; +} + +// allow beforeMatch to act as a "qualifier" for the match +// the full match begin must be [beforeMatch][begin] +const beforeMatchExt = (mode, parent) => { + if (!mode.beforeMatch) return; + // starts conflicts with endsParent which we need to make sure the child + // rule is not matched multiple times + if (mode.starts) throw new Error("beforeMatch cannot be used with starts"); + + const originalMode = Object.assign({}, mode); + Object.keys(mode).forEach((key) => { delete mode[key]; }); + + mode.keywords = originalMode.keywords; + mode.begin = concat(originalMode.beforeMatch, lookahead(originalMode.begin)); + mode.starts = { + relevance: 0, + contains: [ + Object.assign(originalMode, { endsParent: true }) + ] + }; + mode.relevance = 0; + + delete originalMode.beforeMatch; +}; + +// keywords that should have no default relevance value +const COMMON_KEYWORDS = [ + 'of', + 'and', + 'for', + 'in', + 'not', + 'or', + 'if', + 'then', + 'parent', // common variable name + 'list', // common variable name + 'value' // common variable name +]; + +const DEFAULT_KEYWORD_SCOPE = "keyword"; + +/** + * Given raw keywords from a language definition, compile them. + * + * @param {string | Record | Array} rawKeywords + * @param {boolean} caseInsensitive + */ +function compileKeywords(rawKeywords, caseInsensitive, scopeName = DEFAULT_KEYWORD_SCOPE) { + /** @type {import("highlight.js/private").KeywordDict} */ + const compiledKeywords = Object.create(null); + + // input can be a string of keywords, an array of keywords, or a object with + // named keys representing scopeName (which can then point to a string or array) + if (typeof rawKeywords === 'string') { + compileList(scopeName, rawKeywords.split(" ")); + } else if (Array.isArray(rawKeywords)) { + compileList(scopeName, rawKeywords); + } else { + Object.keys(rawKeywords).forEach(function(scopeName) { + // collapse all our objects back into the parent object + Object.assign( + compiledKeywords, + compileKeywords(rawKeywords[scopeName], caseInsensitive, scopeName) + ); + }); + } + return compiledKeywords; + + // --- + + /** + * Compiles an individual list of keywords + * + * Ex: "for if when while|5" + * + * @param {string} scopeName + * @param {Array} keywordList + */ + function compileList(scopeName, keywordList) { + if (caseInsensitive) { + keywordList = keywordList.map(x => x.toLowerCase()); + } + keywordList.forEach(function(keyword) { + const pair = keyword.split('|'); + compiledKeywords[pair[0]] = [scopeName, scoreForKeyword(pair[0], pair[1])]; + }); + } +} + +/** + * Returns the proper score for a given keyword + * + * Also takes into account comment keywords, which will be scored 0 UNLESS + * another score has been manually assigned. + * @param {string} keyword + * @param {string} [providedScore] + */ +function scoreForKeyword(keyword, providedScore) { + // manual scores always win over common keywords + // so you can force a score of 1 if you really insist + if (providedScore) { + return Number(providedScore); + } + + return commonKeyword(keyword) ? 0 : 1; +} + +/** + * Determines if a given keyword is common or not + * + * @param {string} keyword */ +function commonKeyword(keyword) { + return COMMON_KEYWORDS.includes(keyword.toLowerCase()); +} + +/* + +For the reasoning behind this please see: +https://github.com/highlightjs/highlight.js/issues/2880#issuecomment-747275419 + +*/ + +/** + * @type {Record} + */ +const seenDeprecations = {}; + +/** + * @param {string} message + */ +const error = (message) => { + console.error(message); +}; + +/** + * @param {string} message + * @param {any} args + */ +const warn = (message, ...args) => { + console.log(`WARN: ${message}`, ...args); +}; + +/** + * @param {string} version + * @param {string} message + */ +const deprecated = (version, message) => { + if (seenDeprecations[`${version}/${message}`]) return; + + console.log(`Deprecated as of ${version}. ${message}`); + seenDeprecations[`${version}/${message}`] = true; +}; + +/* eslint-disable no-throw-literal */ + +/** +@typedef {import('highlight.js').CompiledMode} CompiledMode +*/ + +const MultiClassError = new Error(); + +/** + * Renumbers labeled scope names to account for additional inner match + * groups that otherwise would break everything. + * + * Lets say we 3 match scopes: + * + * { 1 => ..., 2 => ..., 3 => ... } + * + * So what we need is a clean match like this: + * + * (a)(b)(c) => [ "a", "b", "c" ] + * + * But this falls apart with inner match groups: + * + * (a)(((b)))(c) => ["a", "b", "b", "b", "c" ] + * + * Our scopes are now "out of alignment" and we're repeating `b` 3 times. + * What needs to happen is the numbers are remapped: + * + * { 1 => ..., 2 => ..., 5 => ... } + * + * We also need to know that the ONLY groups that should be output + * are 1, 2, and 5. This function handles this behavior. + * + * @param {CompiledMode} mode + * @param {Array} regexes + * @param {{key: "beginScope"|"endScope"}} opts + */ +function remapScopeNames(mode, regexes, { key }) { + let offset = 0; + const scopeNames = mode[key]; + /** @type Record */ + const emit = {}; + /** @type Record */ + const positions = {}; + + for (let i = 1; i <= regexes.length; i++) { + positions[i + offset] = scopeNames[i]; + emit[i + offset] = true; + offset += countMatchGroups(regexes[i - 1]); + } + // we use _emit to keep track of which match groups are "top-level" to avoid double + // output from inside match groups + mode[key] = positions; + mode[key]._emit = emit; + mode[key]._multi = true; +} + +/** + * @param {CompiledMode} mode + */ +function beginMultiClass(mode) { + if (!Array.isArray(mode.begin)) return; + + if (mode.skip || mode.excludeBegin || mode.returnBegin) { + error("skip, excludeBegin, returnBegin not compatible with beginScope: {}"); + throw MultiClassError; + } + + if (typeof mode.beginScope !== "object" || mode.beginScope === null) { + error("beginScope must be object"); + throw MultiClassError; + } + + remapScopeNames(mode, mode.begin, { key: "beginScope" }); + mode.begin = _rewriteBackreferences(mode.begin, { joinWith: "" }); +} + +/** + * @param {CompiledMode} mode + */ +function endMultiClass(mode) { + if (!Array.isArray(mode.end)) return; + + if (mode.skip || mode.excludeEnd || mode.returnEnd) { + error("skip, excludeEnd, returnEnd not compatible with endScope: {}"); + throw MultiClassError; + } + + if (typeof mode.endScope !== "object" || mode.endScope === null) { + error("endScope must be object"); + throw MultiClassError; + } + + remapScopeNames(mode, mode.end, { key: "endScope" }); + mode.end = _rewriteBackreferences(mode.end, { joinWith: "" }); +} + +/** + * this exists only to allow `scope: {}` to be used beside `match:` + * Otherwise `beginScope` would necessary and that would look weird + + { + match: [ /def/, /\w+/ ] + scope: { 1: "keyword" , 2: "title" } + } + + * @param {CompiledMode} mode + */ +function scopeSugar(mode) { + if (mode.scope && typeof mode.scope === "object" && mode.scope !== null) { + mode.beginScope = mode.scope; + delete mode.scope; + } +} + +/** + * @param {CompiledMode} mode + */ +function MultiClass(mode) { + scopeSugar(mode); + + if (typeof mode.beginScope === "string") { + mode.beginScope = { _wrap: mode.beginScope }; + } + if (typeof mode.endScope === "string") { + mode.endScope = { _wrap: mode.endScope }; + } + + beginMultiClass(mode); + endMultiClass(mode); +} + +/** +@typedef {import('highlight.js').Mode} Mode +@typedef {import('highlight.js').CompiledMode} CompiledMode +@typedef {import('highlight.js').Language} Language +@typedef {import('highlight.js').HLJSPlugin} HLJSPlugin +@typedef {import('highlight.js').CompiledLanguage} CompiledLanguage +*/ + +// compilation + +/** + * Compiles a language definition result + * + * Given the raw result of a language definition (Language), compiles this so + * that it is ready for highlighting code. + * @param {Language} language + * @returns {CompiledLanguage} + */ +function compileLanguage(language) { + /** + * Builds a regex with the case sensitivity of the current language + * + * @param {RegExp | string} value + * @param {boolean} [global] + */ + function langRe(value, global) { + return new RegExp( + source(value), + 'm' + + (language.case_insensitive ? 'i' : '') + + (language.unicodeRegex ? 'u' : '') + + (global ? 'g' : '') + ); + } + + /** + Stores multiple regular expressions and allows you to quickly search for + them all in a string simultaneously - returning the first match. It does + this by creating a huge (a|b|c) regex - each individual item wrapped with () + and joined by `|` - using match groups to track position. When a match is + found checking which position in the array has content allows us to figure + out which of the original regexes / match groups triggered the match. + + The match object itself (the result of `Regex.exec`) is returned but also + enhanced by merging in any meta-data that was registered with the regex. + This is how we keep track of which mode matched, and what type of rule + (`illegal`, `begin`, end, etc). + */ + class MultiRegex { + constructor() { + this.matchIndexes = {}; + // @ts-ignore + this.regexes = []; + this.matchAt = 1; + this.position = 0; + } + + // @ts-ignore + addRule(re, opts) { + opts.position = this.position++; + // @ts-ignore + this.matchIndexes[this.matchAt] = opts; + this.regexes.push([opts, re]); + this.matchAt += countMatchGroups(re) + 1; + } + + compile() { + if (this.regexes.length === 0) { + // avoids the need to check length every time exec is called + // @ts-ignore + this.exec = () => null; + } + const terminators = this.regexes.map(el => el[1]); + this.matcherRe = langRe(_rewriteBackreferences(terminators, { joinWith: '|' }), true); + this.lastIndex = 0; + } + + /** @param {string} s */ + exec(s) { + this.matcherRe.lastIndex = this.lastIndex; + const match = this.matcherRe.exec(s); + if (!match) { return null; } + + // eslint-disable-next-line no-undefined + const i = match.findIndex((el, i) => i > 0 && el !== undefined); + // @ts-ignore + const matchData = this.matchIndexes[i]; + // trim off any earlier non-relevant match groups (ie, the other regex + // match groups that make up the multi-matcher) + match.splice(0, i); + + return Object.assign(match, matchData); + } + } + + /* + Created to solve the key deficiently with MultiRegex - there is no way to + test for multiple matches at a single location. Why would we need to do + that? In the future a more dynamic engine will allow certain matches to be + ignored. An example: if we matched say the 3rd regex in a large group but + decided to ignore it - we'd need to started testing again at the 4th + regex... but MultiRegex itself gives us no real way to do that. + + So what this class creates MultiRegexs on the fly for whatever search + position they are needed. + + NOTE: These additional MultiRegex objects are created dynamically. For most + grammars most of the time we will never actually need anything more than the + first MultiRegex - so this shouldn't have too much overhead. + + Say this is our search group, and we match regex3, but wish to ignore it. + + regex1 | regex2 | regex3 | regex4 | regex5 ' ie, startAt = 0 + + What we need is a new MultiRegex that only includes the remaining + possibilities: + + regex4 | regex5 ' ie, startAt = 3 + + This class wraps all that complexity up in a simple API... `startAt` decides + where in the array of expressions to start doing the matching. It + auto-increments, so if a match is found at position 2, then startAt will be + set to 3. If the end is reached startAt will return to 0. + + MOST of the time the parser will be setting startAt manually to 0. + */ + class ResumableMultiRegex { + constructor() { + // @ts-ignore + this.rules = []; + // @ts-ignore + this.multiRegexes = []; + this.count = 0; + + this.lastIndex = 0; + this.regexIndex = 0; + } + + // @ts-ignore + getMatcher(index) { + if (this.multiRegexes[index]) return this.multiRegexes[index]; + + const matcher = new MultiRegex(); + this.rules.slice(index).forEach(([re, opts]) => matcher.addRule(re, opts)); + matcher.compile(); + this.multiRegexes[index] = matcher; + return matcher; + } + + resumingScanAtSamePosition() { + return this.regexIndex !== 0; + } + + considerAll() { + this.regexIndex = 0; + } + + // @ts-ignore + addRule(re, opts) { + this.rules.push([re, opts]); + if (opts.type === "begin") this.count++; + } + + /** @param {string} s */ + exec(s) { + const m = this.getMatcher(this.regexIndex); + m.lastIndex = this.lastIndex; + let result = m.exec(s); + + // The following is because we have no easy way to say "resume scanning at the + // existing position but also skip the current rule ONLY". What happens is + // all prior rules are also skipped which can result in matching the wrong + // thing. Example of matching "booger": + + // our matcher is [string, "booger", number] + // + // ....booger.... + + // if "booger" is ignored then we'd really need a regex to scan from the + // SAME position for only: [string, number] but ignoring "booger" (if it + // was the first match), a simple resume would scan ahead who knows how + // far looking only for "number", ignoring potential string matches (or + // future "booger" matches that might be valid.) + + // So what we do: We execute two matchers, one resuming at the same + // position, but the second full matcher starting at the position after: + + // /--- resume first regex match here (for [number]) + // |/---- full match here for [string, "booger", number] + // vv + // ....booger.... + + // Which ever results in a match first is then used. So this 3-4 step + // process essentially allows us to say "match at this position, excluding + // a prior rule that was ignored". + // + // 1. Match "booger" first, ignore. Also proves that [string] does non match. + // 2. Resume matching for [number] + // 3. Match at index + 1 for [string, "booger", number] + // 4. If #2 and #3 result in matches, which came first? + if (this.resumingScanAtSamePosition()) { + if (result && result.index === this.lastIndex) ; else { // use the second matcher result + const m2 = this.getMatcher(0); + m2.lastIndex = this.lastIndex + 1; + result = m2.exec(s); + } + } + + if (result) { + this.regexIndex += result.position + 1; + if (this.regexIndex === this.count) { + // wrap-around to considering all matches again + this.considerAll(); + } + } + + return result; + } + } + + /** + * Given a mode, builds a huge ResumableMultiRegex that can be used to walk + * the content and find matches. + * + * @param {CompiledMode} mode + * @returns {ResumableMultiRegex} + */ + function buildModeRegex(mode) { + const mm = new ResumableMultiRegex(); + + mode.contains.forEach(term => mm.addRule(term.begin, { rule: term, type: "begin" })); + + if (mode.terminatorEnd) { + mm.addRule(mode.terminatorEnd, { type: "end" }); + } + if (mode.illegal) { + mm.addRule(mode.illegal, { type: "illegal" }); + } + + return mm; + } + + /** skip vs abort vs ignore + * + * @skip - The mode is still entered and exited normally (and contains rules apply), + * but all content is held and added to the parent buffer rather than being + * output when the mode ends. Mostly used with `sublanguage` to build up + * a single large buffer than can be parsed by sublanguage. + * + * - The mode begin ands ends normally. + * - Content matched is added to the parent mode buffer. + * - The parser cursor is moved forward normally. + * + * @abort - A hack placeholder until we have ignore. Aborts the mode (as if it + * never matched) but DOES NOT continue to match subsequent `contains` + * modes. Abort is bad/suboptimal because it can result in modes + * farther down not getting applied because an earlier rule eats the + * content but then aborts. + * + * - The mode does not begin. + * - Content matched by `begin` is added to the mode buffer. + * - The parser cursor is moved forward accordingly. + * + * @ignore - Ignores the mode (as if it never matched) and continues to match any + * subsequent `contains` modes. Ignore isn't technically possible with + * the current parser implementation. + * + * - The mode does not begin. + * - Content matched by `begin` is ignored. + * - The parser cursor is not moved forward. + */ + + /** + * Compiles an individual mode + * + * This can raise an error if the mode contains certain detectable known logic + * issues. + * @param {Mode} mode + * @param {CompiledMode | null} [parent] + * @returns {CompiledMode | never} + */ + function compileMode(mode, parent) { + const cmode = /** @type CompiledMode */ (mode); + if (mode.isCompiled) return cmode; + + [ + scopeClassName, + // do this early so compiler extensions generally don't have to worry about + // the distinction between match/begin + compileMatch, + MultiClass, + beforeMatchExt + ].forEach(ext => ext(mode, parent)); + + language.compilerExtensions.forEach(ext => ext(mode, parent)); + + // __beforeBegin is considered private API, internal use only + mode.__beforeBegin = null; + + [ + beginKeywords, + // do this later so compiler extensions that come earlier have access to the + // raw array if they wanted to perhaps manipulate it, etc. + compileIllegal, + // default to 1 relevance if not specified + compileRelevance + ].forEach(ext => ext(mode, parent)); + + mode.isCompiled = true; + + let keywordPattern = null; + if (typeof mode.keywords === "object" && mode.keywords.$pattern) { + // we need a copy because keywords might be compiled multiple times + // so we can't go deleting $pattern from the original on the first + // pass + mode.keywords = Object.assign({}, mode.keywords); + keywordPattern = mode.keywords.$pattern; + delete mode.keywords.$pattern; + } + keywordPattern = keywordPattern || /\w+/; + + if (mode.keywords) { + mode.keywords = compileKeywords(mode.keywords, language.case_insensitive); + } + + cmode.keywordPatternRe = langRe(keywordPattern, true); + + if (parent) { + if (!mode.begin) mode.begin = /\B|\b/; + cmode.beginRe = langRe(cmode.begin); + if (!mode.end && !mode.endsWithParent) mode.end = /\B|\b/; + if (mode.end) cmode.endRe = langRe(cmode.end); + cmode.terminatorEnd = source(cmode.end) || ''; + if (mode.endsWithParent && parent.terminatorEnd) { + cmode.terminatorEnd += (mode.end ? '|' : '') + parent.terminatorEnd; + } + } + if (mode.illegal) cmode.illegalRe = langRe(/** @type {RegExp | string} */ (mode.illegal)); + if (!mode.contains) mode.contains = []; + + mode.contains = [].concat(...mode.contains.map(function(c) { + return expandOrCloneMode(c === 'self' ? mode : c); + })); + mode.contains.forEach(function(c) { compileMode(/** @type Mode */ (c), cmode); }); + + if (mode.starts) { + compileMode(mode.starts, parent); + } + + cmode.matcher = buildModeRegex(cmode); + return cmode; + } + + if (!language.compilerExtensions) language.compilerExtensions = []; + + // self is not valid at the top-level + if (language.contains && language.contains.includes('self')) { + throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation."); + } + + // we need a null object, which inherit will guarantee + language.classNameAliases = inherit$1(language.classNameAliases || {}); + + return compileMode(/** @type Mode */ (language)); +} + +/** + * Determines if a mode has a dependency on it's parent or not + * + * If a mode does have a parent dependency then often we need to clone it if + * it's used in multiple places so that each copy points to the correct parent, + * where-as modes without a parent can often safely be re-used at the bottom of + * a mode chain. + * + * @param {Mode | null} mode + * @returns {boolean} - is there a dependency on the parent? + * */ +function dependencyOnParent(mode) { + if (!mode) return false; + + return mode.endsWithParent || dependencyOnParent(mode.starts); +} + +/** + * Expands a mode or clones it if necessary + * + * This is necessary for modes with parental dependenceis (see notes on + * `dependencyOnParent`) and for nodes that have `variants` - which must then be + * exploded into their own individual modes at compile time. + * + * @param {Mode} mode + * @returns {Mode | Mode[]} + * */ +function expandOrCloneMode(mode) { + if (mode.variants && !mode.cachedVariants) { + mode.cachedVariants = mode.variants.map(function(variant) { + return inherit$1(mode, { variants: null }, variant); + }); + } + + // EXPAND + // if we have variants then essentially "replace" the mode with the variants + // this happens in compileMode, where this function is called from + if (mode.cachedVariants) { + return mode.cachedVariants; + } + + // CLONE + // if we have dependencies on parents then we need a unique + // instance of ourselves, so we can be reused with many + // different parents without issue + if (dependencyOnParent(mode)) { + return inherit$1(mode, { starts: mode.starts ? inherit$1(mode.starts) : null }); + } + + if (Object.isFrozen(mode)) { + return inherit$1(mode); + } + + // no special dependency issues, just return ourselves + return mode; +} + +var version = "11.10.0"; + +class HTMLInjectionError extends Error { + constructor(reason, html) { + super(reason); + this.name = "HTMLInjectionError"; + this.html = html; + } +} + +/* +Syntax highlighting with language autodetection. +https://highlightjs.org/ +*/ + + + +/** +@typedef {import('highlight.js').Mode} Mode +@typedef {import('highlight.js').CompiledMode} CompiledMode +@typedef {import('highlight.js').CompiledScope} CompiledScope +@typedef {import('highlight.js').Language} Language +@typedef {import('highlight.js').HLJSApi} HLJSApi +@typedef {import('highlight.js').HLJSPlugin} HLJSPlugin +@typedef {import('highlight.js').PluginEvent} PluginEvent +@typedef {import('highlight.js').HLJSOptions} HLJSOptions +@typedef {import('highlight.js').LanguageFn} LanguageFn +@typedef {import('highlight.js').HighlightedHTMLElement} HighlightedHTMLElement +@typedef {import('highlight.js').BeforeHighlightContext} BeforeHighlightContext +@typedef {import('highlight.js/private').MatchType} MatchType +@typedef {import('highlight.js/private').KeywordData} KeywordData +@typedef {import('highlight.js/private').EnhancedMatch} EnhancedMatch +@typedef {import('highlight.js/private').AnnotatedError} AnnotatedError +@typedef {import('highlight.js').AutoHighlightResult} AutoHighlightResult +@typedef {import('highlight.js').HighlightOptions} HighlightOptions +@typedef {import('highlight.js').HighlightResult} HighlightResult +*/ + + +const escape = escapeHTML; +const inherit = inherit$1; +const NO_MATCH = Symbol("nomatch"); +const MAX_KEYWORD_HITS = 7; + +/** + * @param {any} hljs - object that is extended (legacy) + * @returns {HLJSApi} + */ +const HLJS = function(hljs) { + // Global internal variables used within the highlight.js library. + /** @type {Record} */ + const languages = Object.create(null); + /** @type {Record} */ + const aliases = Object.create(null); + /** @type {HLJSPlugin[]} */ + const plugins = []; + + // safe/production mode - swallows more errors, tries to keep running + // even if a single syntax or parse hits a fatal error + let SAFE_MODE = true; + const LANGUAGE_NOT_FOUND = "Could not find the language '{}', did you forget to load/include a language module?"; + /** @type {Language} */ + const PLAINTEXT_LANGUAGE = { disableAutodetect: true, name: 'Plain text', contains: [] }; + + // Global options used when within external APIs. This is modified when + // calling the `hljs.configure` function. + /** @type HLJSOptions */ + let options = { + ignoreUnescapedHTML: false, + throwUnescapedHTML: false, + noHighlightRe: /^(no-?highlight)$/i, + languageDetectRe: /\blang(?:uage)?-([\w-]+)\b/i, + classPrefix: 'hljs-', + cssSelector: 'pre code', + languages: null, + // beta configuration options, subject to change, welcome to discuss + // https://github.com/highlightjs/highlight.js/issues/1086 + __emitter: TokenTreeEmitter + }; + + /* Utility functions */ + + /** + * Tests a language name to see if highlighting should be skipped + * @param {string} languageName + */ + function shouldNotHighlight(languageName) { + return options.noHighlightRe.test(languageName); + } + + /** + * @param {HighlightedHTMLElement} block - the HTML element to determine language for + */ + function blockLanguage(block) { + let classes = block.className + ' '; + + classes += block.parentNode ? block.parentNode.className : ''; + + // language-* takes precedence over non-prefixed class names. + const match = options.languageDetectRe.exec(classes); + if (match) { + const language = getLanguage(match[1]); + if (!language) { + warn(LANGUAGE_NOT_FOUND.replace("{}", match[1])); + warn("Falling back to no-highlight mode for this block.", block); + } + return language ? match[1] : 'no-highlight'; + } + + return classes + .split(/\s+/) + .find((_class) => shouldNotHighlight(_class) || getLanguage(_class)); + } + + /** + * Core highlighting function. + * + * OLD API + * highlight(lang, code, ignoreIllegals, continuation) + * + * NEW API + * highlight(code, {lang, ignoreIllegals}) + * + * @param {string} codeOrLanguageName - the language to use for highlighting + * @param {string | HighlightOptions} optionsOrCode - the code to highlight + * @param {boolean} [ignoreIllegals] - whether to ignore illegal matches, default is to bail + * + * @returns {HighlightResult} Result - an object that represents the result + * @property {string} language - the language name + * @property {number} relevance - the relevance score + * @property {string} value - the highlighted HTML code + * @property {string} code - the original raw code + * @property {CompiledMode} top - top of the current mode stack + * @property {boolean} illegal - indicates whether any illegal matches were found + */ + function highlight(codeOrLanguageName, optionsOrCode, ignoreIllegals) { + let code = ""; + let languageName = ""; + if (typeof optionsOrCode === "object") { + code = codeOrLanguageName; + ignoreIllegals = optionsOrCode.ignoreIllegals; + languageName = optionsOrCode.language; + } else { + // old API + deprecated("10.7.0", "highlight(lang, code, ...args) has been deprecated."); + deprecated("10.7.0", "Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"); + languageName = codeOrLanguageName; + code = optionsOrCode; + } + + // https://github.com/highlightjs/highlight.js/issues/3149 + // eslint-disable-next-line no-undefined + if (ignoreIllegals === undefined) { ignoreIllegals = true; } + + /** @type {BeforeHighlightContext} */ + const context = { + code, + language: languageName + }; + // the plugin can change the desired language or the code to be highlighted + // just be changing the object it was passed + fire("before:highlight", context); + + // a before plugin can usurp the result completely by providing it's own + // in which case we don't even need to call highlight + const result = context.result + ? context.result + : _highlight(context.language, context.code, ignoreIllegals); + + result.code = context.code; + // the plugin can change anything in result to suite it + fire("after:highlight", result); + + return result; + } + + /** + * private highlight that's used internally and does not fire callbacks + * + * @param {string} languageName - the language to use for highlighting + * @param {string} codeToHighlight - the code to highlight + * @param {boolean?} [ignoreIllegals] - whether to ignore illegal matches, default is to bail + * @param {CompiledMode?} [continuation] - current continuation mode, if any + * @returns {HighlightResult} - result of the highlight operation + */ + function _highlight(languageName, codeToHighlight, ignoreIllegals, continuation) { + const keywordHits = Object.create(null); + + /** + * Return keyword data if a match is a keyword + * @param {CompiledMode} mode - current mode + * @param {string} matchText - the textual match + * @returns {KeywordData | false} + */ + function keywordData(mode, matchText) { + return mode.keywords[matchText]; + } + + function processKeywords() { + if (!top.keywords) { + emitter.addText(modeBuffer); + return; + } + + let lastIndex = 0; + top.keywordPatternRe.lastIndex = 0; + let match = top.keywordPatternRe.exec(modeBuffer); + let buf = ""; + + while (match) { + buf += modeBuffer.substring(lastIndex, match.index); + const word = language.case_insensitive ? match[0].toLowerCase() : match[0]; + const data = keywordData(top, word); + if (data) { + const [kind, keywordRelevance] = data; + emitter.addText(buf); + buf = ""; + + keywordHits[word] = (keywordHits[word] || 0) + 1; + if (keywordHits[word] <= MAX_KEYWORD_HITS) relevance += keywordRelevance; + if (kind.startsWith("_")) { + // _ implied for relevance only, do not highlight + // by applying a class name + buf += match[0]; + } else { + const cssClass = language.classNameAliases[kind] || kind; + emitKeyword(match[0], cssClass); + } + } else { + buf += match[0]; + } + lastIndex = top.keywordPatternRe.lastIndex; + match = top.keywordPatternRe.exec(modeBuffer); + } + buf += modeBuffer.substring(lastIndex); + emitter.addText(buf); + } + + function processSubLanguage() { + if (modeBuffer === "") return; + /** @type HighlightResult */ + let result = null; + + if (typeof top.subLanguage === 'string') { + if (!languages[top.subLanguage]) { + emitter.addText(modeBuffer); + return; + } + result = _highlight(top.subLanguage, modeBuffer, true, continuations[top.subLanguage]); + continuations[top.subLanguage] = /** @type {CompiledMode} */ (result._top); + } else { + result = highlightAuto(modeBuffer, top.subLanguage.length ? top.subLanguage : null); + } + + // Counting embedded language score towards the host language may be disabled + // with zeroing the containing mode relevance. Use case in point is Markdown that + // allows XML everywhere and makes every XML snippet to have a much larger Markdown + // score. + if (top.relevance > 0) { + relevance += result.relevance; + } + emitter.__addSublanguage(result._emitter, result.language); + } + + function processBuffer() { + if (top.subLanguage != null) { + processSubLanguage(); + } else { + processKeywords(); + } + modeBuffer = ''; + } + + /** + * @param {string} text + * @param {string} scope + */ + function emitKeyword(keyword, scope) { + if (keyword === "") return; + + emitter.startScope(scope); + emitter.addText(keyword); + emitter.endScope(); + } + + /** + * @param {CompiledScope} scope + * @param {RegExpMatchArray} match + */ + function emitMultiClass(scope, match) { + let i = 1; + const max = match.length - 1; + while (i <= max) { + if (!scope._emit[i]) { i++; continue; } + const klass = language.classNameAliases[scope[i]] || scope[i]; + const text = match[i]; + if (klass) { + emitKeyword(text, klass); + } else { + modeBuffer = text; + processKeywords(); + modeBuffer = ""; + } + i++; + } + } + + /** + * @param {CompiledMode} mode - new mode to start + * @param {RegExpMatchArray} match + */ + function startNewMode(mode, match) { + if (mode.scope && typeof mode.scope === "string") { + emitter.openNode(language.classNameAliases[mode.scope] || mode.scope); + } + if (mode.beginScope) { + // beginScope just wraps the begin match itself in a scope + if (mode.beginScope._wrap) { + emitKeyword(modeBuffer, language.classNameAliases[mode.beginScope._wrap] || mode.beginScope._wrap); + modeBuffer = ""; + } else if (mode.beginScope._multi) { + // at this point modeBuffer should just be the match + emitMultiClass(mode.beginScope, match); + modeBuffer = ""; + } + } + + top = Object.create(mode, { parent: { value: top } }); + return top; + } + + /** + * @param {CompiledMode } mode - the mode to potentially end + * @param {RegExpMatchArray} match - the latest match + * @param {string} matchPlusRemainder - match plus remainder of content + * @returns {CompiledMode | void} - the next mode, or if void continue on in current mode + */ + function endOfMode(mode, match, matchPlusRemainder) { + let matched = startsWith(mode.endRe, matchPlusRemainder); + + if (matched) { + if (mode["on:end"]) { + const resp = new Response(mode); + mode["on:end"](match, resp); + if (resp.isMatchIgnored) matched = false; + } + + if (matched) { + while (mode.endsParent && mode.parent) { + mode = mode.parent; + } + return mode; + } + } + // even if on:end fires an `ignore` it's still possible + // that we might trigger the end node because of a parent mode + if (mode.endsWithParent) { + return endOfMode(mode.parent, match, matchPlusRemainder); + } + } + + /** + * Handle matching but then ignoring a sequence of text + * + * @param {string} lexeme - string containing full match text + */ + function doIgnore(lexeme) { + if (top.matcher.regexIndex === 0) { + // no more regexes to potentially match here, so we move the cursor forward one + // space + modeBuffer += lexeme[0]; + return 1; + } else { + // no need to move the cursor, we still have additional regexes to try and + // match at this very spot + resumeScanAtSamePosition = true; + return 0; + } + } + + /** + * Handle the start of a new potential mode match + * + * @param {EnhancedMatch} match - the current match + * @returns {number} how far to advance the parse cursor + */ + function doBeginMatch(match) { + const lexeme = match[0]; + const newMode = match.rule; + + const resp = new Response(newMode); + // first internal before callbacks, then the public ones + const beforeCallbacks = [newMode.__beforeBegin, newMode["on:begin"]]; + for (const cb of beforeCallbacks) { + if (!cb) continue; + cb(match, resp); + if (resp.isMatchIgnored) return doIgnore(lexeme); + } + + if (newMode.skip) { + modeBuffer += lexeme; + } else { + if (newMode.excludeBegin) { + modeBuffer += lexeme; + } + processBuffer(); + if (!newMode.returnBegin && !newMode.excludeBegin) { + modeBuffer = lexeme; + } + } + startNewMode(newMode, match); + return newMode.returnBegin ? 0 : lexeme.length; + } + + /** + * Handle the potential end of mode + * + * @param {RegExpMatchArray} match - the current match + */ + function doEndMatch(match) { + const lexeme = match[0]; + const matchPlusRemainder = codeToHighlight.substring(match.index); + + const endMode = endOfMode(top, match, matchPlusRemainder); + if (!endMode) { return NO_MATCH; } + + const origin = top; + if (top.endScope && top.endScope._wrap) { + processBuffer(); + emitKeyword(lexeme, top.endScope._wrap); + } else if (top.endScope && top.endScope._multi) { + processBuffer(); + emitMultiClass(top.endScope, match); + } else if (origin.skip) { + modeBuffer += lexeme; + } else { + if (!(origin.returnEnd || origin.excludeEnd)) { + modeBuffer += lexeme; + } + processBuffer(); + if (origin.excludeEnd) { + modeBuffer = lexeme; + } + } + do { + if (top.scope) { + emitter.closeNode(); + } + if (!top.skip && !top.subLanguage) { + relevance += top.relevance; + } + top = top.parent; + } while (top !== endMode.parent); + if (endMode.starts) { + startNewMode(endMode.starts, match); + } + return origin.returnEnd ? 0 : lexeme.length; + } + + function processContinuations() { + const list = []; + for (let current = top; current !== language; current = current.parent) { + if (current.scope) { + list.unshift(current.scope); + } + } + list.forEach(item => emitter.openNode(item)); + } + + /** @type {{type?: MatchType, index?: number, rule?: Mode}}} */ + let lastMatch = {}; + + /** + * Process an individual match + * + * @param {string} textBeforeMatch - text preceding the match (since the last match) + * @param {EnhancedMatch} [match] - the match itself + */ + function processLexeme(textBeforeMatch, match) { + const lexeme = match && match[0]; + + // add non-matched text to the current mode buffer + modeBuffer += textBeforeMatch; + + if (lexeme == null) { + processBuffer(); + return 0; + } + + // we've found a 0 width match and we're stuck, so we need to advance + // this happens when we have badly behaved rules that have optional matchers to the degree that + // sometimes they can end up matching nothing at all + // Ref: https://github.com/highlightjs/highlight.js/issues/2140 + if (lastMatch.type === "begin" && match.type === "end" && lastMatch.index === match.index && lexeme === "") { + // spit the "skipped" character that our regex choked on back into the output sequence + modeBuffer += codeToHighlight.slice(match.index, match.index + 1); + if (!SAFE_MODE) { + /** @type {AnnotatedError} */ + const err = new Error(`0 width match regex (${languageName})`); + err.languageName = languageName; + err.badRule = lastMatch.rule; + throw err; + } + return 1; + } + lastMatch = match; + + if (match.type === "begin") { + return doBeginMatch(match); + } else if (match.type === "illegal" && !ignoreIllegals) { + // illegal match, we do not continue processing + /** @type {AnnotatedError} */ + const err = new Error('Illegal lexeme "' + lexeme + '" for mode "' + (top.scope || '') + '"'); + err.mode = top; + throw err; + } else if (match.type === "end") { + const processed = doEndMatch(match); + if (processed !== NO_MATCH) { + return processed; + } + } + + // edge case for when illegal matches $ (end of line) which is technically + // a 0 width match but not a begin/end match so it's not caught by the + // first handler (when ignoreIllegals is true) + if (match.type === "illegal" && lexeme === "") { + // advance so we aren't stuck in an infinite loop + return 1; + } + + // infinite loops are BAD, this is a last ditch catch all. if we have a + // decent number of iterations yet our index (cursor position in our + // parsing) still 3x behind our index then something is very wrong + // so we bail + if (iterations > 100000 && iterations > match.index * 3) { + const err = new Error('potential infinite loop, way more iterations than matches'); + throw err; + } + + /* + Why might be find ourselves here? An potential end match that was + triggered but could not be completed. IE, `doEndMatch` returned NO_MATCH. + (this could be because a callback requests the match be ignored, etc) + + This causes no real harm other than stopping a few times too many. + */ + + modeBuffer += lexeme; + return lexeme.length; + } + + const language = getLanguage(languageName); + if (!language) { + error(LANGUAGE_NOT_FOUND.replace("{}", languageName)); + throw new Error('Unknown language: "' + languageName + '"'); + } + + const md = compileLanguage(language); + let result = ''; + /** @type {CompiledMode} */ + let top = continuation || md; + /** @type Record */ + const continuations = {}; // keep continuations for sub-languages + const emitter = new options.__emitter(options); + processContinuations(); + let modeBuffer = ''; + let relevance = 0; + let index = 0; + let iterations = 0; + let resumeScanAtSamePosition = false; + + try { + if (!language.__emitTokens) { + top.matcher.considerAll(); + + for (;;) { + iterations++; + if (resumeScanAtSamePosition) { + // only regexes not matched previously will now be + // considered for a potential match + resumeScanAtSamePosition = false; + } else { + top.matcher.considerAll(); + } + top.matcher.lastIndex = index; + + const match = top.matcher.exec(codeToHighlight); + // console.log("match", match[0], match.rule && match.rule.begin) + + if (!match) break; + + const beforeMatch = codeToHighlight.substring(index, match.index); + const processedCount = processLexeme(beforeMatch, match); + index = match.index + processedCount; + } + processLexeme(codeToHighlight.substring(index)); + } else { + language.__emitTokens(codeToHighlight, emitter); + } + + emitter.finalize(); + result = emitter.toHTML(); + + return { + language: languageName, + value: result, + relevance, + illegal: false, + _emitter: emitter, + _top: top + }; + } catch (err) { + if (err.message && err.message.includes('Illegal')) { + return { + language: languageName, + value: escape(codeToHighlight), + illegal: true, + relevance: 0, + _illegalBy: { + message: err.message, + index, + context: codeToHighlight.slice(index - 100, index + 100), + mode: err.mode, + resultSoFar: result + }, + _emitter: emitter + }; + } else if (SAFE_MODE) { + return { + language: languageName, + value: escape(codeToHighlight), + illegal: false, + relevance: 0, + errorRaised: err, + _emitter: emitter, + _top: top + }; + } else { + throw err; + } + } + } + + /** + * returns a valid highlight result, without actually doing any actual work, + * auto highlight starts with this and it's possible for small snippets that + * auto-detection may not find a better match + * @param {string} code + * @returns {HighlightResult} + */ + function justTextHighlightResult(code) { + const result = { + value: escape(code), + illegal: false, + relevance: 0, + _top: PLAINTEXT_LANGUAGE, + _emitter: new options.__emitter(options) + }; + result._emitter.addText(code); + return result; + } + + /** + Highlighting with language detection. Accepts a string with the code to + highlight. Returns an object with the following properties: + + - language (detected language) + - relevance (int) + - value (an HTML string with highlighting markup) + - secondBest (object with the same structure for second-best heuristically + detected language, may be absent) + + @param {string} code + @param {Array} [languageSubset] + @returns {AutoHighlightResult} + */ + function highlightAuto(code, languageSubset) { + languageSubset = languageSubset || options.languages || Object.keys(languages); + const plaintext = justTextHighlightResult(code); + + const results = languageSubset.filter(getLanguage).filter(autoDetection).map(name => + _highlight(name, code, false) + ); + results.unshift(plaintext); // plaintext is always an option + + const sorted = results.sort((a, b) => { + // sort base on relevance + if (a.relevance !== b.relevance) return b.relevance - a.relevance; + + // always award the tie to the base language + // ie if C++ and Arduino are tied, it's more likely to be C++ + if (a.language && b.language) { + if (getLanguage(a.language).supersetOf === b.language) { + return 1; + } else if (getLanguage(b.language).supersetOf === a.language) { + return -1; + } + } + + // otherwise say they are equal, which has the effect of sorting on + // relevance while preserving the original ordering - which is how ties + // have historically been settled, ie the language that comes first always + // wins in the case of a tie + return 0; + }); + + const [best, secondBest] = sorted; + + /** @type {AutoHighlightResult} */ + const result = best; + result.secondBest = secondBest; + + return result; + } + + /** + * Builds new class name for block given the language name + * + * @param {HTMLElement} element + * @param {string} [currentLang] + * @param {string} [resultLang] + */ + function updateClassName(element, currentLang, resultLang) { + const language = (currentLang && aliases[currentLang]) || resultLang; + + element.classList.add("hljs"); + element.classList.add(`language-${language}`); + } + + /** + * Applies highlighting to a DOM node containing code. + * + * @param {HighlightedHTMLElement} element - the HTML element to highlight + */ + function highlightElement(element) { + /** @type HTMLElement */ + let node = null; + const language = blockLanguage(element); + + if (shouldNotHighlight(language)) return; + + fire("before:highlightElement", + { el: element, language }); + + if (element.dataset.highlighted) { + console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.", element); + return; + } + + // we should be all text, no child nodes (unescaped HTML) - this is possibly + // an HTML injection attack - it's likely too late if this is already in + // production (the code has likely already done its damage by the time + // we're seeing it)... but we yell loudly about this so that hopefully it's + // more likely to be caught in development before making it to production + if (element.children.length > 0) { + if (!options.ignoreUnescapedHTML) { + console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."); + console.warn("https://github.com/highlightjs/highlight.js/wiki/security"); + console.warn("The element with unescaped HTML:"); + console.warn(element); + } + if (options.throwUnescapedHTML) { + const err = new HTMLInjectionError( + "One of your code blocks includes unescaped HTML.", + element.innerHTML + ); + throw err; + } + } + + node = element; + const text = node.textContent; + const result = language ? highlight(text, { language, ignoreIllegals: true }) : highlightAuto(text); + + element.innerHTML = result.value; + element.dataset.highlighted = "yes"; + updateClassName(element, language, result.language); + element.result = { + language: result.language, + // TODO: remove with version 11.0 + re: result.relevance, + relevance: result.relevance + }; + if (result.secondBest) { + element.secondBest = { + language: result.secondBest.language, + relevance: result.secondBest.relevance + }; + } + + fire("after:highlightElement", { el: element, result, text }); + } + + /** + * Updates highlight.js global options with the passed options + * + * @param {Partial} userOptions + */ + function configure(userOptions) { + options = inherit(options, userOptions); + } + + // TODO: remove v12, deprecated + const initHighlighting = () => { + highlightAll(); + deprecated("10.6.0", "initHighlighting() deprecated. Use highlightAll() now."); + }; + + // TODO: remove v12, deprecated + function initHighlightingOnLoad() { + highlightAll(); + deprecated("10.6.0", "initHighlightingOnLoad() deprecated. Use highlightAll() now."); + } + + let wantsHighlight = false; + + /** + * auto-highlights all pre>code elements on the page + */ + function highlightAll() { + // if we are called too early in the loading process + if (document.readyState === "loading") { + wantsHighlight = true; + return; + } + + const blocks = document.querySelectorAll(options.cssSelector); + blocks.forEach(highlightElement); + } + + function boot() { + // if a highlight was requested before DOM was loaded, do now + if (wantsHighlight) highlightAll(); + } + + // make sure we are in the browser environment + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('DOMContentLoaded', boot, false); + } + + /** + * Register a language grammar module + * + * @param {string} languageName + * @param {LanguageFn} languageDefinition + */ + function registerLanguage(languageName, languageDefinition) { + let lang = null; + try { + lang = languageDefinition(hljs); + } catch (error$1) { + error("Language definition for '{}' could not be registered.".replace("{}", languageName)); + // hard or soft error + if (!SAFE_MODE) { throw error$1; } else { error(error$1); } + // languages that have serious errors are replaced with essentially a + // "plaintext" stand-in so that the code blocks will still get normal + // css classes applied to them - and one bad language won't break the + // entire highlighter + lang = PLAINTEXT_LANGUAGE; + } + // give it a temporary name if it doesn't have one in the meta-data + if (!lang.name) lang.name = languageName; + languages[languageName] = lang; + lang.rawDefinition = languageDefinition.bind(null, hljs); + + if (lang.aliases) { + registerAliases(lang.aliases, { languageName }); + } + } + + /** + * Remove a language grammar module + * + * @param {string} languageName + */ + function unregisterLanguage(languageName) { + delete languages[languageName]; + for (const alias of Object.keys(aliases)) { + if (aliases[alias] === languageName) { + delete aliases[alias]; + } + } + } + + /** + * @returns {string[]} List of language internal names + */ + function listLanguages() { + return Object.keys(languages); + } + + /** + * @param {string} name - name of the language to retrieve + * @returns {Language | undefined} + */ + function getLanguage(name) { + name = (name || '').toLowerCase(); + return languages[name] || languages[aliases[name]]; + } + + /** + * + * @param {string|string[]} aliasList - single alias or list of aliases + * @param {{languageName: string}} opts + */ + function registerAliases(aliasList, { languageName }) { + if (typeof aliasList === 'string') { + aliasList = [aliasList]; + } + aliasList.forEach(alias => { aliases[alias.toLowerCase()] = languageName; }); + } + + /** + * Determines if a given language has auto-detection enabled + * @param {string} name - name of the language + */ + function autoDetection(name) { + const lang = getLanguage(name); + return lang && !lang.disableAutodetect; + } + + /** + * Upgrades the old highlightBlock plugins to the new + * highlightElement API + * @param {HLJSPlugin} plugin + */ + function upgradePluginAPI(plugin) { + // TODO: remove with v12 + if (plugin["before:highlightBlock"] && !plugin["before:highlightElement"]) { + plugin["before:highlightElement"] = (data) => { + plugin["before:highlightBlock"]( + Object.assign({ block: data.el }, data) + ); + }; + } + if (plugin["after:highlightBlock"] && !plugin["after:highlightElement"]) { + plugin["after:highlightElement"] = (data) => { + plugin["after:highlightBlock"]( + Object.assign({ block: data.el }, data) + ); + }; + } + } + + /** + * @param {HLJSPlugin} plugin + */ + function addPlugin(plugin) { + upgradePluginAPI(plugin); + plugins.push(plugin); + } + + /** + * @param {HLJSPlugin} plugin + */ + function removePlugin(plugin) { + const index = plugins.indexOf(plugin); + if (index !== -1) { + plugins.splice(index, 1); + } + } + + /** + * + * @param {PluginEvent} event + * @param {any} args + */ + function fire(event, args) { + const cb = event; + plugins.forEach(function(plugin) { + if (plugin[cb]) { + plugin[cb](args); + } + }); + } + + /** + * DEPRECATED + * @param {HighlightedHTMLElement} el + */ + function deprecateHighlightBlock(el) { + deprecated("10.7.0", "highlightBlock will be removed entirely in v12.0"); + deprecated("10.7.0", "Please use highlightElement now."); + + return highlightElement(el); + } + + /* Interface definition */ + Object.assign(hljs, { + highlight, + highlightAuto, + highlightAll, + highlightElement, + // TODO: Remove with v12 API + highlightBlock: deprecateHighlightBlock, + configure, + initHighlighting, + initHighlightingOnLoad, + registerLanguage, + unregisterLanguage, + listLanguages, + getLanguage, + registerAliases, + autoDetection, + inherit, + addPlugin, + removePlugin + }); + + hljs.debugMode = function() { SAFE_MODE = false; }; + hljs.safeMode = function() { SAFE_MODE = true; }; + hljs.versionString = version; + + hljs.regex = { + concat: concat, + lookahead: lookahead, + either: either, + optional: optional, + anyNumberOfTimes: anyNumberOfTimes + }; + + for (const key in MODES) { + // @ts-ignore + if (typeof MODES[key] === "object") { + // @ts-ignore + deepFreeze(MODES[key]); + } + } + + // merge all the modes/regexes into our main object + Object.assign(hljs, MODES); + + return hljs; +}; + +// Other names for the variable may break build script +const highlight = HLJS({}); + +// returns a new instance of the highlighter to be used for extensions +// check https://github.com/wooorm/lowlight/issues/47 +highlight.newInstance = () => HLJS({}); + +export { highlight as default }; diff --git a/public/assets/highlight/es/highlight.min.js b/public/assets/highlight/es/highlight.min.js new file mode 100644 index 00000000..8e6dfb50 --- /dev/null +++ b/public/assets/highlight/es/highlight.min.js @@ -0,0 +1,307 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +function e(t){return t instanceof Map?t.clear=t.delete=t.set=()=>{ +throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{ +const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i) +})),t}class t{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function n(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] +;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope +;class r{constructor(e,t){ +this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ +this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const n=e.split(".") +;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ") +}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)} +closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const o=(e={})=>{const t={children:[]} +;return Object.assign(t,e),t};class a{constructor(){ +this.rootNode=o(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const t=o({scope:e}) +;this.add(t),this.stack.push(t)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ +return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), +t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,t){const n=e.root +;t&&(n.scope="language:"+t),this.add(n)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function l(e){ +return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")} +function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")} +function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{ +const t=e[e.length-1] +;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{} +})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n +;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break} +s+=i.substring(0,e.index), +i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0], +"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)} +const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",y="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_="\\b(0b[01]+)",O={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[O]},v={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t, +contains:[]},n);s.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return s.contains.push({begin:h(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s +},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var A=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{ +scope:"number",begin:_,relevance:0},BINARY_NUMBER_RE:_,COMMENT:N, +C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number", +begin:y,relevance:0},C_NUMBER_RE:y,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0}, +NUMBER_MODE:{scope:"number",begin:w,relevance:0},NUMBER_RE:w, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:v,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const t=/^#![ ]*\// +;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t, +end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function j(e,t){ +"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){ +t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=j,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function L(e,t){ +Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function P(e,t){ +void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t] +})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={ +relevance:0,contains:[Object.assign(n,{endsParent:!0})] +},e.relevance=0,delete n.beforeMatch +},H=["of","and","for","in","not","or","if","then","parent","list","value"],C="keyword" +;function $(e,t,n=C){const i=Object.create(null) +;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ +Object.assign(i,$(e[n],t,n))})),i;function s(e,n){ +t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") +;i[n[0]]=[e,U(n[0],n[1])]}))}}function U(e,t){ +return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const z={},W=e=>{ +console.error(e)},X=(e,...t)=>{console.log("WARN: "+e,...t)},G=(e,t)=>{ +z[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),z[`${e}/${t}`]=!0) +},K=Error();function F(e,t,{key:n}){let i=0;const s=e[n],r={},o={} +;for(let e=1;e<=t.length;e++)o[e+i]=s[e],r[e+i]=!0,i+=p(t[e-1]) +;e[n]=o,e[n]._emit=r,e[n]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw W("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +K +;if("object"!=typeof e.beginScope||null===e.beginScope)throw W("beginScope must be object"), +K;F(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw W("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +K +;if("object"!=typeof e.endScope||null===e.endScope)throw W("endScope must be object"), +K;F(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function V(e){ +function t(t,n){ +return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":"")) +}class n{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,t){ +t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const t=this.matcherRe.exec(e);if(!t)return null +;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] +;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n +;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), +t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ +this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ +const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex +;let n=t.exec(e) +;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ +const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} +return n&&(this.regexIndex+=n.position+1, +this.regexIndex===this.count&&this.considerAll()),n}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=i(e.classNameAliases||{}),function n(r,o){const a=r +;if(r.isCompiled)return a +;[I,B,Z,D].forEach((e=>e(r,o))),e.compilerExtensions.forEach((e=>e(r,o))), +r.__beforeBegin=null,[T,L,P].forEach((e=>e(r,o))),r.isCompiled=!0;let c=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +c=r.keywords.$pattern, +delete r.keywords.$pattern),c=c||/\w+/,r.keywords&&(r.keywords=$(r.keywords,e.case_insensitive)), +a.keywordPatternRe=t(c,!0), +o&&(r.begin||(r.begin=/\B|\b/),a.beginRe=t(a.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(a.endRe=t(a.end)), +a.terminatorEnd=l(a.end)||"",r.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)), +r.illegal&&(a.illegalRe=t(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{ +variants:null},t)))),e.cachedVariants?e.cachedVariants:q(e)?i(e,{ +starts:e.starts?i(e.starts):null +}):Object.isFrozen(e)?i(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{n(e,a) +})),r.starts&&n(r.starts,o),a.matcher=(e=>{const t=new s +;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function q(e){ +return!!e&&(e.endsWithParent||q(e.starts))}class J extends Error{ +constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}} +const Y=n,Q=i,ee=Symbol("nomatch"),te=n=>{ +const i=Object.create(null),s=Object.create(null),r=[];let o=!0 +;const a="Could not find the language '{}', did you forget to load/include a language module?",l={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:c};function b(e){ +return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s="" +;"object"==typeof t?(i=e, +n=t.ignoreIllegals,s=t.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."), +G("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +s=e,i=t),void 0===n&&(n=!0);const r={code:i,language:s};N("before:highlight",r) +;const o=r.result?r.result:E(r.language,r.code,n) +;return o.code=r.code,N("after:highlight",o),o}function E(e,n,s,r){ +const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R) +;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n="" +;for(;t;){n+=R.substring(e,t.index) +;const s=_.case_insensitive?t[0].toLowerCase():t[0],r=(i=s,N.keywords[i]);if(r){ +const[e,i]=r +;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(A+=i),e.startsWith("_"))n+=t[0];else{ +const n=_.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0] +;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i +;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{ +if(""===R)return;let e=null;if("string"==typeof N.subLanguage){ +if(!i[N.subLanguage])return void M.addText(R) +;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top +}else e=x(R,N.subLanguage.length?N.subLanguage:null) +;N.relevance>0&&(A+=e.relevance),M.__addSublanguage(e._emitter,e.language) +})():l(),R=""}function u(e,t){ +""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1 +;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue} +const i=_.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}} +function h(e,t){ +return e.scope&&"string"==typeof e.scope&&M.openNode(_.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(R,_.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{ +value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t) +;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e) +;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return f(e.parent,n,i)}function b(e){ +return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){ +const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return ee;const r=N +;N.endScope&&N.endScope._wrap?(g(), +u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(), +d(N.endScope,e)):r.skip?R+=t:(r.returnEnd||r.excludeEnd||(R+=t), +g(),r.excludeEnd&&(R=t));do{ +N.scope&&M.closeNode(),N.skip||N.subLanguage||(A+=N.relevance),N=N.parent +}while(N!==s.parent);return s.starts&&h(s.starts,e),r.returnEnd?0:t.length} +let w={};function y(i,r){const a=r&&r[0];if(R+=i,null==a)return g(),0 +;if("begin"===w.type&&"end"===r.type&&w.index===r.index&&""===a){ +if(R+=n.slice(r.index,r.index+1),!o){const t=Error(`0 width match regex (${e})`) +;throw t.languageName=e,t.badRule=w.rule,t}return 1} +if(w=r,"begin"===r.type)return(e=>{ +const n=e[0],i=e.rule,s=new t(i),r=[i.__beforeBegin,i["on:begin"]] +;for(const t of r)if(t&&(t(e,s),s.isMatchIgnored))return b(n) +;return i.skip?R+=n:(i.excludeBegin&&(R+=n), +g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(r) +;if("illegal"===r.type&&!s){ +const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"') +;throw e.mode=N,e}if("end"===r.type){const e=m(r);if(e!==ee)return e} +if("illegal"===r.type&&""===a)return 1 +;if(I>1e5&&I>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return R+=a,a.length}const _=O(e) +;if(!_)throw W(a.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=V(_);let v="",N=r||k;const S={},M=new p.__emitter(p);(()=>{const e=[] +;for(let t=N;t!==_;t=t.parent)t.scope&&e.unshift(t.scope) +;e.forEach((e=>M.openNode(e)))})();let R="",A=0,j=0,I=0,T=!1;try{ +if(_.__emitTokens)_.__emitTokens(n,M);else{for(N.matcher.considerAll();;){ +I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=j +;const e=N.matcher.exec(n);if(!e)break;const t=y(n.substring(j,e.index),e) +;j=e.index+t}y(n.substring(j))}return M.finalize(),v=M.toHTML(),{language:e, +value:v,relevance:A,illegal:!1,_emitter:M,_top:N}}catch(t){ +if(t.message&&t.message.includes("Illegal"))return{language:e,value:Y(n), +illegal:!0,relevance:0,_illegalBy:{message:t.message,index:j, +context:n.slice(j-100,j+100),mode:t.mode,resultSoFar:v},_emitter:M};if(o)return{ +language:e,value:Y(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N} +;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{ +const t={value:Y(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(v).map((t=>E(t,e,!1))) +;s.unshift(n);const r=s.sort(((e,t)=>{ +if(e.relevance!==t.relevance)return t.relevance-e.relevance +;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1 +;if(O(t.language).supersetOf===e.language)return-1}return 0})),[o,a]=r,c=o +;return c.secondBest=a,c}function w(e){let t=null;const n=(e=>{ +let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"" +;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1]) +;return t||(X(a.replace("{}",n[1])), +X("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"} +return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return +;if(N("before:highlightElement",{el:e,language:n +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new J("One of your code blocks includes unescaped HTML.",e.innerHTML) +;t=e;const i=t.textContent,r=n?m(i,{language:n,ignoreIllegals:!0}):x(i) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n +;e.classList.add("hljs"),e.classList.add("language-"+i) +})(e,n,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),N("after:highlightElement",{el:e,result:r,text:i})}let y=!1;function _(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(w):y=!0 +}function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]} +function k(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +s[e.toLowerCase()]=t}))}function v(e){const t=O(e) +;return t&&!t.disableAutodetect}function N(e,t){const n=e;r.forEach((e=>{ +e[n]&&e[n](t)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +y&&_()}),!1),Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:_, +highlightElement:w, +highlightBlock:e=>(G("10.7.0","highlightBlock will be removed entirely in v12.0"), +G("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{p=Q(p,e)}, +initHighlighting:()=>{ +_(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +_(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){ +if(W("Language definition for '{}' could not be registered.".replace("{}",e)), +!o)throw t;W(t),s=l} +s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&k(s.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete i[e] +;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, +listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:k, +autoDetection:v,inherit:Q,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ +e["before:highlightBlock"](Object.assign({block:t.el},t)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ +e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),r.push(e)}, +removePlugin:e=>{const t=r.indexOf(e);-1!==t&&r.splice(t,1)}}),n.debugMode=()=>{ +o=!1},n.safeMode=()=>{o=!0},n.versionString="11.10.0",n.regex={concat:h, +lookahead:g,either:f,optional:d,anyNumberOfTimes:u} +;for(const t in A)"object"==typeof A[t]&&e(A[t]);return Object.assign(n,A),n +},ne=te({});ne.newInstance=()=>te({});export{ne as default}; \ No newline at end of file diff --git a/public/assets/highlight/es/languages/bash.js b/public/assets/highlight/es/languages/bash.js new file mode 100644 index 00000000..ec067a70 --- /dev/null +++ b/public/assets/highlight/es/languages/bash.js @@ -0,0 +1,415 @@ +/*! `bash` grammar compiled for Highlight.js 11.10.0 */ +var hljsGrammar = (function () { + 'use strict'; + + /* + Language: Bash + Author: vah + Contributrors: Benjamin Pannell + Website: https://www.gnu.org/software/bash/ + Category: common, scripting + */ + + /** @type LanguageFn */ + function bash(hljs) { + const regex = hljs.regex; + const VAR = {}; + const BRACED_VAR = { + begin: /\$\{/, + end: /\}/, + contains: [ + "self", + { + begin: /:-/, + contains: [ VAR ] + } // default values + ] + }; + Object.assign(VAR, { + className: 'variable', + variants: [ + { begin: regex.concat(/\$[\w\d#@][\w\d_]*/, + // negative look-ahead tries to avoid matching patterns that are not + // Perl at all like $ident$, @ident@, etc. + `(?![\\w\\d])(?![$])`) }, + BRACED_VAR + ] + }); + + const SUBST = { + className: 'subst', + begin: /\$\(/, + end: /\)/, + contains: [ hljs.BACKSLASH_ESCAPE ] + }; + const COMMENT = hljs.inherit( + hljs.COMMENT(), + { + match: [ + /(^|\s)/, + /#.*$/ + ], + scope: { + 2: 'comment' + } + } + ); + const HERE_DOC = { + begin: /<<-?\s*(?=\w+)/, + starts: { contains: [ + hljs.END_SAME_AS_BEGIN({ + begin: /(\w+)/, + end: /(\w+)/, + className: 'string' + }) + ] } + }; + const QUOTE_STRING = { + className: 'string', + begin: /"/, + end: /"/, + contains: [ + hljs.BACKSLASH_ESCAPE, + VAR, + SUBST + ] + }; + SUBST.contains.push(QUOTE_STRING); + const ESCAPED_QUOTE = { + match: /\\"/ + }; + const APOS_STRING = { + className: 'string', + begin: /'/, + end: /'/ + }; + const ESCAPED_APOS = { + match: /\\'/ + }; + const ARITHMETIC = { + begin: /\$?\(\(/, + end: /\)\)/, + contains: [ + { + begin: /\d+#[0-9a-f]+/, + className: "number" + }, + hljs.NUMBER_MODE, + VAR + ] + }; + const SH_LIKE_SHELLS = [ + "fish", + "bash", + "zsh", + "sh", + "csh", + "ksh", + "tcsh", + "dash", + "scsh", + ]; + const KNOWN_SHEBANG = hljs.SHEBANG({ + binary: `(${SH_LIKE_SHELLS.join("|")})`, + relevance: 10 + }); + const FUNCTION = { + className: 'function', + begin: /\w[\w\d_]*\s*\(\s*\)\s*\{/, + returnBegin: true, + contains: [ hljs.inherit(hljs.TITLE_MODE, { begin: /\w[\w\d_]*/ }) ], + relevance: 0 + }; + + const KEYWORDS = [ + "if", + "then", + "else", + "elif", + "fi", + "for", + "while", + "until", + "in", + "do", + "done", + "case", + "esac", + "function", + "select" + ]; + + const LITERALS = [ + "true", + "false" + ]; + + // to consume paths to prevent keyword matches inside them + const PATH_MODE = { match: /(\/[a-z._-]+)+/ }; + + // http://www.gnu.org/software/bash/manual/html_node/Shell-Builtin-Commands.html + const SHELL_BUILT_INS = [ + "break", + "cd", + "continue", + "eval", + "exec", + "exit", + "export", + "getopts", + "hash", + "pwd", + "readonly", + "return", + "shift", + "test", + "times", + "trap", + "umask", + "unset" + ]; + + const BASH_BUILT_INS = [ + "alias", + "bind", + "builtin", + "caller", + "command", + "declare", + "echo", + "enable", + "help", + "let", + "local", + "logout", + "mapfile", + "printf", + "read", + "readarray", + "source", + "sudo", + "type", + "typeset", + "ulimit", + "unalias" + ]; + + const ZSH_BUILT_INS = [ + "autoload", + "bg", + "bindkey", + "bye", + "cap", + "chdir", + "clone", + "comparguments", + "compcall", + "compctl", + "compdescribe", + "compfiles", + "compgroups", + "compquote", + "comptags", + "comptry", + "compvalues", + "dirs", + "disable", + "disown", + "echotc", + "echoti", + "emulate", + "fc", + "fg", + "float", + "functions", + "getcap", + "getln", + "history", + "integer", + "jobs", + "kill", + "limit", + "log", + "noglob", + "popd", + "print", + "pushd", + "pushln", + "rehash", + "sched", + "setcap", + "setopt", + "stat", + "suspend", + "ttyctl", + "unfunction", + "unhash", + "unlimit", + "unsetopt", + "vared", + "wait", + "whence", + "where", + "which", + "zcompile", + "zformat", + "zftp", + "zle", + "zmodload", + "zparseopts", + "zprof", + "zpty", + "zregexparse", + "zsocket", + "zstyle", + "ztcp" + ]; + + const GNU_CORE_UTILS = [ + "chcon", + "chgrp", + "chown", + "chmod", + "cp", + "dd", + "df", + "dir", + "dircolors", + "ln", + "ls", + "mkdir", + "mkfifo", + "mknod", + "mktemp", + "mv", + "realpath", + "rm", + "rmdir", + "shred", + "sync", + "touch", + "truncate", + "vdir", + "b2sum", + "base32", + "base64", + "cat", + "cksum", + "comm", + "csplit", + "cut", + "expand", + "fmt", + "fold", + "head", + "join", + "md5sum", + "nl", + "numfmt", + "od", + "paste", + "ptx", + "pr", + "sha1sum", + "sha224sum", + "sha256sum", + "sha384sum", + "sha512sum", + "shuf", + "sort", + "split", + "sum", + "tac", + "tail", + "tr", + "tsort", + "unexpand", + "uniq", + "wc", + "arch", + "basename", + "chroot", + "date", + "dirname", + "du", + "echo", + "env", + "expr", + "factor", + // "false", // keyword literal already + "groups", + "hostid", + "id", + "link", + "logname", + "nice", + "nohup", + "nproc", + "pathchk", + "pinky", + "printenv", + "printf", + "pwd", + "readlink", + "runcon", + "seq", + "sleep", + "stat", + "stdbuf", + "stty", + "tee", + "test", + "timeout", + // "true", // keyword literal already + "tty", + "uname", + "unlink", + "uptime", + "users", + "who", + "whoami", + "yes" + ]; + + return { + name: 'Bash', + aliases: [ + 'sh', + 'zsh' + ], + keywords: { + $pattern: /\b[a-z][a-z0-9._-]+\b/, + keyword: KEYWORDS, + literal: LITERALS, + built_in: [ + ...SHELL_BUILT_INS, + ...BASH_BUILT_INS, + // Shell modifiers + "set", + "shopt", + ...ZSH_BUILT_INS, + ...GNU_CORE_UTILS + ] + }, + contains: [ + KNOWN_SHEBANG, // to catch known shells and boost relevancy + hljs.SHEBANG(), // to catch unknown shells but still highlight the shebang + FUNCTION, + ARITHMETIC, + COMMENT, + HERE_DOC, + PATH_MODE, + QUOTE_STRING, + ESCAPED_QUOTE, + APOS_STRING, + ESCAPED_APOS, + VAR + ] + }; + } + + return bash; + +})(); +; +export default hljsGrammar; \ No newline at end of file diff --git a/public/assets/highlight/es/languages/bash.min.js b/public/assets/highlight/es/languages/bash.min.js new file mode 100644 index 00000000..623f6d21 --- /dev/null +++ b/public/assets/highlight/es/languages/bash.min.js @@ -0,0 +1,21 @@ +/*! `bash` grammar compiled for Highlight.js 11.10.0 */ +var hljsGrammar=(()=>{"use strict";return e=>{const s=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const n={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,n]};n.contains.push(o);const r={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})() +;export default hljsGrammar; \ No newline at end of file diff --git a/public/assets/highlight/es/languages/php.js b/public/assets/highlight/es/languages/php.js new file mode 100644 index 00000000..70d61041 --- /dev/null +++ b/public/assets/highlight/es/languages/php.js @@ -0,0 +1,621 @@ +/*! `php` grammar compiled for Highlight.js 11.10.0 */ +var hljsGrammar = (function () { + 'use strict'; + + /* + Language: PHP + Author: Victor Karamzin + Contributors: Evgeny Stepanischev , Ivan Sagalaev + Website: https://www.php.net + Category: common + */ + + /** + * @param {HLJSApi} hljs + * @returns {LanguageDetail} + * */ + function php(hljs) { + const regex = hljs.regex; + // negative look-ahead tries to avoid matching patterns that are not + // Perl at all like $ident$, @ident@, etc. + const NOT_PERL_ETC = /(?![A-Za-z0-9])(?![$])/; + const IDENT_RE = regex.concat( + /[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/, + NOT_PERL_ETC); + // Will not detect camelCase classes + const PASCAL_CASE_CLASS_NAME_RE = regex.concat( + /(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/, + NOT_PERL_ETC); + const VARIABLE = { + scope: 'variable', + match: '\\$+' + IDENT_RE, + }; + const PREPROCESSOR = { + scope: 'meta', + variants: [ + { begin: /<\?php/, relevance: 10 }, // boost for obvious PHP + { begin: /<\?=/ }, + // less relevant per PSR-1 which says not to use short-tags + { begin: /<\?/, relevance: 0.1 }, + { begin: /\?>/ } // end php tag + ] + }; + const SUBST = { + scope: 'subst', + variants: [ + { begin: /\$\w+/ }, + { + begin: /\{\$/, + end: /\}/ + } + ] + }; + const SINGLE_QUOTED = hljs.inherit(hljs.APOS_STRING_MODE, { illegal: null, }); + const DOUBLE_QUOTED = hljs.inherit(hljs.QUOTE_STRING_MODE, { + illegal: null, + contains: hljs.QUOTE_STRING_MODE.contains.concat(SUBST), + }); + + const HEREDOC = { + begin: /<<<[ \t]*(?:(\w+)|"(\w+)")\n/, + end: /[ \t]*(\w+)\b/, + contains: hljs.QUOTE_STRING_MODE.contains.concat(SUBST), + 'on:begin': (m, resp) => { resp.data._beginMatch = m[1] || m[2]; }, + 'on:end': (m, resp) => { if (resp.data._beginMatch !== m[1]) resp.ignoreMatch(); }, + }; + + const NOWDOC = hljs.END_SAME_AS_BEGIN({ + begin: /<<<[ \t]*'(\w+)'\n/, + end: /[ \t]*(\w+)\b/, + }); + // list of valid whitespaces because non-breaking space might be part of a IDENT_RE + const WHITESPACE = '[ \t\n]'; + const STRING = { + scope: 'string', + variants: [ + DOUBLE_QUOTED, + SINGLE_QUOTED, + HEREDOC, + NOWDOC + ] + }; + const NUMBER = { + scope: 'number', + variants: [ + { begin: `\\b0[bB][01]+(?:_[01]+)*\\b` }, // Binary w/ underscore support + { begin: `\\b0[oO][0-7]+(?:_[0-7]+)*\\b` }, // Octals w/ underscore support + { begin: `\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b` }, // Hex w/ underscore support + // Decimals w/ underscore support, with optional fragments and scientific exponent (e) suffix. + { begin: `(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?` } + ], + relevance: 0 + }; + const LITERALS = [ + "false", + "null", + "true" + ]; + const KWS = [ + // Magic constants: + // + "__CLASS__", + "__DIR__", + "__FILE__", + "__FUNCTION__", + "__COMPILER_HALT_OFFSET__", + "__LINE__", + "__METHOD__", + "__NAMESPACE__", + "__TRAIT__", + // Function that look like language construct or language construct that look like function: + // List of keywords that may not require parenthesis + "die", + "echo", + "exit", + "include", + "include_once", + "print", + "require", + "require_once", + // These are not language construct (function) but operate on the currently-executing function and can access the current symbol table + // 'compact extract func_get_arg func_get_args func_num_args get_called_class get_parent_class ' + + // Other keywords: + // + // + "array", + "abstract", + "and", + "as", + "binary", + "bool", + "boolean", + "break", + "callable", + "case", + "catch", + "class", + "clone", + "const", + "continue", + "declare", + "default", + "do", + "double", + "else", + "elseif", + "empty", + "enddeclare", + "endfor", + "endforeach", + "endif", + "endswitch", + "endwhile", + "enum", + "eval", + "extends", + "final", + "finally", + "float", + "for", + "foreach", + "from", + "global", + "goto", + "if", + "implements", + "instanceof", + "insteadof", + "int", + "integer", + "interface", + "isset", + "iterable", + "list", + "match|0", + "mixed", + "new", + "never", + "object", + "or", + "private", + "protected", + "public", + "readonly", + "real", + "return", + "string", + "switch", + "throw", + "trait", + "try", + "unset", + "use", + "var", + "void", + "while", + "xor", + "yield" + ]; + + const BUILT_INS = [ + // Standard PHP library: + // + "Error|0", + "AppendIterator", + "ArgumentCountError", + "ArithmeticError", + "ArrayIterator", + "ArrayObject", + "AssertionError", + "BadFunctionCallException", + "BadMethodCallException", + "CachingIterator", + "CallbackFilterIterator", + "CompileError", + "Countable", + "DirectoryIterator", + "DivisionByZeroError", + "DomainException", + "EmptyIterator", + "ErrorException", + "Exception", + "FilesystemIterator", + "FilterIterator", + "GlobIterator", + "InfiniteIterator", + "InvalidArgumentException", + "IteratorIterator", + "LengthException", + "LimitIterator", + "LogicException", + "MultipleIterator", + "NoRewindIterator", + "OutOfBoundsException", + "OutOfRangeException", + "OuterIterator", + "OverflowException", + "ParentIterator", + "ParseError", + "RangeException", + "RecursiveArrayIterator", + "RecursiveCachingIterator", + "RecursiveCallbackFilterIterator", + "RecursiveDirectoryIterator", + "RecursiveFilterIterator", + "RecursiveIterator", + "RecursiveIteratorIterator", + "RecursiveRegexIterator", + "RecursiveTreeIterator", + "RegexIterator", + "RuntimeException", + "SeekableIterator", + "SplDoublyLinkedList", + "SplFileInfo", + "SplFileObject", + "SplFixedArray", + "SplHeap", + "SplMaxHeap", + "SplMinHeap", + "SplObjectStorage", + "SplObserver", + "SplPriorityQueue", + "SplQueue", + "SplStack", + "SplSubject", + "SplTempFileObject", + "TypeError", + "UnderflowException", + "UnexpectedValueException", + "UnhandledMatchError", + // Reserved interfaces: + // + "ArrayAccess", + "BackedEnum", + "Closure", + "Fiber", + "Generator", + "Iterator", + "IteratorAggregate", + "Serializable", + "Stringable", + "Throwable", + "Traversable", + "UnitEnum", + "WeakReference", + "WeakMap", + // Reserved classes: + // + "Directory", + "__PHP_Incomplete_Class", + "parent", + "php_user_filter", + "self", + "static", + "stdClass" + ]; + + /** Dual-case keywords + * + * ["then","FILE"] => + * ["then", "THEN", "FILE", "file"] + * + * @param {string[]} items */ + const dualCase = (items) => { + /** @type string[] */ + const result = []; + items.forEach(item => { + result.push(item); + if (item.toLowerCase() === item) { + result.push(item.toUpperCase()); + } else { + result.push(item.toLowerCase()); + } + }); + return result; + }; + + const KEYWORDS = { + keyword: KWS, + literal: dualCase(LITERALS), + built_in: BUILT_INS, + }; + + /** + * @param {string[]} items */ + const normalizeKeywords = (items) => { + return items.map(item => { + return item.replace(/\|\d+$/, ""); + }); + }; + + const CONSTRUCTOR_CALL = { variants: [ + { + match: [ + /new/, + regex.concat(WHITESPACE, "+"), + // to prevent built ins from being confused as the class constructor call + regex.concat("(?!", normalizeKeywords(BUILT_INS).join("\\b|"), "\\b)"), + PASCAL_CASE_CLASS_NAME_RE, + ], + scope: { + 1: "keyword", + 4: "title.class", + }, + } + ] }; + + const CONSTANT_REFERENCE = regex.concat(IDENT_RE, "\\b(?!\\()"); + + const LEFT_AND_RIGHT_SIDE_OF_DOUBLE_COLON = { variants: [ + { + match: [ + regex.concat( + /::/, + regex.lookahead(/(?!class\b)/) + ), + CONSTANT_REFERENCE, + ], + scope: { 2: "variable.constant", }, + }, + { + match: [ + /::/, + /class/, + ], + scope: { 2: "variable.language", }, + }, + { + match: [ + PASCAL_CASE_CLASS_NAME_RE, + regex.concat( + /::/, + regex.lookahead(/(?!class\b)/) + ), + CONSTANT_REFERENCE, + ], + scope: { + 1: "title.class", + 3: "variable.constant", + }, + }, + { + match: [ + PASCAL_CASE_CLASS_NAME_RE, + regex.concat( + "::", + regex.lookahead(/(?!class\b)/) + ), + ], + scope: { 1: "title.class", }, + }, + { + match: [ + PASCAL_CASE_CLASS_NAME_RE, + /::/, + /class/, + ], + scope: { + 1: "title.class", + 3: "variable.language", + }, + } + ] }; + + const NAMED_ARGUMENT = { + scope: 'attr', + match: regex.concat(IDENT_RE, regex.lookahead(':'), regex.lookahead(/(?!::)/)), + }; + const PARAMS_MODE = { + relevance: 0, + begin: /\(/, + end: /\)/, + keywords: KEYWORDS, + contains: [ + NAMED_ARGUMENT, + VARIABLE, + LEFT_AND_RIGHT_SIDE_OF_DOUBLE_COLON, + hljs.C_BLOCK_COMMENT_MODE, + STRING, + NUMBER, + CONSTRUCTOR_CALL, + ], + }; + const FUNCTION_INVOKE = { + relevance: 0, + match: [ + /\b/, + // to prevent keywords from being confused as the function title + regex.concat("(?!fn\\b|function\\b|", normalizeKeywords(KWS).join("\\b|"), "|", normalizeKeywords(BUILT_INS).join("\\b|"), "\\b)"), + IDENT_RE, + regex.concat(WHITESPACE, "*"), + regex.lookahead(/(?=\()/) + ], + scope: { 3: "title.function.invoke", }, + contains: [ PARAMS_MODE ] + }; + PARAMS_MODE.contains.push(FUNCTION_INVOKE); + + const ATTRIBUTE_CONTAINS = [ + NAMED_ARGUMENT, + LEFT_AND_RIGHT_SIDE_OF_DOUBLE_COLON, + hljs.C_BLOCK_COMMENT_MODE, + STRING, + NUMBER, + CONSTRUCTOR_CALL, + ]; + + const ATTRIBUTES = { + begin: regex.concat(/#\[\s*/, PASCAL_CASE_CLASS_NAME_RE), + beginScope: "meta", + end: /]/, + endScope: "meta", + keywords: { + literal: LITERALS, + keyword: [ + 'new', + 'array', + ] + }, + contains: [ + { + begin: /\[/, + end: /]/, + keywords: { + literal: LITERALS, + keyword: [ + 'new', + 'array', + ] + }, + contains: [ + 'self', + ...ATTRIBUTE_CONTAINS, + ] + }, + ...ATTRIBUTE_CONTAINS, + { + scope: 'meta', + match: PASCAL_CASE_CLASS_NAME_RE + } + ] + }; + + return { + case_insensitive: false, + keywords: KEYWORDS, + contains: [ + ATTRIBUTES, + hljs.HASH_COMMENT_MODE, + hljs.COMMENT('//', '$'), + hljs.COMMENT( + '/\\*', + '\\*/', + { contains: [ + { + scope: 'doctag', + match: '@[A-Za-z]+' + } + ] } + ), + { + match: /__halt_compiler\(\);/, + keywords: '__halt_compiler', + starts: { + scope: "comment", + end: hljs.MATCH_NOTHING_RE, + contains: [ + { + match: /\?>/, + scope: "meta", + endsParent: true + } + ] + } + }, + PREPROCESSOR, + { + scope: 'variable.language', + match: /\$this\b/ + }, + VARIABLE, + FUNCTION_INVOKE, + LEFT_AND_RIGHT_SIDE_OF_DOUBLE_COLON, + { + match: [ + /const/, + /\s/, + IDENT_RE, + ], + scope: { + 1: "keyword", + 3: "variable.constant", + }, + }, + CONSTRUCTOR_CALL, + { + scope: 'function', + relevance: 0, + beginKeywords: 'fn function', + end: /[;{]/, + excludeEnd: true, + illegal: '[$%\\[]', + contains: [ + { beginKeywords: 'use', }, + hljs.UNDERSCORE_TITLE_MODE, + { + begin: '=>', // No markup, just a relevance booster + endsParent: true + }, + { + scope: 'params', + begin: '\\(', + end: '\\)', + excludeBegin: true, + excludeEnd: true, + keywords: KEYWORDS, + contains: [ + 'self', + VARIABLE, + LEFT_AND_RIGHT_SIDE_OF_DOUBLE_COLON, + hljs.C_BLOCK_COMMENT_MODE, + STRING, + NUMBER + ] + }, + ] + }, + { + scope: 'class', + variants: [ + { + beginKeywords: "enum", + illegal: /[($"]/ + }, + { + beginKeywords: "class interface trait", + illegal: /[:($"]/ + } + ], + relevance: 0, + end: /\{/, + excludeEnd: true, + contains: [ + { beginKeywords: 'extends implements' }, + hljs.UNDERSCORE_TITLE_MODE + ] + }, + // both use and namespace still use "old style" rules (vs multi-match) + // because the namespace name can include `\` and we still want each + // element to be treated as its own *individual* title + { + beginKeywords: 'namespace', + relevance: 0, + end: ';', + illegal: /[.']/, + contains: [ hljs.inherit(hljs.UNDERSCORE_TITLE_MODE, { scope: "title.class" }) ] + }, + { + beginKeywords: 'use', + relevance: 0, + end: ';', + contains: [ + // TODO: title.function vs title.class + { + match: /\b(as|const|function)\b/, + scope: "keyword" + }, + // TODO: could be title.class or title.function + hljs.UNDERSCORE_TITLE_MODE + ] + }, + STRING, + NUMBER, + ] + }; + } + + return php; + +})(); +; +export default hljsGrammar; \ No newline at end of file diff --git a/public/assets/highlight/es/languages/php.min.js b/public/assets/highlight/es/languages/php.min.js new file mode 100644 index 00000000..e2daacdf --- /dev/null +++ b/public/assets/highlight/es/languages/php.min.js @@ -0,0 +1,58 @@ +/*! `php` grammar compiled for Highlight.js 11.10.0 */ +var hljsGrammar=(()=>{"use strict";return e=>{ +const t=e.regex,a=/(?![A-Za-z0-9])(?![$])/,r=t.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,a),n=t.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,a),o={ +scope:"variable",match:"\\$+"+r},c={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},i=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),s="[ \t\n]",l={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(c)}),i,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(c),"on:begin":(e,t)=>{ +t.data._beginMatch=e[1]||e[2]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},_=["false","null","true"],p=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],E={ +keyword:p,literal:(e=>{const t=[];return e.forEach((e=>{ +t.push(e),e.toLowerCase()===e?t.push(e.toUpperCase()):t.push(e.toLowerCase()) +})),t})(_),built_in:b},u=e=>e.map((e=>e.replace(/\|\d+$/,""))),g={variants:[{ +match:[/new/,t.concat(s,"+"),t.concat("(?!",u(b).join("\\b|"),"\\b)"),n],scope:{ +1:"keyword",4:"title.class"}}]},h=t.concat(r,"\\b(?!\\()"),m={variants:[{ +match:[t.concat(/::/,t.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[n,t.concat(/::/,t.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[n,t.concat("::",t.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[n,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},f={scope:"attr", +match:t.concat(r,t.lookahead(":"),t.lookahead(/(?!::)/))},I={relevance:0, +begin:/\(/,end:/\)/,keywords:E,contains:[f,o,m,e.C_BLOCK_COMMENT_MODE,l,d,g] +},O={relevance:0, +match:[/\b/,t.concat("(?!fn\\b|function\\b|",u(p).join("\\b|"),"|",u(b).join("\\b|"),"\\b)"),r,t.concat(s,"*"),t.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[I]};I.contains.push(O) +;const v=[f,m,e.C_BLOCK_COMMENT_MODE,l,d,g];return{case_insensitive:!1, +keywords:E,contains:[{begin:t.concat(/#\[\s*/,n),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:_,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:_,keyword:["new","array"]}, +contains:["self",...v]},...v,{scope:"meta",match:n}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},o,O,m,{ +match:[/const/,/\s/,r],scope:{1:"keyword",3:"variable.constant"}},g,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:E, +contains:["self",o,m,e.C_BLOCK_COMMENT_MODE,l,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},l,d]} +}})();export default hljsGrammar; \ No newline at end of file diff --git a/public/assets/highlight/es/languages/xml.js b/public/assets/highlight/es/languages/xml.js new file mode 100644 index 00000000..fecd3941 --- /dev/null +++ b/public/assets/highlight/es/languages/xml.js @@ -0,0 +1,249 @@ +/*! `xml` grammar compiled for Highlight.js 11.10.0 */ +var hljsGrammar = (function () { + 'use strict'; + + /* + Language: HTML, XML + Website: https://www.w3.org/XML/ + Category: common, web + Audit: 2020 + */ + + /** @type LanguageFn */ + function xml(hljs) { + const regex = hljs.regex; + // XML names can have the following additional letters: https://www.w3.org/TR/xml/#NT-NameChar + // OTHER_NAME_CHARS = /[:\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]/; + // Element names start with NAME_START_CHAR followed by optional other Unicode letters, ASCII digits, hyphens, underscores, and periods + // const TAG_NAME_RE = regex.concat(/[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/, regex.optional(/[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*:/), /[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*/);; + // const XML_IDENT_RE = /[A-Z_a-z:\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]+/; + // const TAG_NAME_RE = regex.concat(/[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/, regex.optional(/[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*:/), /[A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*/); + // however, to cater for performance and more Unicode support rely simply on the Unicode letter class + const TAG_NAME_RE = regex.concat(/[\p{L}_]/u, regex.optional(/[\p{L}0-9_.-]*:/u), /[\p{L}0-9_.-]*/u); + const XML_IDENT_RE = /[\p{L}0-9._:-]+/u; + const XML_ENTITIES = { + className: 'symbol', + begin: /&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/ + }; + const XML_META_KEYWORDS = { + begin: /\s/, + contains: [ + { + className: 'keyword', + begin: /#?[a-z_][a-z1-9_-]+/, + illegal: /\n/ + } + ] + }; + const XML_META_PAR_KEYWORDS = hljs.inherit(XML_META_KEYWORDS, { + begin: /\(/, + end: /\)/ + }); + const APOS_META_STRING_MODE = hljs.inherit(hljs.APOS_STRING_MODE, { className: 'string' }); + const QUOTE_META_STRING_MODE = hljs.inherit(hljs.QUOTE_STRING_MODE, { className: 'string' }); + const TAG_INTERNALS = { + endsWithParent: true, + illegal: /`]+/ } + ] + } + ] + } + ] + }; + return { + name: 'HTML, XML', + aliases: [ + 'html', + 'xhtml', + 'rss', + 'atom', + 'xjb', + 'xsd', + 'xsl', + 'plist', + 'wsf', + 'svg' + ], + case_insensitive: true, + unicodeRegex: true, + contains: [ + { + className: 'meta', + begin: //, + relevance: 10, + contains: [ + XML_META_KEYWORDS, + QUOTE_META_STRING_MODE, + APOS_META_STRING_MODE, + XML_META_PAR_KEYWORDS, + { + begin: /\[/, + end: /\]/, + contains: [ + { + className: 'meta', + begin: //, + contains: [ + XML_META_KEYWORDS, + XML_META_PAR_KEYWORDS, + QUOTE_META_STRING_MODE, + APOS_META_STRING_MODE + ] + } + ] + } + ] + }, + hljs.COMMENT( + //, + { relevance: 10 } + ), + { + begin: //, + relevance: 10 + }, + XML_ENTITIES, + // xml processing instructions + { + className: 'meta', + end: /\?>/, + variants: [ + { + begin: /<\?xml/, + relevance: 10, + contains: [ + QUOTE_META_STRING_MODE + ] + }, + { + begin: /<\?[a-z][a-z0-9]+/, + } + ] + + }, + { + className: 'tag', + /* + The lookahead pattern (?=...) ensures that 'begin' only matches + ')/, + end: />/, + keywords: { name: 'style' }, + contains: [ TAG_INTERNALS ], + starts: { + end: /<\/style>/, + returnEnd: true, + subLanguage: [ + 'css', + 'xml' + ] + } + }, + { + className: 'tag', + // See the comment in the