From e3fbf5468b2751fa6fb6f23c29899764c874ea5f Mon Sep 17 00:00:00 2001 From: Tuomas Vlimaa <50957-tvalimaa@users.noreply.drupalcode.org> Date: Fri, 4 Sep 2026 16:15:52 +0300 Subject: [PATCH 01/14] Add ALTCHA integration --- CaptchaEvents.php | 1 + Config/config.php | 40 +++- Controller/ChallengeController.php | 78 ++++++++ EventListener/AltchaFormSubscriber.php | 119 ++++++++++++ Form/Type/AltchaType.php | 126 ++++++++++++ Integration/AltchaIntegration.php | 157 +++++++++++++++ Resources/views/Integration/altcha.html.twig | 172 +++++++++++++++++ Service/AltchaClient.php | 192 +++++++++++++++++++ Translations/en_US/messages.ini | 47 +++++ Twig/AltchaExtension.php | 46 +++++ composer.json | 8 +- 11 files changed, 982 insertions(+), 4 deletions(-) create mode 100644 Controller/ChallengeController.php create mode 100644 EventListener/AltchaFormSubscriber.php create mode 100644 Form/Type/AltchaType.php create mode 100644 Integration/AltchaIntegration.php create mode 100644 Resources/views/Integration/altcha.html.twig create mode 100644 Service/AltchaClient.php create mode 100644 Twig/AltchaExtension.php diff --git a/CaptchaEvents.php b/CaptchaEvents.php index 4655453..2c761a3 100644 --- a/CaptchaEvents.php +++ b/CaptchaEvents.php @@ -15,4 +15,5 @@ final class CaptchaEvents { public const HCAPTCHA_ON_FORM_VALIDATE = "mautic.plugin.hcaptcha.on_form_validate"; public const RECAPTCHA_ON_FORM_VALIDATE = "mautic.plugin.recaptcha.on_form_validate"; public const TURNSTILE_ON_FORM_VALIDATE = "mautic.plugin.turnstile.on_form_validate"; + public const ALTCHA_ON_FORM_VALIDATE = "mautic.plugin.altcha.on_form_validate"; } diff --git a/Config/config.php b/Config/config.php index e67c03c..9c05e4b 100644 --- a/Config/config.php +++ b/Config/config.php @@ -3,14 +3,19 @@ use MauticPlugin\MauticMultiCaptchaBundle\EventListener\HcaptchaFormSubscriber; use MauticPlugin\MauticMultiCaptchaBundle\EventListener\RecaptchaFormSubscriber; use MauticPlugin\MauticMultiCaptchaBundle\EventListener\TurnstileFormSubscriber; +use MauticPlugin\MauticMultiCaptchaBundle\EventListener\AltchaFormSubscriber; use MauticPlugin\MauticMultiCaptchaBundle\Service\HcaptchaClient; use MauticPlugin\MauticMultiCaptchaBundle\Service\RecaptchaClient; use MauticPlugin\MauticMultiCaptchaBundle\Service\TurnstileClient; +use MauticPlugin\MauticMultiCaptchaBundle\Service\AltchaClient; use MauticPlugin\MauticMultiCaptchaBundle\Integration\HcaptchaIntegration; use MauticPlugin\MauticMultiCaptchaBundle\Integration\RecaptchaIntegration; use MauticPlugin\MauticMultiCaptchaBundle\Integration\TurnstileIntegration; +use MauticPlugin\MauticMultiCaptchaBundle\Integration\AltchaIntegration; + +use MauticPlugin\MauticMultiCaptchaBundle\Controller\ChallengeController; use Mautic\CoreBundle\Helper\AppVersion; @@ -67,12 +72,17 @@ return [ "name" => "MultiCAPTCHA", - "description" => "Enables Google's reCAPTCHA, hCaptcha, and Cloudflare Turnstile integration for Mautic", + "description" => "Enables Google's reCAPTCHA, hCaptcha, Cloudflare Turnstile, and ALTCHA integration for Mautic", "version" => "1.0.8", "author" => "FireMultimedia B.V.", "routes" => [ - + "public" => [ + "mautic_altcha_challenge" => [ + "path" => "/altcha/challenge", + "controller" => ChallengeController::class // invokable - see ChallengeController::__invoke() + ] + ] ], "services" => [ @@ -109,6 +119,18 @@ "mautic.lead.model.lead", "mautic.helper.integration" ] + ], + + "mautic.altcha.event_listener.form_subscriber" => [ + "class" => AltchaFormSubscriber::class, + + "arguments" => [ + "event_dispatcher", + "mautic.altcha.service.altcha_client", + "mautic.lead.model.lead", + "request_stack", + "mautic.helper.integration" + ] ] ], @@ -139,6 +161,15 @@ "arguments" => [ "mautic.helper.integration" ] + ], + + "mautic.altcha.service.altcha_client" => [ + "class" => AltchaClient::class, + + "arguments" => [ + "mautic.helper.integration", + "router" + ] ] ], @@ -156,6 +187,11 @@ "mautic.integration.turnstile" => [ "class" => TurnstileIntegration::class, "arguments" => $defaultIntegrationArguments + ], + + "mautic.integration.altcha" => [ + "class" => AltchaIntegration::class, + "arguments" => $defaultIntegrationArguments ] ] ], diff --git a/Controller/ChallengeController.php b/Controller/ChallengeController.php new file mode 100644 index 0000000..b0962ba --- /dev/null +++ b/Controller/ChallengeController.php @@ -0,0 +1,78 @@ +Class ChallengeController + * + * A public (unauthenticated), read-only endpoint that the + * fetches a fresh challenge from directly, in self-hosted mode. + * + * This exists because Mautic caches an entire form's RENDERED HTML in + * `forms.cached_html` (see `Mautic\FormBundle\Model\FormModel::generateHtml()` + * / `getContent()`) and only regenerates it when the form itself is saved - + * not on every page view, and not every time preview is opened. A challenge + * embedded directly into that cached HTML would be reused by every visitor + * until the form is next saved, and would eventually expire while still + * being served - which is exactly what caused "Verification failed. Try + * again later." to appear on every attempt, deterministically, regardless + * of what the challenge JSON itself contained. Pointing the widget's + * "challengeurl" attribute at this endpoint instead (a URL, fetched live by + * the visitor's browser at the moment the widget actually loads) sidesteps + * that caching layer entirely - the same approach ALTCHA Sentinel already + * uses, and what ALTCHA's own docs describe as the standard "server + * integration" pattern for any dynamically-cached page. + * + * Deliberately a plain, invokable class (`__invoke()`), not a + * Mautic\CoreBundle\Controller\FormController with a named *Action method - + * Mautic's own plugin-config docs specifically show a bare `SomeClass::class` + * reference (assuming an invokable controller) as the pattern for routes + * registered under the "public" firewall, as opposed to the `[SomeClass, + * 'methodName']` array pairing used for "main"/authenticated routes. + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Controller + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class ChallengeController { + + /** Hard floor/ceiling on the requested expiry, regardless of what's passed in the query string. */ + private const MIN_EXPIRE_SECONDS = 30; + private const MAX_EXPIRE_SECONDS = 3600; + + public function __construct(private readonly AltchaClient $altchaClient) { + + } + + public function __invoke(Request $request): JsonResponse { + $complexity = (string) $request->query->get("complexity", "medium"); + $expireSeconds = (int) $request->query->get("expire", (string) AltchaClient::DEFAULT_EXPIRE_SECONDS); + $expireSeconds = max(self::MIN_EXPIRE_SECONDS, min(self::MAX_EXPIRE_SECONDS, $expireSeconds)); + + $challenge = $this->altchaClient->createChallengeForComplexity($complexity, $expireSeconds); + + if($challenge === null) { + return new JsonResponse(["error" => "ALTCHA is not configured for self-hosted challenges."], 503); + } + + $response = new JsonResponse($challenge); + + // Never let any layer (browser, reverse proxy, CDN) cache this response - + // a stale/reused challenge is exactly what this endpoint exists to prevent. + $response->headers->set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + $response->headers->set("Pragma", "no-cache"); + + // Mautic forms are routinely embedded on third-party domains - the + // widget's fetch() call must be allowed cross-origin. The response + // contains no sensitive or user-specific data. + $response->headers->set("Access-Control-Allow-Origin", "*"); + + return $response; + } + +} diff --git a/EventListener/AltchaFormSubscriber.php b/EventListener/AltchaFormSubscriber.php new file mode 100644 index 0000000..86e4301 --- /dev/null +++ b/EventListener/AltchaFormSubscriber.php @@ -0,0 +1,119 @@ +Class AltchaFormSubscriber + * + * @package MauticPlugin\MauticMultiCaptchaBundle\EventListener + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class AltchaFormSubscriber implements EventSubscriberInterface { + + private ?TranslatorInterface $translator = null; + + private bool $isConfigured = false; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + private readonly AltchaClient $altchaClient, + private readonly LeadModel $leadModel, + private readonly RequestStack $requestStack, + + IntegrationHelper $integrationHelper + ) { + $integrationObject = $integrationHelper->getIntegrationObject(AltchaIntegration::INTEGRATION_NAME); + + if($integrationObject instanceof AbstractIntegration) { + $this->translator = $integrationObject->getTranslator(); + } + + $this->isConfigured = $this->altchaClient->isConfigured(); + } + + /** {@inheritDoc} */ + public static function getSubscribedEvents(): array { + return [ + FormEvents::FORM_ON_BUILD => ["onFormBuild", 0], + CaptchaEvents::ALTCHA_ON_FORM_VALIDATE => ["onFormValidate", 0] + ]; + } + + public function onFormBuild(FormBuilderEvent $event): void { + if(!$this->isConfigured) + return; + + $event->addFormField("plugin.altcha", [ + "label" => "strings.altcha.plugin.name", + "formType" => AltchaType::class, + "template" => "@MauticMultiCaptcha/Integration/altcha.html.twig", + + "builderOptions" => [ + "addLeadFieldList" => false, + "addIsRequired" => false, + "addDefaultValue" => false, + "addSaveResult" => false + ] + ]); + + $event->addValidator("plugin.altcha.validator", [ + "eventName" => CaptchaEvents::ALTCHA_ON_FORM_VALIDATE, + "fieldType" => "plugin.altcha" + ]); + } + + public function onFormValidate(ValidationEvent $event): void { + if(!$this->isConfigured) + return; + + $payload = (string) $this->requestStack->getCurrentRequest()?->request->get("altcha", ""); + + if($this->altchaClient->verify($payload)) + return; + + $event->failedValidation( + $this->translator === null + ? "ALTCHA verification was not successful." + : $this->translator->trans("strings.altcha.failure_message") + ); + + // Mirror the other CAPTCHA providers: if a lead was nonetheless + // created for this failed submission, remove it once the request + // finishes so no spam contacts pile up in the database. + $this->eventDispatcher->addListener(LeadEvents::LEAD_POST_SAVE, function(LeadEvent $event) { + if(!$event->isNew()) + return; + + $lead = $event->getLead(); + + $this->eventDispatcher->addListener("kernel.terminate", function() use ($lead) { + if($lead) + $this->leadModel->deleteEntity($lead); + }); + }, -255); + } + +} diff --git a/Form/Type/AltchaType.php b/Form/Type/AltchaType.php new file mode 100644 index 0000000..5e27cc9 --- /dev/null +++ b/Form/Type/AltchaType.php @@ -0,0 +1,126 @@ +Class AltchaType + * + * Note: unlike the other CAPTCHA providers, ALTCHA does not set cookies or + * load any third-party tracking script, so there is deliberately no + * "explicit consent" toggle here - there is nothing to consent to. + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Form\Type + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class AltchaType extends AbstractType { + + /** {@inheritDoc} */ + public function buildForm(FormBuilderInterface $builder, array $options): void { + $builder->add("complexity", ChoiceType::class, [ + "label" => "strings.altcha.settings.complexity", + "required" => false, + "data" => $options["data"]["complexity"] ?? "medium", + + "choices" => [ + "strings.altcha.settings.complexity.option.low" => "low", + "strings.altcha.settings.complexity.option.medium" => "medium", + "strings.altcha.settings.complexity.option.high" => "high" + ], + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "tooltip" => "strings.altcha.settings.complexity.tooltip" + ] + ])->add("expire", IntegerType::class, [ + "label" => "strings.altcha.settings.expire", + "required" => false, + "data" => isset($options["data"]["expire"]) ? (int) $options["data"]["expire"] : 600, + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "class" => "form-control", + "tooltip" => "strings.altcha.settings.expire.tooltip" + ] + ])->add("auto", ChoiceType::class, [ + "label" => "strings.altcha.settings.auto", + "required" => false, + "data" => $options["data"]["auto"] ?? "onsubmit", + + "choices" => [ + "strings.altcha.settings.auto.option.onload" => "onload", + "strings.altcha.settings.auto.option.onsubmit" => "onsubmit", + "strings.altcha.settings.auto.option.off" => "off" + ], + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "tooltip" => "strings.altcha.settings.auto.tooltip" + ] + ])->add("display", ChoiceType::class, [ + "label" => "strings.altcha.settings.display", + "required" => false, + "data" => $options["data"]["display"] ?? "standard", + + "choices" => [ + "strings.altcha.settings.display.option.standard" => "standard", + "strings.altcha.settings.display.option.bar" => "bar", + "strings.altcha.settings.display.option.floating" => "floating", + "strings.altcha.settings.display.option.overlay" => "overlay", + "strings.altcha.settings.display.option.invisible" => "invisible" + ], + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "tooltip" => "strings.altcha.settings.display.tooltip" + ] + ])->add("hideFooter", YesNoButtonGroupType::class, [ + "label" => "strings.altcha.settings.hide_footer", + "required" => false, + "data" => $options["data"]["hideFooter"] ?? false, + + "label_attr" => [ + "class" => "control-label" + ] + ])->add("hideLogo", YesNoButtonGroupType::class, [ + "label" => "strings.altcha.settings.hide_logo", + "required" => false, + "data" => $options["data"]["hideLogo"] ?? false, + + "label_attr" => [ + "class" => "control-label" + ] + ]); + + if(!empty($options["action"])) + $builder->setAction($options["action"]); + } + + /** {@inheritDoc} */ + public function getBlockPrefix(): string { + return AltchaIntegration::INTEGRATION_NAME; + } + +} diff --git a/Integration/AltchaIntegration.php b/Integration/AltchaIntegration.php new file mode 100644 index 0000000..5eca036 --- /dev/null +++ b/Integration/AltchaIntegration.php @@ -0,0 +1,157 @@ +Class AltchaIntegration + * + * ALTCHA is self-hosted by default: there is no third-party API to + * authenticate against, so authentication type is "none". Two ways of + * running it are supported side by side in the same settings form: + * + * - Self-hosted: just an "hmac_secret" that Mautic uses to sign/verify + * challenges on its own, with no outbound requests at all. + * - ALTCHA Sentinel: a "sentinel_domain" (your Sentinel instance's base + * URL), a "sentinel_api_key", and a "sentinel_api_secret". Sentinel + * issues challenges directly to the visitor's browser; Mautic only + * verifies the resulting server signature locally (no outbound request). + * + * None of the four fields are added via getRequiredKeyFields() - that would + * force ALL of them to be filled in simultaneously, making it impossible to + * use just the self-hosted mode or just Sentinel. They are added as optional + * fields via appendToForm() instead, and isConfigured() enforces the + * "one full set or the other" rule at runtime. + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Integration + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class AltchaIntegration extends AbstractIntegration { + + public const INTEGRATION_NAME = "Altcha"; + + /** {@inheritDoc} */ + public function getName(): string { + return self::INTEGRATION_NAME; + } + + /** {@inheritDoc} */ + public function getDisplayName(): string { + return "ALTCHA"; + } + + /** {@inheritDoc} */ + public function getAuthenticationType(): string { + return "none"; + } + + /** + * Deliberately empty - see the class docblock. Fields are added via + * appendToForm() so none of them are forced to be non-empty simultaneously. + */ + public function getRequiredKeyFields(): array { + return []; + } + + /** {@inheritDoc} */ + public function getSecretKeys(): array { + return [ + "hmac_secret", + "sentinel_api_secret" + ]; + } + + /** + * Either the self-hosted HMAC secret OR the full set of Sentinel + * credentials is required to count as configured. + */ + public function isConfigured(): bool { + $keys = $this->getKeys(); + + $sentinelReady = !empty($keys["sentinel_domain"]) && !empty($keys["sentinel_api_key"]) && !empty($keys["sentinel_api_secret"]); + $selfHostedReady = !empty($keys["hmac_secret"]); + + return $sentinelReady || $selfHostedReady; + } + + /** {@inheritDoc} */ + public function appendToForm(&$builder, $data, $formArea): void { + if("keys" !== $formArea) + return; + + $builder->add("hmac_secret", PasswordType::class, [ + "label" => "strings.altcha.settings.hmac_secret", + "required" => false, + "data" => $data["hmac_secret"] ?? "", + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "class" => "form-control", + "tooltip" => "strings.altcha.settings.hmac_secret.notice", + "placeholder" => "strings.altcha.settings.hmac_secret.placeholder" + ] + ])->add("sentinel_domain", UrlType::class, [ + "label" => "strings.altcha.settings.sentinel_domain", + "required" => false, + "data" => $data["sentinel_domain"] ?? "", + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "class" => "form-control", + "tooltip" => "strings.altcha.settings.sentinel_domain.notice" + ] + ])->add("sentinel_api_key", TextType::class, [ + "label" => "strings.altcha.settings.sentinel_api_key", + "required" => false, + "data" => $data["sentinel_api_key"] ?? "", + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "class" => "form-control", + "tooltip" => "strings.altcha.settings.sentinel_api_key.notice" + ] + ])->add("sentinel_api_secret", PasswordType::class, [ + "label" => "strings.altcha.settings.sentinel_api_secret", + "required" => false, + "data" => $data["sentinel_api_secret"] ?? "", + + "label_attr" => [ + "class" => "control-label" + ], + + "attr" => [ + "class" => "form-control", + "tooltip" => "strings.altcha.settings.sentinel_api_secret.notice", + "placeholder" => "strings.altcha.settings.sentinel_api_secret.placeholder" + ] + ]); + } + + /** {@inheritDoc} */ + public function getFormNotes($section): array { + if(in_array($section, ["keys", "custom"], true)) { + return [ + "strings.altcha.settings.notice", + "info" + ]; + } + + return parent::getFormNotes($section); + } + +} diff --git a/Resources/views/Integration/altcha.html.twig b/Resources/views/Integration/altcha.html.twig new file mode 100644 index 0000000..0961dfa --- /dev/null +++ b/Resources/views/Integration/altcha.html.twig @@ -0,0 +1,172 @@ +{# +Variables + - field + - formName (optional, string) + - fieldPage + - contactFields + - companyFields + - inBuilder + - fields + - inForm (optional, bool) + - required (optional, bool) +#} +{% set defaultInputClass = 'text' %} +{% set containerType = 'div-wrapper' %} + +{# start: field_helper #} +{% set defaultInputFormClass = defaultInputFormClass|default('') %} +{% set defaultLabelClass = defaultLabelClass|default('label') %} +{% set formName = formName|default('') %} +{% set defaultInputClass = 'mauticform-' ~ defaultInputClass %} +{% set defaultLabelClass = 'mauticform-' ~ defaultLabelClass %} +{% set containerClass = containerClass|default(containerType) %} +{% set order = field.order|default(0) %} +{% set validationMessage = '' %} + +{% set inputAttributes = htmlAttributesStringToArray(field.inputAttributes|default('')) %} +{% set labelAttributes = htmlAttributesStringToArray(field.labelAttributes|default('')) %} +{% set containerAttributes = htmlAttributesStringToArray(field.containerAttributes|default('')) %} + +{% if ignoreName is not defined or (ignoreName is defined and ignoreName is empty) %} + {% set inputName = 'mauticform[' ~ field.alias ~ ']' %} + {% if field.properties.multiple is defined %} + {% set inputName = inputName ~ '[]' %} + {% endif %} + {% set inputAttributes = inputAttributes|merge({ + 'name': inputName, + }) %} +{% endif %} + +{% if field.type not in ['checkboxgrp', 'radiogrp', 'textarea'] %} + {% set inputAttributes = inputAttributes|merge({ + 'value': field.defaultValue|default(''), + }) %} +{% endif %} + +{% if ignoreId is not defined or (ignoreId is defined and ignoreId is empty) %} + {% set inputAttributes = inputAttributes|merge({ + 'id': 'mauticform_input' ~ formName ~ '_' ~ field.alias, + }) %} + {% set labelAttributes = labelAttributes|merge({ + 'id': 'mauticform_label' ~ formName ~ '_' ~ field.alias, + 'for': 'mauticform_input' ~ formName ~ '_' ~ field.alias, + }) %} +{% endif %} + +{# Label and input #} +{% if inForm is defined and (true == inForm or inForm is not empty) %} + {% set labelAttributes = labelAttributes|merge({ + 'class': labelAttributes.class|default([])|merge([defaultLabelClass]), + }) %} + {% set inputAttributes = inputAttributes|merge({ + 'disabled': 'disabled', + 'class': inputAttributes.class|default([])|merge([defaultInputClass, defaultInputFormClass]), + }) %} +{% else %} + {% set labelAttributes = labelAttributes|merge({ + 'class': labelAttributes.class|default([])|merge([defaultLabelClass]), + }) %} + {% set inputAttributes = inputAttributes|merge({ + 'class': inputAttributes.class|default([])|merge([defaultInputClass]), + }) %} +{% endif %} + +{# Container #} +{% set containerAttributes = containerAttributes|merge({ + 'id': 'mauticform' ~ formName|default('') ~ '_' ~ id, + 'class': containerAttributes.class|default([])|merge([ + 'mauticform-row', + 'mauticform-' ~ containerClass, + 'mauticform-field-' ~ order, + ]), +}) %} +{% if field.parent and fields[field.parent] is defined %} + {% set values = field.conditions.values|join('|') %} + + {% if field.conditions.any is not empty and 'notIn' != field.conditions.expr %} + {% set values = '*' %} + {% endif %} + + {% set containerAttributes = containerAttributes|merge({ + 'data-mautic-form-show-on': fields[field.parent].alias ~ ':' ~ values, + 'data-mautic-form-expr': field.conditions.expr, + 'class': containerAttributes.class|merge([ + 'mauticform-field-hidden', + ]), + }) %} +{% endif %} + +{# Field is required #} +{% if field.isRequired is defined and field.isRequired %} + {% set required = true %} + {% set validationMessage = field.validationMessage %} + {% if validationMessage is empty %} + {% set validationMessage = 'mautic.form.field.generic.required'|trans([], 'validators') %} + {% endif %} + {% set containerAttributes = containerAttributes|merge({ + 'class': containerAttributes.class|default([])|merge([ + 'mauticform-required', + ]), + 'data-validate': field.alias, + 'data-validation-type': field.type, + }) %} +{% elseif required is defined and true == required %} + {% set containerAttributes = containerAttributes|merge({ + 'class': containerAttributes.class|default([])|merge([ + 'mauticform-required', + ]), + }) %} +{% endif %} +{# end: field_helper #} + +{% set formName = formName|replace({'_': ''})|default('mauticform') %} + +{% block altcha %} + {% set complexity = field.properties.complexity|default('medium') %} + {% set expireSeconds = field.properties.expire|default(600) %} + {% set autoMode = field.properties.auto|default('onsubmit') %} + {% set displayMode = field.properties.display|default('standard') %} + {% set hideFooter = field.properties.hideFooter|default(false) %} + {% set hideLogo = field.properties.hideLogo|default(false) %} + + {% if field.showLabel %} + + {% endif %} + + {% if inForm is defined and (true == inForm or inForm is not empty) %} +
+ ALTCHA widget ({{ complexity }} complexity) +
+ {% else %} + {# Always a URL - never inline JSON. Mautic caches form HTML in + forms.cached_html, so a baked-in challenge would go stale for + every visitor until the form is next saved. The widget fetches + this URL live each time it loads. #} + {% set challengeUrl = altcha_challenge(complexity, expireSeconds) %} + + + +
+
+ +
+ + {% if challengeUrl is not null %} + + {% else %} +
+ ALTCHA is not configured yet. Under Plugins → ALTCHA, add either an HMAC secret (self-hosted) or your Sentinel domain + API key + API secret. +
+ {% endif %} +
+ {% endif %} +{% endblock %} diff --git a/Service/AltchaClient.php b/Service/AltchaClient.php new file mode 100644 index 0000000..8423dca --- /dev/null +++ b/Service/AltchaClient.php @@ -0,0 +1,192 @@ +Class AltchaClient + * + * Supports two mutually exclusive ways of running ALTCHA: + * + * 1. Self-hosted (default): Mautic generates and signs its own challenge + * with a local HMAC secret, and verifies the solution locally too. No + * outbound HTTP request is ever made - privacy-friendly (no cookies, + * no external requests, no tracking). + * + * 2. ALTCHA Sentinel: challenges are issued directly by a Sentinel instance. + * The widget points at Sentinel's /v1/challenge endpoint, and Mautic only + * verifies the server signature locally using the API key's secret. + * Still no outbound network call on verification. + * + * If both a self-hosted HMAC secret and Sentinel credentials are filled in, + * Sentinel takes precedence. + * + * IMPORTANT: the widget's "challengeurl" attribute always points at a URL - + * never an inline JSON challenge. Mautic caches the entire rendered form HTML + * in `forms.cached_html` and only regenerates it when the form is saved, not + * on every page view. A challenge baked into that cached HTML would be reused + * by every visitor until the form's next save, going stale and causing + * "Verification failed. Try again later." for everyone. A URL sidesteps this + * entirely since the widget fetches a fresh challenge live each time it loads. + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Service + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class AltchaClient { + + public const DEFAULT_MAX_NUMBER = 100000; + public const DEFAULT_EXPIRE_SECONDS = 600; + + /** Maps the field's "complexity" property to a proof-of-work maxNumber. */ + private const COMPLEXITY_MAP = [ + "low" => 50000, + "medium" => 100000, + "high" => 400000 + ]; + + private ?string $hmacSecret = null; + + private ?string $sentinelDomain = null; + private ?string $sentinelApiKey = null; + private ?string $sentinelApiSecret = null; + + public function __construct( + IntegrationHelper $integrationHelper, + private readonly UrlGeneratorInterface $router + ) { + $integrationObject = $integrationHelper->getIntegrationObject(AltchaIntegration::INTEGRATION_NAME); + + if($integrationObject instanceof AbstractIntegration) { + $keys = $integrationObject->getKeys(); + + $this->hmacSecret = $keys["hmac_secret"] ?? null; + + $this->sentinelDomain = $keys["sentinel_domain"] ?? null; + $this->sentinelApiKey = $keys["sentinel_api_key"] ?? null; + $this->sentinelApiSecret = $keys["sentinel_api_secret"] ?? null; + } + } + + public function usesSentinel(): bool { + return !empty($this->sentinelDomain) && !empty($this->sentinelApiKey) && !empty($this->sentinelApiSecret); + } + + public function hasSelfHostedSecret(): bool { + return !empty($this->hmacSecret); + } + + public function isConfigured(): bool { + return $this->usesSentinel() || $this->hasSelfHostedSecret(); + } + + public function buildSentinelChallengeUrl(): string { + $domain = rtrim((string) $this->sentinelDomain, "/"); + + return $domain . "/v1/challenge?apiKey=" . rawurlencode((string) $this->sentinelApiKey); + } + + /** + * Generates a fresh signed challenge. Must be called on every request + * (via ChallengeController) - never cache or reuse a challenge. + * + * @return array{algorithm: string, challenge: string, salt: string, signature: string, maxNumber: int} + */ + public function createChallenge(int $maxNumber = self::DEFAULT_MAX_NUMBER, int $expireSeconds = self::DEFAULT_EXPIRE_SECONDS): array { + $altcha = new Altcha((string) $this->hmacSecret); + + $challenge = $altcha->createChallenge(new ChallengeOptions( + algorithm: Algorithm::SHA256, + maxNumber: $maxNumber, + expires: new \DateTimeImmutable("+{$expireSeconds} seconds") + )); + + return [ + "algorithm" => $challenge->algorithm, + "challenge" => $challenge->challenge, + "salt" => $challenge->salt, + "signature" => $challenge->signature, + // Must be camelCase - widgets >= v1.4.0 require "maxNumber", + // not "maxnumber". A lowercase key makes the widget treat the + // challenge as malformed and fail immediately client-side. + "maxNumber" => $challenge->maxNumber + ]; + } + + public function createChallengeForComplexity(string $complexity, int $expireSeconds = self::DEFAULT_EXPIRE_SECONDS): ?array { + if(!$this->hasSelfHostedSecret()) + return null; + + $maxNumber = self::COMPLEXITY_MAP[$complexity] ?? self::COMPLEXITY_MAP["medium"]; + + return $this->createChallenge($maxNumber, $expireSeconds); + } + + public function buildSelfHostedChallengeUrl(string $complexity, int $expireSeconds): string { + return $this->router->generate("mautic_altcha_challenge", [ + "complexity" => $complexity, + "expire" => $expireSeconds + ], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * Returns the URL the widget's "challengeurl" attribute should contain. + * Always a URL (never inline JSON) - see class docblock for why. + * + * @return string|null Null when neither mode is configured yet. + */ + public function buildWidgetChallenge(string $complexity = "medium", int $expireSeconds = self::DEFAULT_EXPIRE_SECONDS): ?string { + if($this->usesSentinel()) + return $this->buildSentinelChallengeUrl(); + + if(!$this->hasSelfHostedSecret()) + return null; + + return $this->buildSelfHostedChallengeUrl($complexity, $expireSeconds); + } + + /** + * Verifies the base64-encoded payload the submits. + * + * - Self-hosted: local HMAC signature + expiry + proof-of-work check. No network call. + * - Sentinel: verifies the server signature locally using the API key's secret. No network call. + */ + public function verify(string $payload): bool { + if("" === trim($payload)) + return false; + + if($this->usesSentinel()) { + try { + $result = ServerSignature::verifyServerSignature($payload, (string) $this->sentinelApiSecret); + + return (bool) $result->verified; + } catch(\Throwable) { + return false; + } + } + + if(!$this->hasSelfHostedSecret()) + return false; + + $altcha = new Altcha((string) $this->hmacSecret); + + try { + return $altcha->verifySolution($payload, true); + } catch(\Throwable) { + return false; + } + } + +} diff --git a/Translations/en_US/messages.ini b/Translations/en_US/messages.ini index b42fba1..289fb1d 100644 --- a/Translations/en_US/messages.ini +++ b/Translations/en_US/messages.ini @@ -56,3 +56,50 @@ strings.turnstile.settings.theme.option.dark="dark" strings.turnstile.accept_cookies="Allow Cloudflare to verify you're not a robot using Turnstile" strings.turnstile.accept_cookies.notice.value="Cloudflare uses cookies for this!" strings.turnstile.failure_message="Turnstile wasn't successful." + +; altcha +mautic.form.field.type.plugin.altcha="ALTCHA" +strings.altcha.plugin.name="ALTCHA" +strings.altcha.failure_message="ALTCHA verification wasn't successful. Please try again." + +strings.altcha.settings.notice="Fill in EITHER the HMAC Secret (self-hosted, runs entirely on this server) OR the three Sentinel fields below (uses an ALTCHA Sentinel instance). If both are filled in, Sentinel takes priority." + +strings.altcha.settings.hmac_secret="HMAC Secret (self-hosted)" +strings.altcha.settings.hmac_secret.notice="A long, random string (e.g. from `openssl rand -hex 32`) that Mautic uses to sign and verify challenges locally. ALTCHA never makes an outbound request to any third party. Leave blank to keep the current value." +strings.altcha.settings.hmac_secret.placeholder="Leave blank to keep the current secret unchanged" + +strings.altcha.settings.sentinel_domain="Sentinel Domain / Base URL" +strings.altcha.settings.sentinel_domain.notice="Your ALTCHA Sentinel instance's base URL, e.g. https://sentinel.example.com. Do not include a trailing slash." + +strings.altcha.settings.sentinel_api_key="Sentinel API Key" +strings.altcha.settings.sentinel_api_key.notice="The API Key ID from your Sentinel app's API Keys section (looks like key_...). Sent to the visitor's browser as part of the widget's challenge URL." + +strings.altcha.settings.sentinel_api_secret="Sentinel API Key Secret" +strings.altcha.settings.sentinel_api_secret.notice="The Secret for the same API Key. Never leaves your server - used only to verify Sentinel's server signature locally. Leave blank to keep the current value." +strings.altcha.settings.sentinel_api_secret.placeholder="Leave blank to keep the current secret unchanged" + +strings.altcha.settings.complexity="Complexity" +strings.altcha.settings.complexity.tooltip="Self-hosted mode only. How much proof-of-work the visitor's browser must compute. Higher = more spam resistance but slightly slower on low-end devices." +strings.altcha.settings.complexity.option.low="Low" +strings.altcha.settings.complexity.option.medium="Medium" +strings.altcha.settings.complexity.option.high="High" + +strings.altcha.settings.expire="Challenge expiry (seconds)" +strings.altcha.settings.expire.tooltip="Self-hosted mode only. How long a generated challenge stays valid. If a visitor takes longer than this to submit, they must solve a new challenge." + +strings.altcha.settings.auto="Start solving" +strings.altcha.settings.auto.tooltip="When the widget should start solving the challenge. \"On submit\" avoids wasting CPU if the visitor never submits; \"On page load\" solves it in the background for instant submission; \"Off\" requires a manual checkbox tick." +strings.altcha.settings.auto.option.onload="On page load" +strings.altcha.settings.auto.option.onsubmit="On submit" +strings.altcha.settings.auto.option.off="Off (manual)" + +strings.altcha.settings.display="Display mode" +strings.altcha.settings.display.tooltip="How the widget is laid out. Standard is an inline box; Bar is a slim bar; Floating attaches a badge near the submit button; Overlay opens a modal when needed; Invisible shows no UI unless a code challenge is required." +strings.altcha.settings.display.option.standard="Standard (inline box)" +strings.altcha.settings.display.option.bar="Bar" +strings.altcha.settings.display.option.floating="Floating" +strings.altcha.settings.display.option.overlay="Overlay" +strings.altcha.settings.display.option.invisible="Invisible" + +strings.altcha.settings.hide_footer="Hide footer" +strings.altcha.settings.hide_logo="Hide ALTCHA logo" diff --git a/Twig/AltchaExtension.php b/Twig/AltchaExtension.php new file mode 100644 index 0000000..ad936e2 --- /dev/null +++ b/Twig/AltchaExtension.php @@ -0,0 +1,46 @@ +Class AltchaExtension + * + * Exposes altcha_challenge() to Twig so the field template can build the + * 's "challengeurl" attribute. Always returns a URL, never + * inline JSON - the widget fetches a fresh challenge live on each page load, + * which sidesteps Mautic's form HTML caching (forms.cached_html) that would + * otherwise cause stale/expired challenges to be served to every visitor. + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Twig + * + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class AltchaExtension extends AbstractExtension { + + public function __construct(private readonly AltchaClient $altchaClient) { + + } + + /** {@inheritDoc} */ + public function getFunctions(): array { + return [ + new TwigFunction("altcha_challenge", [$this, "createChallenge"]) + ]; + } + + /** + * @param string $complexity One of "low", "medium", "high". Ignored in Sentinel mode. + * @param int $expireSeconds Ignored in Sentinel mode. + * + * @return string|null Null when neither self-hosted nor Sentinel credentials are configured. + */ + public function createChallenge(string $complexity = "medium", int $expireSeconds = 600): ?string { + return $this->altchaClient->buildWidgetChallenge($complexity, $expireSeconds); + } + +} diff --git a/composer.json b/composer.json index d7f5b3e..02eb904 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,16 @@ "integration", "hCaptcha", "Cloudflare Turnstile", - "Google reCAPTCHA" + "Google reCAPTCHA", + "ALTCHA", + "proof-of-work", + "spam-protection" ], "require": { "php": "^8.1|^8.2", "ext-json": "*", - "mautic/core-lib": "^5.0|^6.0|^7.0" + "mautic/core-lib": "^5.0|^6.0|^7.0", + "altcha-org/altcha": "^2.0" }, "extra": { "install-directory-name": "MauticMultiCaptchaBundle" From 8fc99911eb435be385c835421bcb84dc0f0e6778 Mon Sep 17 00:00:00 2001 From: Tuomas Vlimaa <50957-tvalimaa@users.noreply.drupalcode.org> Date: Fri, 4 Sep 2026 16:41:29 +0300 Subject: [PATCH 02/14] Add ALTCHA integration with tests and translations --- Assets/img/altcha.png | Bin 0 -> 5680 bytes Assets/js/altcha.min.js | 8 + Controller/ChallengeController.php | 16 +- Resources/views/Integration/altcha.html.twig | 14 +- Tests/Controller/AltchaApiControllerTest.php | 155 + .../AltchaFormSubscriberTest.php | 210 + Tests/Form/Type/AltchaTypeTest.php | 96 + Tests/Integration/AltchaIntegrationTest.php | 151 + Tests/README.md | 53 + Tests/Resources/AltchaTemplateTest.php | 265 + Tests/Service/AltchaClientTest.php | 213 + Translations/de_DE/messages.ini | 105 + Translations/fi_FI/messages.ini | 105 + composer.json | 13 + composer.lock | 16454 ++++++++++++++++ phpunit.xml | 19 + 16 files changed, 17871 insertions(+), 6 deletions(-) create mode 100644 Assets/img/altcha.png create mode 100644 Assets/js/altcha.min.js create mode 100644 Tests/Controller/AltchaApiControllerTest.php create mode 100644 Tests/EventListener/AltchaFormSubscriberTest.php create mode 100644 Tests/Form/Type/AltchaTypeTest.php create mode 100644 Tests/Integration/AltchaIntegrationTest.php create mode 100644 Tests/README.md create mode 100644 Tests/Resources/AltchaTemplateTest.php create mode 100644 Tests/Service/AltchaClientTest.php create mode 100644 Translations/de_DE/messages.ini create mode 100644 Translations/fi_FI/messages.ini create mode 100644 composer.lock create mode 100644 phpunit.xml diff --git a/Assets/img/altcha.png b/Assets/img/altcha.png new file mode 100644 index 0000000000000000000000000000000000000000..5e57cf869472bdf1ae78dc3ae9c9eb70488af44e GIT binary patch literal 5680 zcmb7IWmHsM*u5YiA}Nh@3No~S!~jY-f^;JdLpRbbpwfcUNQp?p&@DOiFm#8+Fbp9j zE#JKB`~Cg+e%yQ3Id|Q&&Ux1J?EUQhNmE^ggouF%000umD@ASW81~;oNPvC!fGvly z1CE!r$_t$NLHc#K+EAtzT)s0e}E*>`##ZaD`n8+W~-gLIAL9 z0RU3z06^=W-J&G}0MwNbMFm~Ih5ejBZ{6uLxZL>|M@oi`NXp=KmndF=7PA$(^8;Nc zBV7)L6F8-C5KGGDPfBJ_ z%H*>UBl`Y*r+WT0ebiW6=b612u31&&A_%##TUWo6sWyIR$ZuR{tZ)Q&ZH!!I&TE$q8>Y@{Uxw%Yn;N@1 zQ-51YP7@oZJSnlD_UiPzW`(}RnmkEQG@2JwuW9CLIY7?A5Fhr0NcdVVKPe7Q`8!rH zdvDGX^~aLuJ}F*F*+Vu$K44j((FD3A$)TFIos#9m$pas+a4UjyAjj8=N=xIZpYA^Q z6^PU?!zDL>Jfb7U6@{i>{yVRwlb(rCpFOEE4-8D|yeY~6nPkq_4aS>kbQ?xR;>edp z4W>*s9d^hNkgB1nneJ9SB_nk8>T4YJn5L)AsKX2gwvsc1*fQ`R9%o1*Q_16Zk0O7T z%k-30HMno9k++-S=cx`2BCGv8z$x6e7GjQ+L-RyYHknzsZGRW&XDXcf_EYtMf(J8d zS*b&akeS5aRyy4p_2ApOC@xiX&l|t$S%oDGAVh};9jk-cBUzJE1ZDVX2?y}rKT77@ zIE$9_>{WHC_z+DX$Hc_O$;&7NGpwVc;uLmH{~U6gOs+(sMKzAb6JuD92zPZp^+d>i zm~UUa*2B}rU+ern`%vcmEn5nYUH)t=?bOMQpL&0W!E}FUAS^E2MQLIy zRkxQ8)3EUyj-e*O!-Ww6@@C%>o;eZd7&{&$*PbPY)*YR9HfkEptG|4iv}wa8U|qo( zzDTmskIoS38|wX(LCoyDH{rI@*8KVUP%eI{Cze>2^w-7Fpv`YxbDbWt_@jnJ7-iMydWs zefl(lV71_2o7fjLu?cBre9&=L)5d=@uJ{(4ukNX)60<#f2K%v!3!sYD)&iyi;z;qhqZe57X# z(lxmD`_olOVT7v?b{NEyGhZwE=kkgi2nUutW51SFC5)gKJ6HQCHL|(M^EB)a%~*z0 znF=dFbjUNgOGro~3H~x}*fgI=Xo!Mh&gv9Zh_$f^*Hz35Hg~C)ViMQv&Vo!{sD+lk z^?e$EhJS#Sg9CCTmB`cEL&lQdFtchWWlm)|!!KH9Ko&1){1ZvI6cUJIVCE1fjpuQn zRa9rVqH+7FK*`muh+vz01eu)oS;knJZMagbi2rU${+u(i2p@gMP6kf@!1>_QLwQ!< zd?hP>^oPthJ%dIaBL}zz`IREdgr6M&&O*qZ6SMM`I3c|IsA+y^NN@=NMMY8}7caMD zz#ljUQZ&BxuJ1hF2vG=+HZY^_jidLct&?DTZnU}JB)TER@$oSLBc3(SRrYC6I9fQv zVCL(GXL+PTdkL8kvJ&ZKRqBa@x7=#gT5ro$Wq`K=8OO(JeVAQLyYt+5c2X8!2YqW8 z1qX54pKi_m_}X5f5O$cigV|EHg0Pv}-6`53j_z}*-@b|4GfmT+Im!@)u=^|{n>oI( z=_SYMPOixwL!o~xTz&(2*boK#rv85C6PP5iVz0gvpW4N$t2@_pzVeTgs}G*Hz0L51 z7s*2kL`5;cU|&*X8eE*Z&}z9ka%}VQe8me{wHk8Jf&mUY0;5xSWNCvA ze_L@_!@2uzB;brdqe;S(QK!WQabsi7s{%y|qKE5~8q$V!KNCuH?)rZBsm!&`hWAjg z(#5S5&D!HZN2*UlfQ1E%JwsbS+}Xa%A`hXOnqLC>UD0UF`&4@1jl^xN`P~&^Zs-cl z^prXkzHA3KQJ{ad^%?3BR(~ARZ?+way;zw`Ze*A zmj|r5clckQ=5t&j%ZLXG>yXkiCdr()Q$$e~cOimUoWaJF1wA|Q_5dQa>hXn1^lJbbn4n*lFjQZ}(l0-j-e3w3VQ z*W_f*kfT^ef%Yji*UI(B+^f*Zl+1@L@u$ra#`&-y!CK?(a1ZK5 z8Pdr(Iqo0#0T@RsDz%sxzi>SC>Ef$vgo9n#`x&rDM5lV@LT+{_CnpWF__NE-aqUhVdMH+7HeHM$ePkFqH$#3?@b8uMg;9OW>u$d{p-CLv$ zz3;U(^cFhGw|9CuQ~rGDcnj3d3<%FKl}$CHP#wr8bYG}9P!PzAMx(Hsp!R}7%$YI^ zYTU8QndXtNm;DpGuDpp0fuu}^+fEnPgPT$D46!-qmL?Pu^0+4W(#6z51vJ#l#{P#Vi3rJXZ1~Dp1%c&GMSPcn`90C=k=1i1-+&pj@W+%YKU2 zxcusB=SNF29zH$+0hzdn7gTPhsxvY(Q#C_;RLwG<+qKBUnm81hpamEopCZ-^eP$BH)hc-sJ z11-i}yRngYlZqTjirP|8ttSlgcYlAt(;JM%aBbbGW?PR|uIw?*LwsN@R#4_u@{)rc z)GljWsd+ZPNXcpy+**+0JIGZsl9Lq=r(8t6enZ`Sz9Q52is#q%C_t$rj0b-eB zhsz5LztCR-B>O=VLu1E;03)5`wfAHT)H!NlEO(NLm`c@zc~Sp%Y|kcO-ET+@ zU*k|&+FMzscn8CLq#)>XW@%kc>_HCW%r%Uv1_q*-t4DuW&sFawTG+Sp+3xzbTwYAum18K4ATt&6M`9|C zT<7JRQ>0u9#A0*cRJ|gWb1>+#s8;ns(K1okAZN8*;>)9!W6y{BWprm*Ft6go($a>H zm571yTIYFkvj7L6=fh)r$7Bw_*dFe?1nG&N<%~m9CC@3|bs%_Zf-XJw{_bRp8AYoK z2n%`tgdn@jIy+=%mZ`7nfp`M-?44hz6e*Xc(^JkLZ&dSP6BN_8?PeKK4>hW7V>8P6 zgB8bb*6tgrrCV5I^rhW8P2nXv3VS{estlBqyG1EeAitoLPxxL#SK4r+Nn!==`L1?| zH#mDJw*#AJ-BTs>F|*S%Em%6EJ&9-YFd{CkAeorkDKzhN^X*MeRva2CFE_<}T(ER> z`0TfxVa;z2#w*mKq`)J19}P(nY5HsYn1lqJJqCo!U$8!iSLWn}yzeXE5Y=IMSG~2P zMfQ*;duQT8#y%^eM z5;Fy)wjLhPQp+1x$SZ{7d>s7|IioM9As1XeObxahd8JdHOGP}SYtY!Y^{1q1Z{#6? ztyyThFuU7rWvfVQ+ggpY(LIxfY)s8@4LG;A7Qu{qPbSqeb=k$LmYrT?C|A<4G8}&I z$Y?v^ArJw$buN#sv=6N>8r6C};YDY%O*mTq-J>iw4{cbS95FGzk@;%~!pzkBmNZ3i zc1@Ky5VKUieVPBGzw2nDs}~1ggEJ5SS)`iD5*!(zd+AXaV z^o)EVBG@t4eTu%bx(m6VuNhPxK1^+O{TJ%mf@Z}Q)`6!{mL?%quxM)+w7H)h!9k4A zAHBjV<-dnye^7FbiWVR-exr#_)lclGqRFm~U^6~G(pt|gVa5cDVy8z)T%QZ~`(E2q z?x-BNN2}n-#OAhRhN#NC|Ct{r_}|6bw;Igx7=IiF5=NPw_3bQCs$wT`lSd()i4`W& z0#7cx_ERr!yvRattRF_K!EGi!e@52RoL<{vyV?JwMFf8F^LKpiUDI;fI{0QG{^Wgq zyfL#Om$r{p)5QDhKPope(w_`)WQ^oU%Oe-h0$z?}`6g+A(H}qA2@UjN_&zAlgFP`UAwsc~Re2)jHHHEeRv zvq~B~Ol#V2U??s9-5T3s0=$AyTrRE(aPX-3XS+EbdtC3|m-HTBslA`JRc&kULXxW? zi6z%^eiB|@_5!+Z4Gr}WEKNuTNysW8(Q%v~mP*19g!GY8j! z4;~aO^%tq-sm=)95$@Cfmd9AqW;Cy;CZ2s-n z)^5j7Xd^Cb%=-H;;x46$_6e0p!Y^Noj;>eJgo6CEtE7C|z1+;#!tYNQHN(=EmIi2Y zu!3~ScXhpqAUJ?A>_n?tg{8u7qkn+bgMd^F=Hv2U*3>R$k>HczpcRGxPQKbfXQ+^a z!{_)*EM6!cnYPL-n@N_bBqo@)2M8IwrSRXEPJ7zh;DSmQMG;4gUne9mi73KP5M*&z zJ9G6;h-wQ3CNz5aPF^;^&)cUV729qutRvmpJTgfhXI})#nmF@OMcf3DP*c;7d^082 ziWr)$_xIu;lX2huEiH4^)(wY~UHT>~ZysNBb=<(>vFcw(X9M&3ga8+(>H5I?piMNb z>wT+F(9M1$i=JBH;UoZTZlMipp+ZGP+XCJ%F8twMN_ z`qU!+o`3QEuTd%Dq|DBRKl3QC)1M6w6)3%DmkN+g zW@D!#w!6CudQ@M}?C2ybCVu51_41XrWEv~zjjDpeqrSdDVmb?XDkViN`C>{c17QxF z<2@t66BG*+!R%HQV|OYAj7iK@G-hT%Mj%3wM z5J>LMAB@eC9{VY7-FHWtuYNSoBb=&o@?o%N(C7HH)BvDfJo0Rov(`cZXKM@n0C{QV z_7R&4e~yPiVq$n3nG@3}97(|n8rLSa`0GPNadowCbfjq|xt^Ynex=NC*ZWUeMOl@uE zVL_grahsc*eK(R{$41)}gWJ;tJF2nOeh)Pk^++U~%M#}TG0rMv5f+{Q^6afoM%2*; zh?vPyv5-PwXKnj#Ys$Z!&F9dkCb%`{zG4Y`|=8EEi z?xC%;$HEy{12BM}hKVLtoPD)x<1-eU*4*(tq;4e{!Qh+foT1k|q$-4Edao{O6RtUd zJCGLf>>~D3Scr{N4xiEmq0x}b&D0on9!e-IPl;FUFu1wAh$#FfOm-KK;H~<)sbtOTl4>6 hcK)vmx@&SJ*Y?xf+I!yE0Bh+25G8fR$`=+9{{vNQ@o)eD literal 0 HcmV?d00001 diff --git a/Assets/js/altcha.min.js b/Assets/js/altcha.min.js new file mode 100644 index 0000000..dad5748 --- /dev/null +++ b/Assets/js/altcha.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.39.0. + * Original file: /npm/altcha@2.2.4/dist/altcha.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +const Yn='(function(){"use strict";const d=new TextEncoder;function p(e){return[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join("")}async function b(e,t,r){if(typeof crypto>"u"||!("subtle"in crypto)||!("digest"in crypto.subtle))throw new Error("Web Crypto is not available. Secure context is required (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");return p(await crypto.subtle.digest(r.toUpperCase(),d.encode(e+t)))}function w(e,t,r="SHA-256",n=1e6,l=0){const o=new AbortController,a=Date.now();return{promise:(async()=>{for(let c=l;c<=n;c+=1){if(o.signal.aborted)return null;if(await b(t,c,r)===e)return{number:c,took:Date.now()-a}}return null})(),controller:o}}function h(e){const t=atob(e),r=new Uint8Array(t.length);for(let n=0;n{for(let i=n;i<=r;i+=1){if(o.signal.aborted||!c||!u)return null;try{const f=await crypto.subtle.decrypt({name:l,iv:g(i)},c,u);if(f)return{clearText:new TextDecoder().decode(f),took:Date.now()-a}}catch{}}return null};let c=null,u=null;try{u=h(e);const i=await crypto.subtle.digest("SHA-256",d.encode(t));c=await crypto.subtle.importKey("raw",i,l,!1,["decrypt"])}catch{return{promise:Promise.reject(),controller:o}}return{promise:s(),controller:o}}let y;onmessage=async e=>{const{type:t,payload:r,start:n,max:l}=e.data;let o=null;if(t==="abort")y?.abort(),y=void 0;else if(t==="work"){if("obfuscated"in r){const{key:a,obfuscated:s}=r||{};o=await m(s,a,l,n)}else{const{algorithm:a,challenge:s,salt:c}=r||{};o=w(s,c,a,l,n)}y=o.controller,o.promise.then(a=>{self.postMessage(a&&{...a,worker:!0})})}}})();\n',Dn=typeof self<"u"&&self.Blob&&new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);",Yn],{type:"text/javascript;charset=utf-8"});function Ni(e){let t;try{if(t=Dn&&(self.URL||self.webkitURL).createObjectURL(Dn),!t)throw"";const n=new Worker(t,{name:e?.name});return n.addEventListener("error",(()=>{(self.URL||self.webkitURL).revokeObjectURL(t)})),n}catch{return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(Yn),{name:e?.name})}}const Li="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(Li);const Pi=1,Oi=4,Fi=8,Mi=16,Vi=1,Ui=2,Mr="[",Zn="[!",zn="]",bt={},ae=Symbol(),ji="http://www.w3.org/1999/xhtml",Nn=!1;function Jn(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}var Kn=Array.isArray,qi=Array.prototype.indexOf,Bi=Array.from,or=Object.keys,Mt=Object.defineProperty,rt=Object.getOwnPropertyDescriptor,Hi=Object.getOwnPropertyDescriptors,Gi=Object.prototype,Wi=Array.prototype,Xn=Object.getPrototypeOf,Ln=Object.isExtensible;const yt=()=>{};function Qn(e){for(var t=0;t{var t=$;Re(a);var n=e();return Re(t),n};return r&&n.set("length",N(e.length)),new Proxy(e,{defineProperty(e,t,r){(!("value"in r)||!1===r.configurable||!1===r.enumerable||!1===r.writable)&&na();var o=n.get(t);return void 0===o?(o=l((()=>N(r.value))),n.set(t,o)):b(o,l((()=>Me(r.value)))),!0},deleteProperty(e,t){var i=n.get(t);if(void 0===i)t in e&&(n.set(t,l((()=>N(ae)))),Ir(o));else{if(r&&"string"==typeof t){var a=n.get("length"),s=Number(t);Number.isInteger(s)&&sN(Me(s?t[r]:ae)))),n.set(r,a)),void 0!==a){var c=i(a);return c===ae?void 0:c}return Reflect.get(t,r,o)},getOwnPropertyDescriptor(e,t){var r=Reflect.getOwnPropertyDescriptor(e,t);if(r&&"value"in r){var o=n.get(t);o&&(r.value=i(o))}else if(void 0===r){var a=n.get(t),l=a?.v;if(void 0!==a&&l!==ae)return{enumerable:!0,configurable:!0,value:l,writable:!0}}return r},has(e,t){if(t===Ot)return!0;var r=n.get(t),o=void 0!==r&&r.v!==ae||Reflect.has(e,t);if((void 0!==r||null!==S&&(!o||rt(e,t)?.writable))&&(void 0===r&&(r=l((()=>N(o?Me(e[t]):ae))),n.set(t,r)),i(r)===ae))return!1;return o},set(e,t,i,a){var s=n.get(t),c=t in e;if(r&&"length"===t)for(var u=i;uN(ae))),n.set(u+"",f))}void 0===s?(!c||rt(e,t)?.writable)&&(b(s=l((()=>N(void 0))),l((()=>Me(i)))),n.set(t,s)):(c=s.v!==ae,b(s,l((()=>Me(i)))));var d=Reflect.getOwnPropertyDescriptor(e,t);if(d?.set&&d.set.call(a,i),!c){if(r&&"string"==typeof t){var h=n.get("length"),v=Number(t);Number.isInteger(v)&&v>=h.v&&b(h,v+1)}Ir(o)}return!0},ownKeys(e){i(o);var t=Reflect.ownKeys(e).filter((e=>{var t=n.get(e);return void 0===t||t.v!==ae}));for(var[r,a]of n)a.v!==ae&&!(r in e)&&t.push(r);return t},setPrototypeOf(){oa()}})}function Ir(e,t=1){b(e,e.v+t)}var Pn,no,oo,io;function Tr(){if(void 0===Pn){Pn=window,no=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;oo=rt(t,"firstChild").get,io=rt(t,"nextSibling").get,Ln(e)&&(e.__click=void 0,e.__className=void 0,e.__attributes=null,e.__style=void 0,e.__e=void 0),Ln(n)&&(n.__t=void 0)}}function vr(e=""){return document.createTextNode(e)}function ve(e){return oo.call(e)}function Be(e){return io.call(e)}function z(e,t){if(!O)return ve(e);var n=ve(P);return null===n&&(n=P.appendChild(vr())),Ue(n),n}function Nt(e,t){if(!O){var n=ve(e);return n instanceof Comment&&""===n.data?Be(n):n}return P}function J(e,t=1,n=!1){let r=O?P:e;for(var o;t--;)o=r,r=Be(r);if(!O)return r;var i=r?.nodeType;if(n&&3!==i){var a=vr();return null===r?o?.after(a):r.before(a),Ue(a),a}return Ue(r),r}function sa(e){e.textContent=""}function ao(e){return e===this.v}function lo(e,t){return e!=e?t==t:e!==t||null!==e&&"object"==typeof e||"function"==typeof e}function jr(e){return!lo(e,this.v)}function gr(e){var t=2050,n=null!==$&&2&$.f?$:null;return null===S||null!==n&&n.f&fe?t|=fe:S.f|=to,{ctx:ne,deps:null,effects:null,equals:ao,f:t,fn:e,reactions:null,rv:0,v:null,wv:0,parent:n??S}}function Lt(e){const t=gr(e);return wo(t),t}function ua(e){const t=gr(e);return t.equals=jr,t}function so(e){var t=e.effects;if(null!==t){e.effects=null;for(var n=0;n{je(t)}}function va(e){const t=lt(64,e,!0);return(e={})=>new Promise((n=>{e.outro?Lr(t,(()=>{je(t),n(void 0)})):(je(t),n(void 0))}))}function Br(e){return lt(4,e,!1)}function Hr(e){return lt(8,e,!0)}function Ce(e,t=[],n=gr){const r=t.map(n);return fo((()=>e(...r.map(i))))}function fo(e,t=0){return lt(24|t,e,!0)}function Nr(e,t=!0){return lt(40,e,!0,t)}function ho(e){var t=e.teardown;if(null!==t){const e=qt,n=$;Fn(!0),Re(null);try{t.call(null)}finally{Fn(e),Re(n)}}}function vo(e,t=!1){var n=e.first;for(e.first=e.last=null;null!==n;){var r=n.next;64&n.f?n.parent=null:je(n,t),n=r}}function ga(e){for(var t=e.first;null!==t;){var n=t.next;!(32&t.f)&&je(t),t=n}}function je(e,t=!0){var n=!1;(t||!!(e.f&zi))&&null!==e.nodes_start&&(go(e.nodes_start,e.nodes_end),n=!0),vo(e,t&&!n),cr(e,0),_e(e,dr);var r=e.transitions;if(null!==r)for(const e of r)e.stop();ho(e);var o=e.parent;null!==o&&null!==o.first&&po(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes_start=e.nodes_end=null}function go(e,t){for(;null!==e;){var n=e===t?null:Be(e);e.remove(),e=n}}function po(e){var t=e.parent,n=e.prev,r=e.next;null!==n&&(n.next=r),null!==r&&(r.prev=n),null!==t&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Lr(e,t){var n=[];mo(e,n,!0),pa(n,(()=>{je(e),t&&t()}))}function pa(e,t){var n=e.length;if(n>0){var r=()=>--n||t();for(var o of e)o.out(r)}else t()}function mo(e,t,n){if(!(e.f&wt)){if(e.f^=wt,null!==e.transitions)for(const r of e.transitions)(r.is_global||n)&&t.push(r);for(var r=e.first;null!==r;){var o=r.next;mo(r,t,!!(!!(r.f&Ur)||!!(32&r.f))&&n),r=o}}}function On(e){_o(e,!0)}function _o(e,t){if(e.f&wt){e.f^=wt,!(e.f&le)&&(e.f^=le),Bt(e)&&(_e(e,Ie),mr(e));for(var n=e.first;null!==n;){var r=n.next;_o(n,!!(!!(n.f&Ur)||!!(32&n.f))&&t),n=r}if(null!==e.transitions)for(const n of e.transitions)(n.is_global||t)&&n.in()}}const ma=typeof requestIdleCallback>"u"?e=>setTimeout(e,1):requestIdleCallback;let Vt=[],Ut=[];function bo(){var e=Vt;Vt=[],Qn(e)}function yo(){var e=Ut;Ut=[],Qn(e)}function Gr(e){0===Vt.length&&queueMicrotask(bo),Vt.push(e)}function _a(e){0===Ut.length&&ma(yo),Ut.push(e)}function ba(){Vt.length>0&&bo(),Ut.length>0&&yo()}let tr=!1,lr=!1,sr=null,nt=!1,qt=!1;function Fn(e){qt=e}let Ft=[],$=null,ke=!1;function Re(e){$=e}let S=null;function qe(e){S=e}let Te=null;function wo(e){null!==$&&$.f&Sr&&(null===Te?Te=[e]:Te.push(e))}let re=null,ce=0,he=null;function ya(e){he=e}let Eo=1,ur=0,Ve=!1;function xo(){return++Eo}function Bt(e){var t=e.f;if(t&Ie)return!0;if(t&at){var n=e.deps,r=!!(t&fe);if(null!==n){var o,i,a=!!(t&ar),l=r&&null!==S&&!Ve,s=n.length;if(a||l){var c=e,u=c.parent;for(o=0;oe.wv)return!0}(!r||null!==S&&!Ve)&&_e(e,le)}return!1}function wa(e,t){for(var n=t;null!==n;){if(n.f&ir)try{return void n.fn(e)}catch{n.f^=ir}n=n.parent}throw tr=!1,e}function Mn(e){return!(e.f&dr||null!==e.parent&&e.parent.f&ir)}function pr(e,t,n,r){if(tr){if(null===n&&(tr=!1),Mn(t))throw e}else if(null!==n&&(tr=!0),wa(e,t),Mn(t))throw e}function Co(e,t,n=!0){var r=e.reactions;if(null!==r)for(var o=0;o0)for(f.length=ce+re.length,d=0;d0;){t++>1e3&&xa();var n=Ft,r=n.length;Ft=[];for(var o=0;o{r.d=!0}))}function So(e){const t=ne;if(null!==t){void 0!==e&&(t.x=e);const a=t.e;if(null!==a){var n=S,r=$;t.e=null;try{for(var o=0;o{document.activeElement===t&&e.focus()}))}}let Un=!1;function Do(){Un||(Un=!0,document.addEventListener("reset",(e=>{Promise.resolve().then((()=>{if(!e.defaultPrevented)for(const t of e.target.elements)t.__on_r?.()}))}),{capture:!0}))}function No(e){var t=$,n=S;Re(null),qe(null);try{return e()}finally{Re(t),qe(n)}}function Ta(e,t,n,r=n){e.addEventListener(t,(()=>No(n)));const o=e.__on_r;e.__on_r=o?()=>{o(),r(!0)}:()=>r(!0),Do()}const Lo=new Set,Pr=new Set;function Da(e,t,n,r={}){function o(e){if(r.capture||Pt.call(t,e),!e.cancelBubble)return No((()=>n?.call(this,e)))}return e.startsWith("pointer")||e.startsWith("touch")||"wheel"===e?Gr((()=>{t.addEventListener(e,o,r)})):t.addEventListener(e,o,r),o}function Fe(e,t,n,r,o){var i={capture:r,passive:o},a=Da(e,t,n,i);(t===document.body||t===window||t===document)&&qr((()=>{t.removeEventListener(e,a,i)}))}function Na(e){for(var t=0;ti||n});var u=$,f=S;Re(null),qe(null);try{for(var d,h=[];null!==i;){var v=i.assignedSlot||i.parentNode||i.host||null;try{var p=i["__"+r];if(null!=p&&(!i.disabled||e.target===i))if(Kn(p)){var[g,...b]=p;g.apply(i,[e,...b])}else p.call(i,e)}catch(e){d?h.push(e):d=e}if(e.cancelBubble||v===t||null===v)break;i=v}if(d){for(let e of h)queueMicrotask((()=>{throw e}));throw d}}finally{e.__root=t,delete e.currentTarget,Re(u),qe(f)}}}function Zr(e){var t=document.createElement("template");return t.innerHTML=e,t.content}function Ae(e,t){var n=S;null===n.nodes_start&&(n.nodes_start=e,n.nodes_end=t)}function be(e,t){var n,r=!!(1&t),o=!!(2&t),i=!e.startsWith("");return()=>{if(O)return Ae(P,null),P;void 0===n&&(n=Zr(i?e:""+e),r||(n=ve(n)));var t=o||no?document.importNode(n,!0):n.cloneNode(!0);r?Ae(ve(t),t.lastChild):Ae(t,t);return t}}function _r(e,t,n="svg"){var r,o=`<${n}>${!e.startsWith("")?e:""+e}`;return()=>{if(O)return Ae(P,null),P;if(!r){var e=Zr(o);r=ve(ve(e))}var t=r.cloneNode(!0);return Ae(t,t),t}}function Xt(){if(O)return Ae(P,null),P;var e=document.createDocumentFragment(),t=document.createComment(""),n=vr();return e.append(t,n),Ae(t,n),e}function B(e,t){if(O)return S.nodes_end=P,void Et();null!==e&&e.before(t)}function La(e,t){var n=null==t?"":"object"==typeof t?t+"":t;n!==(e.__t??=e.nodeValue)&&(e.__t=n,e.nodeValue=n+"")}function Po(e,t){return Oo(e,t)}function Pa(e,t){Tr(),t.intro=t.intro??!1;const n=t.target,r=O,o=P;try{for(var i=ve(n);i&&(8!==i.nodeType||i.data!==Mr);)i=Be(i);if(!i)throw bt;_t(!0),Ue(i),Et();const r=Oo(e,{...t,anchor:i});if(null===P||8!==P.nodeType||P.data!==zn)throw hr(),bt;return _t(!1),r}catch(r){if(r===bt)return!1===t.recover&&ta(),Tr(),sa(n),_t(!1),Po(e,t);throw r}finally{_t(r),Ue(o)}}const pt=new Map;function Oo(e,{target:t,anchor:n,props:r={},events:o,context:i,intro:a=!0}){Tr();var l=new Set,s=e=>{for(var n=0;n{var a=n??t.appendChild(vr());return Nr((()=>{i&&($o({}),ne.c=i);o&&(r.$$events=o),O&&Ae(a,null),c=e(a,r)||{},O&&(S.nodes_end=P),i&&So()})),()=>{for(var e of l){t.removeEventListener(e,Pt);var r=pt.get(e);0==--r?(document.removeEventListener(e,Pt),pt.delete(e)):pt.set(e,r)}Pr.delete(s),a!==n&&a.parentNode?.removeChild(a)}}));return Or.set(c,u),c}let Or=new WeakMap;function Oa(e,t){const n=Or.get(e);return n?(Or.delete(e),n(t)):Promise.resolve()}function K(e,t,[n,r]=[0,0]){O&&0===n&&Et();var o=e,i=null,a=null,l=ae,s=!1;const c=(e,t=!0)=>{s=!0,u(t,e)},u=(e,t)=>{if(l===(l=e))return;let s=!1;if(O&&-1!==r){if(0===n){const e=o.data;e===Mr?r=0:e===Zn?r=1/0:(r=parseInt(e.substring(1)))!=r&&(r=l?1/0:-1)}!!l===r>n&&(Ue(o=aa()),_t(!1),s=!0,r=-1)}l?(i?On(i):t&&(i=Nr((()=>t(o)))),a&&Lr(a,(()=>{a=null}))):(a?On(a):t&&(a=Nr((()=>t(o,[n+1,r])))),i&&Lr(i,(()=>{i=null}))),s&&_t(!0)};fo((()=>{s=!1,t(c),s||u(null,null)}),n>0?Ur:0),O&&(o=P)}function tt(e,t,n=!1,r=!1,o=!1){var i=e,a="";Ce((()=>{var e=S;if(a!==(a=t()??"")){if(null!==e.nodes_start&&(go(e.nodes_start,e.nodes_end),e.nodes_start=e.nodes_end=null),""!==a){if(O){P.data;for(var o=Et(),l=o;null!==o&&(8!==o.nodeType||""!==o.data);)l=o,o=Be(o);if(null===o)throw hr(),bt;return Ae(P,l),void(i=Ue(o))}var s=a+"";n?s=`${s}`:r&&(s=`${s}`);var c=Zr(s);if((n||r)&&(c=ve(c)),Ae(ve(c),c.lastChild),n||r)for(;ve(c);)i.before(ve(c));else i.before(c)}}else O&&Et()}))}function Fa(e,t,n,r,o){O&&Et();var i=t.$$slots?.[n],a=!1;!0===i&&(i=t.children,a=!0),void 0===i||i(e,a?()=>r:r)}const jn=[..." \t\n\r\f \v\ufeff"];function Ma(e,t,n){var r=""+e;if(n)for(var o in n)if(n[o])r=r?r+" "+o:o;else if(r.length)for(var i=o.length,a=0;(a=r.indexOf(o,a))>=0;){var l=a+i;0!==a&&!jn.includes(r[a-1])||l!==r.length&&!jn.includes(r[l])?a=l:r=(0===a?"":r.substring(0,a))+r.substring(l+1)}return""===r?null:r}function Va(e,t,n,r,o,i){var a=e.__className;if(O||a!==n||void 0===a){var l=Ma(n,r,i);(!O||l!==e.getAttribute("class"))&&(null==l?e.removeAttribute("class"):e.className=l),e.__className=n}else if(i&&o!==i)for(var s in i){var c=!!i[s];(null==o||c!==!!o[s])&&e.classList.toggle(s,c)}return i}const Ua=Symbol("is custom element"),ja=Symbol("is html");function qn(e){if(O){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute("value")){var n=e.value;R(e,"value",null),e.value=n}if(e.hasAttribute("checked")){var r=e.checked;R(e,"checked",null),e.checked=r}}};e.__on_r=n,_a(n),Do()}}function qa(e,t){var n=Fo(e);n.value===(n.value=t??void 0)||e.value===t&&(0!==t||"PROGRESS"!==e.nodeName)||(e.value=t??"")}function R(e,t,n,r){var o=Fo(e);O&&(o[t]=e.getAttribute(t),"src"===t||"srcset"===t||"href"===t&&"LINK"===e.nodeName)||o[t]!==(o[t]=n)&&("loading"===t&&(e[Ji]=n),null==n?e.removeAttribute(t):"string"!=typeof n&&Ba(e).includes(t)?e[t]=n:e.setAttribute(t,n))}function Fo(e){return e.__attributes??={[Ua]:e.nodeName.includes("-"),[ja]:e.namespaceURI===ji}}var Bn=new Map;function Ba(e){var t=Bn.get(e.nodeName);if(t)return t;Bn.set(e.nodeName,t=[]);for(var n,r=e,o=Element.prototype;o!==r;){for(var i in n=Hi(r))n[i].set&&t.push(i);r=Xn(r)}return t}function Ha(e,t,n=t){Ta(e,"change",(t=>{var r=t?e.defaultChecked:e.checked;n(r)})),(O&&e.defaultChecked!==e.checked||null==ot(t))&&n(e.checked),Hr((()=>{var n=t();e.checked=!!n}))}function Hn(e,t){return e===t||e?.[Ot]===t}function Qt(e={},t,n,r){return Br((()=>{var r,o;return Hr((()=>{r=o,o=[],ot((()=>{e!==n(...o)&&(t(e,...o),r&&Hn(n(...r),e)&&t(null,...r))}))})),()=>{Gr((()=>{o&&Hn(n(...o),e)&&t(null,...o)}))}})),e}function Mo(e){null===ne&&Jn(),Dr((()=>{const t=ot(e);if("function"==typeof t)return t}))}function Ga(e){null===ne&&Jn(),Mo((()=>()=>ot(e)))}function Vo(e,t,n){if(null==e)return t(void 0),yt;const r=ot((()=>e.subscribe(t,n)));return r.unsubscribe?()=>r.unsubscribe():r}const mt=[];function Wa(e,t=yt){let n=null;const r=new Set;function o(t){if(lo(e,t)&&(e=t,n)){const t=!mt.length;for(const t of r)t[1](),mt.push(t,e);if(t){for(let e=0;e{r.delete(s),0===r.size&&n&&(n(),n=null)}}}}function rr(e){let t;return Vo(e,(e=>t=e))(),t}let Uo,er=!1,Fr=Symbol();function Ya(e,t,n){const r=n[t]??={store:null,source:Yr(void 0),unsubscribe:yt};if(r.store!==e&&!(Fr in n))if(r.unsubscribe(),r.store=e??null,null==e)r.source.v=void 0,r.unsubscribe=yt;else{var o=!0;r.unsubscribe=Vo(e,(e=>{o?r.source.v=e:b(r.source,e)})),o=!1}return e&&Fr in n?rr(e):i(r.source)}function Za(){const e={};return[e,function(){qr((()=>{for(var t in e)e[t].unsubscribe();Mt(e,Fr,{enumerable:!1,value:!0})}))}]}function za(e){var t=er;try{return er=!1,[e(),er]}finally{er=t}}function Gn(e){return e.ctx?.d??!1}function x(e,t,n,r){var o,a=!!(1&n),l=!!(8&n),s=!!(16&n),c=!1;l?[o,c]=za((()=>e[t])):o=e[t];var u,f=Ot in e||ro in e,d=l&&(rt(e,t)?.set??(f&&t in e&&(n=>e[t]=n)))||void 0,h=r,v=!0,p=!1,g=()=>(p=!0,v&&(v=!1,h=s?ot(r):r),h);if(void 0===o&&void 0!==r&&(d&&ra(),o=g(),d&&d(o)),u=()=>{var n=e[t];return void 0===n?g():(v=!0,p=!1,n)},!(4&n))return u;if(d){var m=e.$$legacy;return function(e,t){return arguments.length>0?((!t||m||c)&&d(t?u():e),e):u()}}var y=!1,w=Yr(o),x=gr((()=>{var e=u(),t=i(w);return y?(y=!1,t):w.v=e}));return l&&i(x),a||(x.equals=jr),function(e,t){if(arguments.length>0){const n=t?i(x):l?Me(e):e;if(!x.equals(n)){if(y=!0,b(w,n),p&&void 0!==h&&(h=n),Gn(x))return e;ot((()=>i(x)))}return e}return Gn(x)?x.v:i(x)}}function Ja(e){return new Ka(e)}class Ka{#e;#t;constructor(e){var t=new Map,n=(e,n)=>{var r=Yr(n);return t.set(e,r),r};const r=new Proxy({...e.props||{},$$events:{}},{get:(e,r)=>i(t.get(r)??n(r,Reflect.get(e,r))),has:(e,r)=>r===ro||(i(t.get(r)??n(r,Reflect.get(e,r))),Reflect.has(e,r)),set:(e,r,o)=>(b(t.get(r)??n(r,o),o),Reflect.set(e,r,o))});this.#t=(e.hydrate?Pa:Po)(e.component,{target:e.target,anchor:e.anchor,props:r,context:e.context,intro:e.intro??!1,recover:e.recover}),(!e?.props?.$$host||!1===e.sync)&&E(),this.#e=r.$$events;for(const e of Object.keys(this.#t))"$set"===e||"$destroy"===e||"$on"===e||Mt(this,e,{get(){return this.#t[e]},set(t){this.#t[e]=t},enumerable:!0});this.#t.$set=e=>{Object.assign(r,e)},this.#t.$destroy=()=>{Oa(this.#t)}}$set(e){this.#t.$set(e)}$on(e,t){this.#e[e]=this.#e[e]||[];const n=(...e)=>t.call(this,...e);return this.#e[e].push(n),()=>{this.#e[e]=this.#e[e].filter((e=>e!==n))}}$destroy(){this.#t.$destroy()}}function nr(e,t,n,r){const o=n[e]?.type;if(t="Boolean"===o&&"boolean"!=typeof t?null!=t:t,!r||!n[e])return t;if("toAttribute"===r)switch(o){case"Object":case"Array":return null==t?null:JSON.stringify(t);case"Boolean":return t?"":null;case"Number":return t??null;default:return t}else switch(o){case"Object":case"Array":return t&&JSON.parse(t);case"Boolean":default:return t;case"Number":return null!=t?+t:t}}function Xa(e){const t={};return e.childNodes.forEach((e=>{t[e.slot||"default"]=!0})),t}function Qa(e,t,n,r,o,i){let a=class extends Uo{constructor(){super(e,n,o),this.$$p_d=t}static get observedAttributes(){return or(t).map((e=>(t[e].attribute||e).toLowerCase()))}};return or(t).forEach((e=>{Mt(a.prototype,e,{get(){return this.$$c&&e in this.$$c?this.$$c[e]:this.$$d[e]},set(n){n=nr(e,n,t),this.$$d[e]=n;var r=this.$$c;if(r){var o=rt(r,e)?.get;o?r[e]=n:r.$set({[e]:n})}}})})),r.forEach((e=>{Mt(a.prototype,e,{get(){return this.$$c?.[e]}})})),e.element=a,a}"function"==typeof HTMLElement&&(Uo=class extends HTMLElement{$$ctor;$$s;$$c;$$cn=!1;$$d={};$$r=!1;$$p_d={};$$l={};$$l_u=new Map;$$me;constructor(e,t,n){super(),this.$$ctor=e,this.$$s=t,n&&this.attachShadow({mode:"open"})}addEventListener(e,t,n){if(this.$$l[e]=this.$$l[e]||[],this.$$l[e].push(t),this.$$c){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}super.addEventListener(e,t,n)}removeEventListener(e,t,n){if(super.removeEventListener(e,t,n),this.$$c){const e=this.$$l_u.get(t);e&&(e(),this.$$l_u.delete(t))}}async connectedCallback(){if(this.$$cn=!0,!this.$$c){let e=function(e){return t=>{const n=document.createElement("slot");"default"!==e&&(n.name=e),B(t,n)}};if(await Promise.resolve(),!this.$$cn||this.$$c)return;const t={},n=Xa(this);for(const r of this.$$s)r in n&&("default"!==r||this.$$d.children?t[r]=e(r):(this.$$d.children=e(r),t.default=!0));for(const e of this.attributes){const t=this.$$g_p(e.name);t in this.$$d||(this.$$d[t]=nr(t,e.value,this.$$p_d,"toProp"))}for(const e in this.$$p_d)!(e in this.$$d)&&void 0!==this[e]&&(this.$$d[e]=this[e],delete this[e]);this.$$c=Ja({component:this.$$ctor,target:this.shadowRoot||this,props:{...this.$$d,$$slots:t,$$host:this}}),this.$$me=ha((()=>{Hr((()=>{this.$$r=!0;for(const e of or(this.$$c)){if(!this.$$p_d[e]?.reflect)continue;this.$$d[e]=this.$$c[e];const t=nr(e,this.$$d[e],this.$$p_d,"toAttribute");null==t?this.removeAttribute(this.$$p_d[e].attribute||e):this.setAttribute(this.$$p_d[e].attribute||e,t)}this.$$r=!1}))}));for(const e in this.$$l)for(const t of this.$$l[e]){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}this.$$l={}}}attributeChangedCallback(e,t,n){this.$$r||(e=this.$$g_p(e),this.$$d[e]=nr(e,n,this.$$p_d,"toProp"),this.$$c?.$set({[e]:this.$$d[e]}))}disconnectedCallback(){this.$$cn=!1,Promise.resolve().then((()=>{!this.$$cn&&this.$$c&&(this.$$c.$destroy(),this.$$me(),this.$$c=void 0)}))}$$g_p(e){return or(this.$$p_d).find((t=>this.$$p_d[t].attribute===e||!this.$$p_d[t].attribute&&t.toLowerCase()===e))||e}});const jo=new TextEncoder;function el(e){return[...new Uint8Array(e)].map((e=>e.toString(16).padStart(2,"0"))).join("")}async function tl(e,t="SHA-256",n=1e5){const r=Date.now().toString(16);e||(e=Math.round(Math.random()*n));return{algorithm:t,challenge:await qo(r,e,t),salt:r,signature:""}}async function qo(e,t,n){if(typeof crypto>"u"||!("subtle"in crypto)||!("digest"in crypto.subtle))throw new Error("Web Crypto is not available. Secure context is required (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");return el(await crypto.subtle.digest(n.toUpperCase(),jo.encode(e+t)))}function rl(e,t,n="SHA-256",r=1e6,o=0){const i=new AbortController,a=Date.now();return{promise:(async()=>{for(let l=o;l<=r;l+=1){if(i.signal.aborted)return null;if(await qo(t,l,n)===e)return{number:l,took:Date.now()-a}}return null})(),controller:i}}function Wn(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{}}function nl(e){const t=atob(e),n=new Uint8Array(t.length);for(let e=0;e{for(let e=r;e<=n;e+=1){if(i.signal.aborted||!l||!s)return null;try{const t=await crypto.subtle.decrypt({name:o,iv:ol(e)},l,s);if(t)return{clearText:(new TextDecoder).decode(t),took:Date.now()-a}}catch{}}return null})(),controller:i}}var y=(e=>(e.CODE="code",e.ERROR="error",e.VERIFIED="verified",e.VERIFYING="verifying",e.UNVERIFIED="unverified",e.EXPIRED="expired",e))(y||{}),Q=(e=>(e.ERROR="error",e.LOADING="loading",e.PLAYING="playing",e.PAUSED="paused",e.READY="ready",e))(Q||{});globalThis.altchaPlugins=globalThis.altchaPlugins||[],globalThis.altchaI18n=globalThis.altchaI18n||{get:e=>rr(globalThis.altchaI18n.store)[e],set:(e,t)=>{Object.assign(rr(globalThis.altchaI18n.store),{[e]:t}),globalThis.altchaI18n.store.set(rr(globalThis.altchaI18n.store))},store:Wa({})};const al={ariaLinkLabel:"Visit Altcha.org",enterCode:"Enter code",enterCodeAria:"Enter code you hear. Press Space to play audio.",error:"Verification failed. Try again later.",expired:"Verification expired. Try again.",footer:'Protected by ALTCHA',getAudioChallenge:"Get an audio challenge",label:"I'm not a robot",loading:"Loading...",reload:"Reload",verify:"Verify",verificationRequired:"Verification required!",verified:"Verified",verifying:"Verifying...",waitAlert:"Verifying... please wait."};globalThis.altchaI18n.set("en",al);const $r=(e,t)=>{let n=ua((()=>Yi(t?.(),24)));var r=cl();Ce((()=>{R(r,"width",i(n)),R(r,"height",i(n))})),B(e,r)};function ll(e,t){"Space"===e.code&&(e.preventDefault(),e.stopImmediatePropagation(),t())}function sl(e,t){e.preventDefault(),t()}function ul(e,t,n,r,o,a,l,s){[y.UNVERIFIED,y.ERROR,y.EXPIRED,y.CODE].includes(i(t))?!1!==n()&&!1===i(r)?.reportValidity()?b(o,!1):a()?l():s():b(o,!0)}var cl=_r(''),fl=be(''),dl=be('
'),hl=_r(''),vl=_r(''),gl=_r(''),pl=be(''),ml=be(""),_l=be(''),bl=be("
"),yl=be("
"),wl=be('
'),El=be(''),xl=be('
'),Cl=be('
',1);function kl(e,t){$o(t,!0);const[n,r]=Za(),o=()=>Ya(X,"$altchaI18nStore",n);let a=x(t,"auto",7,void 0),l=x(t,"blockspam",7,void 0),s=x(t,"challengeurl",7,void 0),c=x(t,"challengejson",7,void 0),u=x(t,"credentials",7,void 0),f=x(t,"customfetch",7,void 0),d=x(t,"debug",7,!1),h=x(t,"delay",7,0),v=x(t,"disableautofocus",7,!1),p=x(t,"refetchonexpire",7,!0),g=x(t,"disablerefetchonexpire",23,(()=>!p())),m=x(t,"expire",7,void 0),w=x(t,"floating",7,void 0),$=x(t,"floatinganchor",7,void 0),C=x(t,"floatingoffset",7,void 0),_=x(t,"floatingpersist",7,!1),k=x(t,"hidefooter",7,!1),A=x(t,"hidelogo",7,!1),I=x(t,"id",7,void 0),S=x(t,"language",7,void 0),L=x(t,"name",7,"altcha"),O=x(t,"maxnumber",7,1e6),P=x(t,"mockerror",7,!1),D=x(t,"obfuscated",7,void 0),V=x(t,"overlay",7,void 0),M=x(t,"overlaycontent",7,void 0),j=x(t,"plugins",7,void 0),T=x(t,"sentinel",7,void 0),F=x(t,"spamfilter",7,!1),U=x(t,"strings",7,void 0),q=x(t,"test",7,!1),H=x(t,"verifyurl",7,void 0),G=x(t,"workers",23,(()=>Math.min(16,navigator.hardwareConcurrency||8))),W=x(t,"workerurl",7,void 0);const{altchaI18n:Y}=globalThis,X=Y.store,ee=["SHA-256","SHA-384","SHA-512"],te=(e,n)=>{t.$$host.dispatchEvent(new CustomEvent(e,{detail:n}))},ne=document.documentElement.lang?.split("-")?.[0],re=Lt((()=>s()&&new URL(s(),location.origin).host.endsWith(".altcha.org")&&!!s()?.includes("apiKey=ckey_"))),oe=Lt((()=>c()?Ye(c()):void 0)),ie=Lt((()=>U()?Ye(U()):{})),ae=Lt((()=>({...Se(o()),...i(ie)}))),le=Lt((()=>`${I()||L()}_checkbox_${Math.round(1e8*Math.random())}`));let se=N(null),ce=N(!1),ue=N(null),fe=N(Me(y.UNVERIFIED)),de=N(void 0),he=N(null),ve=N(null),pe=N(null),ge=N(null),be=N(null),me=N(null),ye=N(null),we=N(null),xe=null,$e=N(null),Ee=N(!1),Re=[],_e=N(!1),ke=N(null);function Ae(e,t){return btoa(JSON.stringify({algorithm:e.algorithm,challenge:e.challenge,number:t.number,salt:e.salt,signature:e.signature,test:!!q()||void 0,took:t.took}))}function Ne(){s()&&!g()&&i(fe)===y.VERIFIED?wt():gt(y.EXPIRED,i(ae).expired)}function Ie(){let e=fetch;if(f())if(Pe("using customfetch"),"string"==typeof f()){if(e=globalThis[f()]||null,!e)throw new Error(`Custom fetch function not found: ${f()}`)}else e=f();return e}function Se(e,t=[S()||"",document.documentElement.lang||"",...navigator.languages]){const n=Object.keys(e).map((e=>e.toLowerCase())),r=t.reduce(((t,r)=>(r=r.toLowerCase(),t||(e[r]?r:null)||n.find((e=>r.split("-")[0]===e.split("-")[0]))||null)),null);return e[r||"en"]}function Le(e){return[...i(me)?.querySelectorAll(e?.length?e.map((e=>`input[name="${e}"]`)).join(", "):'input[type="text"]:not([data-no-spamfilter]), textarea:not([data-no-spamfilter])')||[]].reduce(((e,t)=>{const n=t.name,r=t.value;return n&&r&&(e[n]=/\n/.test(r)?r.replace(new RegExp("(?e instanceof Error)))&&console[e[0]instanceof Error?"error":"log"]("ALTCHA",`[name=${L()}]`,...e)}function De(){b($e,Q.PAUSED,!0)}function Ve(e){b($e,Q.ERROR,!0)}function Be(){b($e,Q.READY,!0)}function je(){b($e,Q.LOADING,!0)}function Te(){b($e,Q.PLAYING,!0)}function Ue(){b($e,Q.PAUSED,!0)}function qe(e){if(e.preventDefault(),e.stopPropagation(),i(ue)){const t=new FormData(e.target),n=String(t.get("code"));if(H()?.startsWith("fn:")){const e=H().replace(/^fn:/,"");if(Pe(`calling ${e} function instead of verifyurl`),!(e in globalThis))throw new Error(`Global function "${e}" is undefined.`);return globalThis[e]({challenge:i(ue).challenge,code:n,solution:i(ue).solution})}b(Ee,!0),nt(Ae(i(ue).challenge,i(ue).solution),n).then((({reason:e,verified:t})=>{t?(b(ue,null),mt(y.VERIFIED),Pe("verified"),Rr().then((()=>{i(ge)?.focus(),te("verified",{payload:i(ke)}),"onsubmit"===a()?rt(i(ye)):V()&&vt()}))):(gt(),b(we,e||"Verification failed",!0))})).catch((e=>{b(ue,null),mt(y.ERROR,e),Pe("sentinel verification failed:",e)})).finally((()=>{b(Ee,!1)}))}}function Ze(e){const t=e.target;w()&&t&&!i(de).contains(t)&&(i(fe)===y.VERIFIED&&!1===_()||i(fe)===y.VERIFIED&&"focus"===_()&&!i(me)?.matches(":focus-within")||"off"===a()&&i(fe)===y.UNVERIFIED)&&vt()}function ze(){w()&&i(fe)!==y.UNVERIFIED&&pt()}function He(e){i(fe)===y.UNVERIFIED?wt():w()&&"focus"===_()&&i(fe)===y.VERIFIED&&yt()}function Ge(e){e.target?.hasAttribute("data-code-challenge-form")||(b(ye,e.submitter,!0),i(me)&&"onsubmit"===a()?(i(ye)?.blur(),i(fe)===y.UNVERIFIED?(e.preventDefault(),e.stopPropagation(),wt().then((()=>{rt(i(ye))}))):i(fe)!==y.VERIFIED&&(e.preventDefault(),e.stopPropagation(),i(fe)===y.VERIFYING&&Ke())):i(me)&&w()&&"off"===a()&&i(fe)===y.UNVERIFIED&&(e.preventDefault(),e.stopPropagation(),yt()))}function Je(){gt()}function Ke(){i(fe)===y.VERIFYING&&i(ae).waitAlert&&alert(i(ae).waitAlert)}function We(){i(ve)?i(ve).paused?(i(ve).currentTime=0,i(ve).play()):i(ve).pause():(b(_e,!0),requestAnimationFrame((()=>{i(ve)?.play()})))}function Qe(){w()&&pt()}function Ye(e){return JSON.parse(e)}function Xe(e){const t=new URLSearchParams(e.split("?")?.[1]),n=t.get("expires")||t.get("expire");if(n){const e=new Date(1e3*+n),t=isNaN(e.getTime())?0:e.getTime()-Date.now();t>0&&ot(t)}else xe&&(clearTimeout(xe),xe=null)}async function et(e){if(!H())throw new Error("Attribute verifyurl not set.");Pe("requesting server verification from",H());const t={payload:e};if(!1!==F()){const{blockedCountries:e,classifier:n,disableRules:r,email:o,expectedLanguages:a,expectedCountries:l,fields:s,ipAddress:c,text:u,timeZone:f}="ipAddress"===F()?{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:!1,expectedCountries:void 0,expectedLanguages:void 0,fields:!1,ipAddress:void 0,text:void 0,timeZone:void 0}:"object"==typeof F()?F():{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:void 0,expectedCountries:void 0,expectedLanguages:void 0,fields:void 0,ipAddress:void 0,text:void 0,timeZone:void 0};t.blockedCountries=e,t.classifier=n,t.disableRules=r,t.email=!1===o?void 0:function(e){const t=i(me)?.querySelector("string"==typeof e?`input[name="${e}"]`:'input[type="email"]:not([data-no-spamfilter])');return t?.value?.slice(t.value.indexOf("@"))||void 0}(o),t.expectedCountries=l,t.expectedLanguages=a||(ne?[ne]:void 0),t.fields=!1===s?void 0:Le(s),t.ipAddress=!1===c?void 0:c||"auto",t.text=u,t.timeZone=!1===f?void 0:f||Wn()}const n=await Ie()(H(),{body:JSON.stringify(t),headers:{"content-type":"application/json"},method:"POST"});if(!(n&&n instanceof Response))throw new Error("Custom fetch function did not return a response.");if(200!==n.status)throw new Error(`Server responded with ${n.status}.`);const r=await n.json();if(r?.payload&&b(ke,r.payload,!0),te("serververification",r),l()&&"BAD"===r.classification)throw new Error("SpamFilter returned negative classification.")}async function nt(e,t){if(!H())throw new Error("Attribute verifyurl not set.");Pe("requesting sentinel verification from",H());const n={code:t,payload:e};T()&&(n.fields=T().fields?Le():void 0,n.timeZone=T().timeZone?Wn():void 0);const r=await Ie()(H(),{body:JSON.stringify(n),headers:{"content-type":"application/json"},method:"POST"});if(!(r&&r instanceof Response))throw new Error("Fetch function did not return a response.");if(200!==r.status)throw new Error(`Server responded with ${r.status}.`);const o=await r.json();return o?.payload&&b(ke,o.payload,!0),te("sentinelverification",o),o}function rt(e){i(me)&&"requestSubmit"in i(me)?i(me).requestSubmit(e):i(me)?.reportValidity()&&(e?e.click():i(me).submit())}function ot(e){Pe("expire",e),xe&&(clearTimeout(xe),xe=null),e<1?Ne():xe=setTimeout(Ne,e)}function it(e){Pe("floating",e),w()!==e&&(i(de).style.left="",i(de).style.top=""),w(!0===e||""===e?"auto":!1===e||"false"===e?void 0:w()),w()?(a()||a("onsubmit"),document.addEventListener("scroll",ze),document.addEventListener("click",Ze),window.addEventListener("resize",Qe)):"onsubmit"===a()&&a(void 0)}function at(e){if(Pe("overlay",e),V(e),e){if(a()||a("onsubmit"),i(pe)&&i(de).parentElement&&i(pe).replaceWith(i(de).parentElement),i(de)?.parentElement?.parentElement){b(pe,document.createElement("div"),!0),i(de).parentElement.parentElement.appendChild(i(pe));const e=document.createElement("div"),t=document.createElement("button");t.type="button",t.innerHTML="×",t.addEventListener("click",(e=>{e.preventDefault(),gt()})),i(pe).classList.add("altcha-overlay-backdrop"),t.classList.add("altcha-overlay-close-button"),e.classList.add("altcha-overlay"),i(pe).append(e),e.append(t),M()&&e.append(...document.querySelectorAll(M())),e.append(i(de).parentElement)}}else i(pe)&&i(de).parentElement&&(i(pe).replaceWith(i(de).parentElement),i(de).style.display="block")}function lt(e){if(!e.algorithm)throw new Error("Invalid challenge. Property algorithm is missing.");if(void 0===e.signature)throw new Error("Invalid challenge. Property signature is missing.");if(!ee.includes(e.algorithm.toUpperCase()))throw new Error(`Unknown algorithm value. Allowed values: ${ee.join(", ")}`);if(!e.challenge||e.challenge.length<40)throw new Error("Challenge is too short. Min. 40 chars.");if(!e.salt||e.salt.length<10)throw new Error("Salt is too short. Min. 10 chars.")}async function st(e){let t=null,n=null;if("Worker"in window){try{t=function(e,t=("number"==typeof q()?q():e.maxNumber||e.maxnumber||O()),n=Math.ceil(G())){const r=new AbortController,o=[];n=Math.min(16,t,Math.max(1,n));for(let e=0;e{const t=await Promise.all(o.map(((t,n)=>{const a=n*i;return r.signal.addEventListener("abort",(()=>{t.postMessage({type:"abort"})})),new Promise((n=>{t.addEventListener("message",(e=>{if(e.data)for(const e of o)e!==t&&e.postMessage({type:"abort"});n(e.data)})),t.postMessage({payload:e,max:a+i,start:a,type:"work"})}))})));for(const e of o)e.terminate();return t.find((e=>!!e))||null})(),controller:r}}(e,e.maxNumber||e.maxnumber||O()),b(se,t.controller,!0),n=await t.promise}catch(e){Pe(e)}finally{b(se,null)}if(null===n||void 0!==n?.number||"obfuscated"in e)return{data:e,solution:n}}if("obfuscated"in e){const t=await il(e.obfuscated,e.key,e.maxNumber||e.maxnumber);return{data:e,solution:await t.promise}}t=rl(e.challenge,e.salt,e.algorithm,e.maxNumber||e.maxnumber||O()),b(se,t.controller,!0);try{n=await t.promise}catch(e){Pe(e)}finally{b(se,null)}return{data:e,solution:n}}async function ct(){if(!D())return void mt(y.ERROR);const e=Re.find((e=>"obfuscation"===e.constructor.pluginName));return e&&"clarify"in e?"clarify"in e&&"function"==typeof e.clarify?e.clarify():void 0:(mt(y.ERROR),void Pe("Plugin `obfuscation` not found. Import `altcha/plugins/obfuscation` to load it."))}function ut(e){void 0!==e.obfuscated&&D(e.obfuscated),void 0!==e.auto&&(a(e.auto),"onload"===a()&&(D()?ct():wt())),void 0!==e.blockspam&&l(!!e.blockspam),void 0!==e.customfetch&&f(e.customfetch),void 0!==e.floatinganchor&&$(e.floatinganchor),void 0!==e.delay&&h(e.delay),void 0!==e.floatingoffset&&C(e.floatingoffset),void 0!==e.floating&&it(e.floating),void 0!==e.expire&&(ot(e.expire),m(e.expire)),e.challenge&&(c("string"==typeof e.challenge?e.challenge:JSON.stringify(e.challenge)),lt(i(oe))),void 0!==e.challengeurl&&s(e.challengeurl),void 0!==e.debug&&d(!!e.debug),void 0!==e.hidefooter&&k(!!e.hidefooter),void 0!==e.hidelogo&&A(!!e.hidelogo),void 0!==e.language&&U(Se(o(),[e.language])),void 0!==e.maxnumber&&O(+e.maxnumber),void 0!==e.mockerror&&P(!!e.mockerror),void 0!==e.name&&L(e.name),void 0!==e.overlaycontent&&M(e.overlaycontent),void 0!==e.overlay&&at(e.overlay),void 0!==e.refetchonexpire&&g(!e.refetchonexpire),void 0!==e.disablerefetchonexpire&&g(!e.disablerefetchonexpire),void 0!==e.sentinel&&"object"==typeof e.sentinel&&T(e.sentinel),void 0!==e.spamfilter&&F("object"==typeof e.spamfilter?e.spamfilter:!!e.spamfilter),e.strings&&U("string"==typeof e.strings?e.strings:JSON.stringify(e.strings)),void 0!==e.test&&q("number"==typeof e.test?e.test:!!e.test),void 0!==e.verifyurl&&H(e.verifyurl),void 0!==e.workers&&G(+e.workers),void 0!==e.workerurl&&W(e.workerurl)}function ft(){return{auto:a(),blockspam:l(),challengeurl:s(),debug:d(),delay:h(),disableautofocus:v(),disablerefetchonexpire:g(),expire:m(),floating:w(),floatinganchor:$(),floatingoffset:C(),hidefooter:k(),hidelogo:A(),name:L(),maxnumber:O(),mockerror:P(),obfuscated:D(),overlay:V(),refetchonexpire:!g(),spamfilter:F(),strings:i(ae),test:q(),verifyurl:H(),workers:G(),workerurl:W()}}function dt(){return i(be)}function ht(){return i(fe)}function vt(){i(de).style.display="none",V()&&i(pe)&&(i(pe).style.display="none")}function pt(e=20){if(i(de))if(i(be)||b(be,($()?document.querySelector($()):i(me)?.querySelector('input[type="submit"], button[type="submit"], button:not([type="button"]):not([type="reset"])'))||i(me),!0),i(be)){const t=parseInt(C(),10)||12,n=i(be).getBoundingClientRect(),r=i(de).getBoundingClientRect(),o=document.documentElement.clientHeight,a=document.documentElement.clientWidth,l="auto"===w()?n.bottom+r.height+t+e>o:"top"===w(),s=Math.max(e,Math.min(a-e-r.width,n.left+n.width/2-r.width/2));if(i(de).style.top=l?n.top-(r.height+t)+"px":`${n.bottom+t}px`,i(de).style.left=`${s}px`,i(de).setAttribute("data-floating",l?"top":"bottom"),i(he)){const e=i(he).getBoundingClientRect();i(he).style.left=n.left-s+n.width/2-e.width/2+"px"}}else Pe("unable to find floating anchor element")}function gt(e=y.UNVERIFIED,t=null){i(se)&&(i(se).abort(),b(se,null)),b(ce,!1),b(ke,null),b(ue,null),b(_e,!1),b($e,null),mt(e,t)}function bt(e){b(be,e,!0)}function mt(e,t=null){b(fe,e,!0),b(we,t,!0),te("statechange",{payload:i(ke),state:i(fe)})}function yt(){i(de).style.display="block",w()&&pt(),V()&&i(pe)&&(i(pe).style.display="flex")}async function wt(){return gt(y.VERIFYING),await new Promise((e=>setTimeout(e,h()||0))),async function(){if(P())throw Pe("mocking error"),new Error("Mocked error.");if(i(oe))return Pe("using provided json data"),Xe(i(oe).salt),i(oe);if(q())return Pe("generating test challenge",{test:q()}),tl("boolean"!=typeof q()?+q():void 0);{if(!s()&&i(me)){const e=i(me).getAttribute("action");e?.includes("/form/")&&s(e+"/altcha")}if(!s())throw new Error("Attribute challengeurl not set.");Pe("fetching challenge from",s());const e={credentials:"boolean"==typeof u()?"include":u(),headers:!1!==F()?{"x-altcha-spam-filter":"1"}:{}},t=await Ie()(s(),e);if(!(t&&t instanceof Response))throw new Error("Custom fetch function did not return a response.");if(200!==t.status)throw new Error(`Server responded with ${t.status}.`);const n=t.headers.get("X-Altcha-Config"),r=await t.json();if(Xe(r.salt),n)try{const e=JSON.parse(n);e&&"object"==typeof e&&(e.verifyurl&&!e.verifyurl.startsWith("fn:")&&(e.verifyurl=Oe(e.verifyurl)),ut(e))}catch(e){Pe("unable to configure from X-Altcha-Config",e)}return r}}().then((e=>(lt(e),Pe("challenge",e),st(e)))).then((({data:e,solution:t})=>{if(Pe("solution",t),!t||e&&"challenge"in e&&!("clearText"in t))if(void 0!==t?.number&&"challenge"in e)if(H()&&"codeChallenge"in e)["INPUT","BUTTON","SELECT","TEXTAREA"].includes(document.activeElement?.tagName||"")&&!1===v()&&document.activeElement.blur(),b(ue,{challenge:e,solution:t},!0);else{if(H()&&void 0!==T())return nt(Ae(e,t));if(H())return et(Ae(e,t));b(ke,Ae(e,t),!0),Pe("payload",i(ke))}else if(i(fe)!==y.EXPIRED)throw Pe("Unable to find a solution. Ensure that the 'maxnumber' attribute is greater than the randomly generated number."),new Error("Unexpected result returned.")})).then((()=>{i(ue)?(mt(y.CODE),Rr().then((()=>{te("code",{codeChallenge:i(ue)})}))):i(ke)&&(mt(y.VERIFIED),Pe("verified"),Rr().then((()=>{te("verified",{payload:i(ke)}),V()&&vt()})))})).catch((e=>{Pe(e),mt(y.ERROR,e.message)}))}Dr((()=>{!function(){for(const e of Re)"function"==typeof e.onErrorChange&&e.onErrorChange(i(we))}(i(we))})),Dr((()=>{!function(){for(const e of Re)"function"==typeof e.onStateChange&&e.onStateChange(i(fe));w()&&i(fe)!==y.UNVERIFIED&&requestAnimationFrame((()=>{pt()})),b(ce,i(fe)===y.VERIFIED),V()&&i(pe)&&(i(fe)!==y.UNVERIFIED?yt():vt())}(i(fe))})),Ga((()=>{(function(){for(const e of Re)e.destroy()})(),b(ye,null),i(me)&&(i(me).removeEventListener("submit",Ge),i(me).removeEventListener("reset",Je),i(me).removeEventListener("focusin",He),b(me,null)),xe&&(clearTimeout(xe),xe=null),document.removeEventListener("click",Ze),document.removeEventListener("scroll",ze),window.removeEventListener("resize",Qe)})),Mo((()=>{Pe("mounted","2.2.4"),Pe("workers",G()),function(){const e=void 0!==j()?j().split(","):void 0;for(const t of globalThis.altchaPlugins)(!e||e.includes(t.pluginName))&&Re.push(new t({el:i(de),clarify:ct,dispatch:te,getConfiguration:ft,getFloatingAnchor:dt,getState:ht,log:Pe,reset:gt,solve:st,setState:mt,setFloatingAnchor:bt,verify:wt}))}(),Pe("plugins",Re.length?Re.map((e=>e.constructor.pluginName)).join(", "):"none"),q()&&Pe("using test mode"),m()&&ot(m()),void 0!==a()&&Pe("auto",a()),void 0!==w()&&it(w()),b(me,i(de)?.closest("form"),!0),i(me)&&(i(me).addEventListener("submit",Ge,{capture:!0}),i(me).addEventListener("reset",Je),("onfocus"===a()||"focus"===_())&&i(me).addEventListener("focusin",He)),V()&&at(!0),"onload"===a()&&(D()?ct():wt()),i(re)&&(k()||A())&&Pe("Attributes hidefooter and hidelogo ignored because usage with free API Keys requires attribution."),requestAnimationFrame((()=>{te("load")}))}));var xt=Cl(),$t=Nt(xt);Fa($t,t,"default",{});var Et=J($t,2),Ct=z(Et),Rt=z(Ct);let _t;var kt=z(Rt),At=e=>{$r(e)};K(kt,(e=>{i(fe)===y.VERIFYING&&e(At)}));var It=J(kt,2);qn(It),It.__change=[ul,fe,F,me,ce,D,ct,wt],Qt(It,(e=>b(ge,e)),(()=>i(ge))),Z(Rt);var St=J(Rt,2),Ot=z(St),Pt=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verified)),B(e,t)},Dt=(e,t)=>{var n=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verifying)),B(e,t)},r=(e,t)=>{var n=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verificationRequired)),B(e,t)},r=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).label)),B(e,t)};K(e,(e=>{i(fe)===y.CODE?e(n):e(r,!1)}),t)};K(e,(e=>{i(fe)===y.VERIFYING?e(n):e(r,!1)}),t)};K(Ot,(e=>{i(fe)===y.VERIFIED?e(Pt):e(Dt,!1)})),Z(St);var Vt=J(St,2),Bt=e=>{var t=fl();qn(t),Ce((()=>{R(t,"name",L()),qa(t,i(ke))})),B(e,t)};K(Vt,(e=>{i(fe)===y.VERIFIED&&e(Bt)}));var Mt=J(Vt,2),jt=e=>{var t=dl(),n=z(t);R(n,"href","https://altcha.org/"),Z(t),Ce((()=>R(n,"aria-label",i(ae).ariaLinkLabel))),B(e,t)};K(Mt,(e=>{(!0!==A()||i(re))&&e(jt)}));var Tt=J(Mt,2),Ft=e=>{var t=_l(),n=J(z(t),2),r=z(n),o=J(r,2);Sa(o,!v()),o.__keydown=[ll,We];var a=J(o,2),l=z(a),s=z(l),c=e=>{var t=pl();t.__click=We;var n=z(t),r=e=>{$r(e,(()=>20))},o=(e,t)=>{var n=e=>{B(e,hl())},r=(e,t)=>{var n=e=>{B(e,vl())},r=e=>{B(e,gl())};K(e,(e=>{i($e)===Q.PLAYING?e(n):e(r,!1)}),t)};K(e,(e=>{i($e)===Q.ERROR?e(n):e(r,!1)}),t)};K(n,(e=>{i($e)===Q.LOADING?e(r):e(o,!1)})),Z(t),Ce((()=>{R(t,"title",i(ae).getAudioChallenge),t.disabled=i($e)===Q.LOADING||i($e)===Q.ERROR||i(Ee),R(t,"aria-label",i($e)===Q.LOADING?i(ae).loading:i(ae).getAudioChallenge)})),B(e,t)};K(s,(e=>{i(ue).challenge.codeChallenge.audio&&e(c)}));var u=J(s,2);u.__click=[sl,wt],Z(l);var f=J(l,2),d=z(f),h=e=>{$r(e,(()=>16))};K(d,(e=>{i(Ee)&&e(h)}));var p=J(d);Z(f),Z(a);var g=J(a,2),m=e=>{var t=ml(),n=z(t);Z(t),Qt(t,(e=>b(ve,e)),(()=>i(ve))),Ce((e=>R(n,"src",e)),[()=>Oe(i(ue).challenge.codeChallenge.audio,{language:S()})]),Fe("loadstart",t,je),Fe("canplay",t,Be),Fe("pause",t,Ue),Fe("playing",t,Te),Fe("ended",t,De),Fe("error",n,Ve),B(e,t)};K(g,(e=>{i(ue).challenge.codeChallenge.audio&&i(_e)&&e(m)})),Z(n),Z(t),Ce((()=>{R(t,"aria-label",i(ae).verificationRequired),R(r,"src",i(ue).challenge.codeChallenge.image),R(o,"minlength",i(ue).challenge.codeChallenge.length||1),R(o,"maxlength",i(ue).challenge.codeChallenge.length),R(o,"placeholder",i(ae).enterCode),R(o,"aria-label",i($e)===Q.LOADING?i(ae).loading:i($e)===Q.PLAYING?"":i(ae).enterCodeAria),R(o,"aria-live",i($e)?"assertive":"polite"),R(o,"aria-busy",i($e)===Q.LOADING),o.disabled=i(Ee),R(u,"aria-label",i(ae).reload),R(u,"title",i(ae).reload),u.disabled=i(Ee),f.disabled=i(Ee),R(f,"aria-label",i(ae).verify),La(p,` ${i(ae).verify??""}`)})),Fe("submit",n,qe,!0),B(e,t)};K(Tt,(e=>{i(ue)?.challenge.codeChallenge&&e(Ft)})),Z(Ct);var Ut=J(Ct,2),qt=e=>{var t=wl(),n=J(z(t),2),r=e=>{var t=bl();tt(z(t),(()=>i(ae).expired)),Z(t),Ce((()=>R(t,"title",i(we)))),B(e,t)},o=e=>{var t=yl();tt(z(t),(()=>i(ae).error)),Z(t),Ce((()=>R(t,"title",i(we)))),B(e,t)};K(n,(e=>{i(fe)===y.EXPIRED?e(r):e(o,!1)})),Z(t),B(e,t)};K(Ut,(e=>{(i(we)||i(fe)===y.EXPIRED)&&e(qt)}));var Zt=J(Ut,2),zt=e=>{var t=El(),n=z(t);tt(z(n),(()=>i(ae).footer)),Z(n),Z(t),B(e,t)};K(Zt,(e=>{i(ae).footer&&(!0!==k()||i(re))&&e(zt)}));var Ht=J(Zt,2),Gt=e=>{var t=xl();Qt(t,(e=>b(he,e)),(()=>i(he))),B(e,t)};K(Ht,(e=>{w()&&e(Gt)})),Z(Et),Qt(Et,(e=>b(de,e)),(()=>i(de))),Ce((e=>{R(Et,"data-state",i(fe)),R(Et,"data-floating",w()),R(Et,"data-overlay",V()),_t=Va(Rt,1,"altcha-checkbox",null,_t,e),R(It,"id",i(le)),It.required="onsubmit"!==a()&&(!w()||"off"!==a()),R(St,"for",i(le))}),[()=>({"altcha-checkbox-verifying":i(fe)===y.VERIFYING})]),Fe("invalid",It,Ke),Ha(It,(()=>i(ce)),(e=>b(ce,e))),B(e,xt);var Jt=So({clarify:ct,configure:ut,getConfiguration:ft,getFloatingAnchor:dt,getPlugin:function(e){return Re.find((t=>t.constructor.pluginName===e))},getState:ht,hide:vt,repositionFloating:pt,reset:gt,setFloatingAnchor:bt,setState:mt,show:yt,verify:wt,get auto(){return a()},set auto(e=void 0){a(e),E()},get blockspam(){return l()},set blockspam(e=void 0){l(e),E()},get challengeurl(){return s()},set challengeurl(e=void 0){s(e),E()},get challengejson(){return c()},set challengejson(e=void 0){c(e),E()},get credentials(){return u()},set credentials(e=void 0){u(e),E()},get customfetch(){return f()},set customfetch(e=void 0){f(e),E()},get debug(){return d()},set debug(e=!1){d(e),E()},get delay(){return h()},set delay(e=0){h(e),E()},get disableautofocus(){return v()},set disableautofocus(e=!1){v(e),E()},get refetchonexpire(){return p()},set refetchonexpire(e=!0){p(e),E()},get disablerefetchonexpire(){return g()},set disablerefetchonexpire(e=!p){g(e),E()},get expire(){return m()},set expire(e=void 0){m(e),E()},get floating(){return w()},set floating(e=void 0){w(e),E()},get floatinganchor(){return $()},set floatinganchor(e=void 0){$(e),E()},get floatingoffset(){return C()},set floatingoffset(e=void 0){C(e),E()},get floatingpersist(){return _()},set floatingpersist(e=!1){_(e),E()},get hidefooter(){return k()},set hidefooter(e=!1){k(e),E()},get hidelogo(){return A()},set hidelogo(e=!1){A(e),E()},get id(){return I()},set id(e=void 0){I(e),E()},get language(){return S()},set language(e=void 0){S(e),E()},get name(){return L()},set name(e="altcha"){L(e),E()},get maxnumber(){return O()},set maxnumber(e=1e6){O(e),E()},get mockerror(){return P()},set mockerror(e=!1){P(e),E()},get obfuscated(){return D()},set obfuscated(e=void 0){D(e),E()},get overlay(){return V()},set overlay(e=void 0){V(e),E()},get overlaycontent(){return M()},set overlaycontent(e=void 0){M(e),E()},get plugins(){return j()},set plugins(e=void 0){j(e),E()},get sentinel(){return T()},set sentinel(e=void 0){T(e),E()},get spamfilter(){return F()},set spamfilter(e=!1){F(e),E()},get strings(){return U()},set strings(e=void 0){U(e),E()},get test(){return q()},set test(e=!1){q(e),E()},get verifyurl(){return H()},set verifyurl(e=void 0){H(e),E()},get workers(){return G()},set workers(e=Math.min(16,navigator.hardwareConcurrency||8)){G(e),E()},get workerurl(){return W()},set workerurl(e=void 0){W(e),E()}});return r(),Jt}Na(["change","keydown","click"]),customElements.define("altcha-widget",Qa(kl,{blockspam:{type:"Boolean"},debug:{type:"Boolean"},delay:{type:"Number"},disableautofocus:{type:"Boolean"},disablerefetchonexpire:{type:"Boolean"},expire:{type:"Number"},floatingoffset:{type:"Number"},hidefooter:{type:"Boolean"},hidelogo:{type:"Boolean"},maxnumber:{type:"Number"},mockerror:{type:"Boolean"},refetchonexpire:{type:"Boolean"},test:{type:"Boolean"},workers:{type:"Number"},auto:{},challengeurl:{},challengejson:{},credentials:{},customfetch:{},floating:{},floatinganchor:{},floatingpersist:{},id:{},language:{},name:{},obfuscated:{},overlay:{},overlaycontent:{},plugins:{},sentinel:{},spamfilter:{},strings:{},verifyurl:{},workerurl:{}},["default"],["clarify","configure","getConfiguration","getFloatingAnchor","getPlugin","getState","hide","repositionFloating","reset","setFloatingAnchor","setState","show","verify"],!1));const Bo='@keyframes overlay-slidein{to{opacity:1;top:50%}}@keyframes altcha-spinner{to{transform:rotate(360deg)}}.altcha{background:var(--altcha-color-base, transparent);border:var(--altcha-border-width, 1px) solid var(--altcha-color-border, #a0a0a0);border-radius:var(--altcha-border-radius, 3px);color:var(--altcha-color-text, currentColor);display:flex;flex-direction:column;max-width:var(--altcha-max-width, 260px);position:relative}.altcha:focus-within{border-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating]{background:var(--altcha-color-base, white);display:none;filter:drop-shadow(3px 3px 6px rgba(0,0,0,.2));left:-100%;position:fixed;top:-100%;width:var(--altcha-max-width, 260px);z-index:999999}.altcha[data-floating=top] .altcha-anchor-arrow{border-bottom-color:transparent;border-top-color:var(--altcha-color-border, #a0a0a0);bottom:-12px;top:auto}.altcha[data-floating=bottom]:focus-within::after{border-bottom-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating=top]:focus-within::after{border-top-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating]:not([data-state=unverified]){display:block}.altcha-anchor-arrow{border:6px solid transparent;border-bottom-color:var(--altcha-color-border, #a0a0a0);content:"";height:0;left:12px;position:absolute;top:-12px;width:0}.altcha-main{align-items:center;display:flex;gap:.4rem;padding:.7rem;position:relative}.altcha-code-challenge{background:var(--altcha-color-base, white);border:1px solid var(--altcha-color-border-focus, currentColor);border-radius:var(--altcha-border-radius, 3px);filter:drop-shadow(3px 3px 6px rgba(0,0,0,.2));padding:.5rem;position:absolute;top:2.5rem;z-index:9999999}.altcha-code-challenge>form{display:flex;flex-direction:column;gap:.5rem}.altcha-code-challenge-input{border:1px solid currentColor;border-radius:3px;box-sizing:border-box;outline:0;font-size:16px;padding:.35rem;width:220px}.altcha-code-challenge-input:focus{outline:2px solid color-mix(in srgb,var(--altcha-color-active, #1D1DC9) 20%,transparent)}.altcha-code-challenge-input:disabled{opacity:.7}.altcha-code-challenge-image{background-color:#fff;border:1px solid currentColor;border-radius:3px;box-sizing:border-box;object-fit:contain;height:50px;width:220px}.altcha-code-challenge-audio,.altcha-code-challenge-reload{background:color-mix(in srgb,var(--altcha-color-text, currentColor) 10%,transparent);border:0;border-radius:3px;color:var(--altcha-color-text, currentColor);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:.35rem}.altcha-code-challenge-audio:disabled,.altcha-code-challenge-reload:disabled,.altcha-code-challenge-verify:disabled{opacity:.7;pointer-events:none}.altcha-code-challenge-audio>*,.altcha-code-challenge-reload>*{height:20px;width:20px}.altcha-code-challenge-buttons{display:flex;justify-content:space-between}.altcha-code-challenge-buttons-left{display:flex;gap:.25rem}.altcha-code-challenge-verify{align-items:center;background:var(--altcha-color-active, #1D1DC9);border:0;border-radius:3px;color:#fff;cursor:pointer;display:flex;gap:.5rem;font-size:100%;padding:.35rem 1rem}.altcha-code-challenge-arrow{border:6px solid transparent;border-bottom-color:var(--altcha-color-border, currentColor);content:"";height:0;left:.15rem;position:absolute;top:-12px;width:0}.altcha[data-floating=top] .altcha-code-challenge{top:-150px}.altcha[data-floating=top] .altcha-code-challenge-arrow{border-bottom-color:transparent;border-top-color:var(--altcha-color-border, currentColor);bottom:-12px;top:auto}.altcha-label{cursor:pointer;flex-grow:1}.altcha-logo{color:currentColor!important;opacity:.7}.altcha-footer:hover,.altcha-logo:hover{opacity:1}.altcha-error{color:var(--altcha-color-error-text, #f23939);display:flex;font-size:.85rem;gap:.3rem;padding:0 .7rem .7rem}.altcha-footer{align-items:center;background-color:var(--altcha-color-footer-bg, transparent);display:flex;font-size:.75rem;opacity:.7;justify-content:end;padding:.2rem .7rem}.altcha-footer a{color:currentColor}.altcha-checkbox{display:flex;align-items:center;justify-content:center;height:24px;position:relative;width:24px}.altcha-checkbox .altcha-spinner{bottom:0;left:0;position:absolute;right:0;top:0}.altcha-checkbox input{width:18px;height:18px;margin:0}.altcha-checkbox-verifying input{appearance:none;opacity:0;pointer-events:none}.altcha-spinner{animation:altcha-spinner .75s infinite linear;transform-origin:center}.altcha-overlay{--altcha-color-base:#fff;--altcha-color-text:#000;animation:overlay-slidein .5s forwards;display:flex;flex-direction:column;gap:.5rem;left:50%;width:260px;opacity:0;position:fixed;top:45%;transform:translate(-50%,-50%)}.altcha-overlay-backdrop{background:rgba(0,0,0,.5);bottom:0;display:none;left:0;position:fixed;right:0;top:0;z-index:99999999}.altcha-overlay-close-button{align-self:flex-end;background:0 0;border:0;padding:.25rem;cursor:pointer;color:currentColor;font-size:130%;line-height:1;opacity:.7}@media (max-height:450px){.altcha-overlay{top:10%!important;transform:translate(-50%,0)}}';function Ho(e,t="__altcha-css"){if(!document.getElementById(t)){const n=document.createElement("style");n.id=t,n.textContent=e,document.head.appendChild(n)}}globalThis.altchaCreateWorker=e=>e?new Worker(new URL(e)):new Ni,Ho(Bo),Ho(Bo);export{kl as Altcha}; +//# sourceMappingURL=/sm/2e0fd2382ba0f4c2525d5a6a0d13423ff9e02efb653e53f4d8cfa83af626b1ea.map \ No newline at end of file diff --git a/Controller/ChallengeController.php b/Controller/ChallengeController.php index b0962ba..aaff36e 100644 --- a/Controller/ChallengeController.php +++ b/Controller/ChallengeController.php @@ -50,6 +50,17 @@ public function __construct(private readonly AltchaClient $altchaClient) { } public function __invoke(Request $request): JsonResponse { + // Handle CORS preflight - Mautic forms are routinely embedded on + // third-party domains, so the widget's fetch() must be allowed cross-origin. + if($request->getMethod() === "OPTIONS") { + $response = new JsonResponse(null, 204); + $response->headers->set("Access-Control-Allow-Origin", "*"); + $response->headers->set("Access-Control-Allow-Methods", "GET, OPTIONS"); + $response->headers->set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Altcha-Spam-Filter, Cache-Control"); + $response->headers->set("Access-Control-Max-Age", "86400"); + return $response; + } + $complexity = (string) $request->query->get("complexity", "medium"); $expireSeconds = (int) $request->query->get("expire", (string) AltchaClient::DEFAULT_EXPIRE_SECONDS); $expireSeconds = max(self::MIN_EXPIRE_SECONDS, min(self::MAX_EXPIRE_SECONDS, $expireSeconds)); @@ -67,10 +78,9 @@ public function __invoke(Request $request): JsonResponse { $response->headers->set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); $response->headers->set("Pragma", "no-cache"); - // Mautic forms are routinely embedded on third-party domains - the - // widget's fetch() call must be allowed cross-origin. The response - // contains no sensitive or user-specific data. $response->headers->set("Access-Control-Allow-Origin", "*"); + $response->headers->set("Access-Control-Allow-Methods", "GET, OPTIONS"); + $response->headers->set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Altcha-Spam-Filter, Cache-Control"); return $response; } diff --git a/Resources/views/Integration/altcha.html.twig b/Resources/views/Integration/altcha.html.twig index 0961dfa..e8f6038 100644 --- a/Resources/views/Integration/altcha.html.twig +++ b/Resources/views/Integration/altcha.html.twig @@ -146,7 +146,14 @@ Variables this URL live each time it loads. #} {% set challengeUrl = altcha_challenge(complexity, expireSeconds) %} - +
@@ -155,8 +162,9 @@ Variables {% if challengeUrl is not null %} createMock(AltchaClient::class); + $client->expects($this->never())->method('createChallengeForComplexity'); + + $controller = new ChallengeController($client); + $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'OPTIONS']); + + $response = $controller->__invoke($request); + + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + $this->assertEquals('*', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertEquals('GET, OPTIONS', $response->headers->get('Access-Control-Allow-Methods')); + $this->assertNotEmpty($response->headers->get('Access-Control-Allow-Headers')); + } + + /** + * Test: GET with default params calls createChallengeForComplexity with 'medium' + default expire + * + * @test + */ + public function testApiUsesDefaultComplexityAndExpire(): void { + $client = $this->createMock(AltchaClient::class); + $client->expects($this->once()) + ->method('createChallengeForComplexity') + ->with('medium', AltchaClient::DEFAULT_EXPIRE_SECONDS) + ->willReturn([ + 'algorithm' => 'SHA-256', + 'challenge' => 'test', + 'salt' => 'test', + 'signature' => 'test', + 'maxNumber' => 100000, + ]); + + $controller = new ChallengeController($client); + $response = $controller->__invoke(new Request()); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + } + + /** + * Test: GET with explicit complexity and expire passes them through + * + * @test + */ + public function testApiPassesComplexityAndExpireFromQueryString(): void { + $client = $this->createMock(AltchaClient::class); + $client->expects($this->once()) + ->method('createChallengeForComplexity') + ->with('high', 300) + ->willReturn([ + 'algorithm' => 'SHA-256', + 'challenge' => 'test', + 'salt' => 'test', + 'signature' => 'test', + 'maxNumber' => 400000, + ]); + + $controller = new ChallengeController($client); + $request = new Request(['complexity' => 'high', 'expire' => '300']); + + $response = $controller->__invoke($request); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + } + + /** + * Test: returns 503 when client is not configured (createChallengeForComplexity returns null) + * + * @test + */ + public function testApiReturnsErrorWhenNotConfigured(): void { + $client = $this->createMock(AltchaClient::class); + $client->method('createChallengeForComplexity')->willReturn(null); + + $controller = new ChallengeController($client); + $response = $controller->__invoke(new Request()); + + $this->assertEquals(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode()); + } + + /** + * Test: successful response includes CORS headers and no-store cache control + * + * @test + */ + public function testApiIncludesCorsAndCacheHeaders(): void { + $client = $this->createMock(AltchaClient::class); + $client->method('createChallengeForComplexity')->willReturn([ + 'algorithm' => 'SHA-256', + 'challenge' => 'test', + 'salt' => 'test', + 'signature' => 'test', + 'maxNumber' => 100000, + ]); + + $controller = new ChallengeController($client); + $response = $controller->__invoke(new Request()); + + $this->assertEquals('*', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); + } + + /** + * Property test: expire is clamped between 30 and 3600 regardless of query param + * + * @test + */ + public function testExpireIsClampedToSafeBounds(): void { + $cases = [ + ['input' => '0', 'expected_min' => 30, 'expected_max' => 30], + ['input' => '29', 'expected_min' => 30, 'expected_max' => 30], + ['input' => '600', 'expected_min' => 600, 'expected_max' => 600], + ['input' => '3600', 'expected_min' => 3600, 'expected_max' => 3600], + ['input' => '9999', 'expected_min' => 3600, 'expected_max' => 3600], + ]; + + foreach ($cases as $case) { + $capturedExpire = null; + + $client = $this->createMock(AltchaClient::class); + $client->method('createChallengeForComplexity') + ->willReturnCallback(function(string $complexity, int $expire) use (&$capturedExpire) { + $capturedExpire = $expire; + return ['algorithm' => 'SHA-256', 'challenge' => 'x', 'salt' => 'x', 'signature' => 'x', 'maxNumber' => 1]; + }); + + $controller = new ChallengeController($client); + $controller->__invoke(new Request(['expire' => $case['input']])); + + $this->assertGreaterThanOrEqual($case['expected_min'], $capturedExpire); + $this->assertLessThanOrEqual($case['expected_max'], $capturedExpire); + } + } + +} diff --git a/Tests/EventListener/AltchaFormSubscriberTest.php b/Tests/EventListener/AltchaFormSubscriberTest.php new file mode 100644 index 0000000..583f9c0 --- /dev/null +++ b/Tests/EventListener/AltchaFormSubscriberTest.php @@ -0,0 +1,210 @@ +createMock(EventDispatcherInterface::class); + $eventDispatcher->method('addListener') + ->willReturnCallback(function($eventName, $listener, $priority = 0) use (&$registeredListeners) { + $registeredListeners[] = ['event' => $eventName, 'listener' => $listener, 'priority' => $priority]; + }); + + $altchaClient = $this->createMock(AltchaClient::class); + $altchaClient->method('isConfigured')->willReturn(true); + $altchaClient->method('verify')->willReturn(false); + + $leadDeleteCalled = false; + $leadModel = $this->createMock(LeadModel::class); + $leadModel->method('deleteEntity')->willReturnCallback(function() use (&$leadDeleteCalled) { + $leadDeleteCalled = true; + }); + + $subscriber = $this->createSubscriber($eventDispatcher, $altchaClient, $leadModel); + + $validationEvent = new ValidationEvent(new Field(), 'invalid-payload'); + + $subscriber->onFormValidate($validationEvent); + + if ($validationEvent->isValid()) { + $failures[] = ['iteration' => $i, 'reason' => 'Validation did not fail']; + continue; + } + + // LEAD_POST_SAVE listener must have been registered + $leadPostSaveListener = null; + foreach ($registeredListeners as $l) { + if ($l['event'] === LeadEvents::LEAD_POST_SAVE) { + $leadPostSaveListener = $l; + break; + } + } + + if ($leadPostSaveListener === null) { + $failures[] = ['iteration' => $i, 'reason' => 'LEAD_POST_SAVE listener not registered']; + continue; + } + + if ($leadPostSaveListener['priority'] !== -255) { + $failures[] = ['iteration' => $i, 'reason' => 'Wrong LEAD_POST_SAVE priority', 'actual' => $leadPostSaveListener['priority']]; + continue; + } + + // Simulate LEAD_POST_SAVE with a new lead + $registeredListeners = []; + $lead = new Lead(); + $lead->setId(123); + ($leadPostSaveListener['listener'])(new LeadEvent($lead, true)); + + // kernel.terminate must be registered next + $kernelTerminateListener = null; + foreach ($registeredListeners as $l) { + if ($l['event'] === 'kernel.terminate') { + $kernelTerminateListener = $l; + break; + } + } + + if ($kernelTerminateListener === null) { + $failures[] = ['iteration' => $i, 'reason' => 'kernel.terminate listener not registered']; + continue; + } + + // Simulate kernel.terminate — lead must be deleted + ($kernelTerminateListener['listener'])(); + + if (!$leadDeleteCalled) { + $failures[] = ['iteration' => $i, 'reason' => 'Lead not deleted after kernel.terminate']; + } + } + + $this->assertEmpty($failures, sprintf( + "Lead cleanup after failed validation failed in %d/%d iterations:\n%s", + count($failures), $iterations, json_encode($failures, JSON_PRETTY_PRINT) + )); + } + + /** + * Test: Existing leads are NOT deleted after failed validation + * + * @test + */ + public function testLeadCleanupSkipsExistingLeads(): void { + $registeredListeners = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('addListener') + ->willReturnCallback(function($eventName, $listener, $priority = 0) use (&$registeredListeners) { + $registeredListeners[] = ['event' => $eventName, 'listener' => $listener, 'priority' => $priority]; + }); + + $altchaClient = $this->createMock(AltchaClient::class); + $altchaClient->method('isConfigured')->willReturn(true); + $altchaClient->method('verify')->willReturn(false); + + $leadDeleteCalled = false; + $leadModel = $this->createMock(LeadModel::class); + $leadModel->method('deleteEntity')->willReturnCallback(function() use (&$leadDeleteCalled) { + $leadDeleteCalled = true; + }); + + $subscriber = $this->createSubscriber($eventDispatcher, $altchaClient, $leadModel); + + $validationEvent = new ValidationEvent(new Field(), 'invalid-payload'); + $subscriber->onFormValidate($validationEvent); + + // Find LEAD_POST_SAVE listener + $leadPostSaveListener = null; + foreach ($registeredListeners as $l) { + if ($l['event'] === LeadEvents::LEAD_POST_SAVE) { + $leadPostSaveListener = $l; + break; + } + } + + $this->assertNotNull($leadPostSaveListener); + + // Simulate with existing lead (isNew = false) + $registeredListeners = []; + $lead = new Lead(); + $lead->setId(456); + ($leadPostSaveListener['listener'])(new LeadEvent($lead, false)); + + $hasKernelTerminate = !empty(array_filter($registeredListeners, fn($l) => $l['event'] === 'kernel.terminate')); + + $this->assertFalse($hasKernelTerminate, 'kernel.terminate should not be registered for existing leads'); + $this->assertFalse($leadDeleteCalled, 'Existing lead must not be deleted'); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function createSubscriber( + EventDispatcherInterface $eventDispatcher, + AltchaClient $altchaClient, + LeadModel $leadModel + ): AltchaFormSubscriber { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturn('ALTCHA verification failed.'); + + $integration = $this->createMock(AbstractIntegration::class); + $integration->method('getKeys')->willReturn(['hmac_secret' => 'test-key']); + $integration->method('getTranslator')->willReturn($translator); + + $integrationHelper = $this->createMock(IntegrationHelper::class); + $integrationHelper->method('getIntegrationObject') + ->with(AltchaIntegration::INTEGRATION_NAME) + ->willReturn($integration); + + $request = $this->createMock(Request::class); + $request->request = new \Symfony\Component\HttpFoundation\InputBag(['altcha' => 'invalid-payload']); + + $requestStack = $this->createMock(RequestStack::class); + $requestStack->method('getCurrentRequest')->willReturn($request); + + return new AltchaFormSubscriber( + $eventDispatcher, + $altchaClient, + $leadModel, + $requestStack, + $integrationHelper + ); + } + +} diff --git a/Tests/Form/Type/AltchaTypeTest.php b/Tests/Form/Type/AltchaTypeTest.php new file mode 100644 index 0000000..9efd286 --- /dev/null +++ b/Tests/Form/Type/AltchaTypeTest.php @@ -0,0 +1,96 @@ +formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new \Symfony\Component\Form\Extension\Validator\ValidatorExtension( + Validation::createValidator() + )) + ->getFormFactory(); + } + + /** + * Test: complexity field accepts all valid values + * + * @test + */ + public function testComplexityFieldAcceptsValidValues(): void { + foreach (['low', 'medium', 'high'] as $complexity) { + $form = $this->formFactory->create(AltchaType::class, ['complexity' => $complexity]); + $form->submit(['complexity' => $complexity, 'expire' => 600, 'auto' => 'onsubmit', 'display' => 'standard', 'hideFooter' => false, 'hideLogo' => false]); + + $this->assertTrue( + $form->isValid(), + "Form should be valid for complexity: {$complexity}" + ); + } + } + + /** + * Test: display field accepts all valid values including invisible + * + * @test + */ + public function testDisplayFieldAcceptsValidValues(): void { + foreach (['standard', 'bar', 'floating', 'overlay', 'invisible'] as $display) { + $form = $this->formFactory->create(AltchaType::class, ['display' => $display]); + $form->submit(['complexity' => 'medium', 'expire' => 600, 'auto' => 'onsubmit', 'display' => $display, 'hideFooter' => false, 'hideLogo' => false]); + + $this->assertTrue( + $form->isValid(), + "Form should be valid for display mode: {$display}" + ); + } + } + + /** + * Test: auto field accepts all valid values + * + * @test + */ + public function testAutoFieldAcceptsValidValues(): void { + foreach (['onload', 'onsubmit', 'off'] as $auto) { + $form = $this->formFactory->create(AltchaType::class, ['auto' => $auto]); + $form->submit(['complexity' => 'medium', 'expire' => 600, 'auto' => $auto, 'display' => 'standard', 'hideFooter' => false, 'hideLogo' => false]); + + $this->assertTrue( + $form->isValid(), + "Form should be valid for auto mode: {$auto}" + ); + } + } + + /** + * Test: expire field accepts integer values + * + * @test + */ + public function testExpireFieldAcceptsIntegerValues(): void { + foreach ([30, 120, 600, 3600] as $expire) { + $form = $this->formFactory->create(AltchaType::class, ['expire' => $expire]); + $form->submit(['complexity' => 'medium', 'expire' => $expire, 'auto' => 'onsubmit', 'display' => 'standard', 'hideFooter' => false, 'hideLogo' => false]); + + $this->assertTrue( + $form->isValid(), + "Form should be valid for expire: {$expire}" + ); + } + } + +} diff --git a/Tests/Integration/AltchaIntegrationTest.php b/Tests/Integration/AltchaIntegrationTest.php new file mode 100644 index 0000000..a614b08 --- /dev/null +++ b/Tests/Integration/AltchaIntegrationTest.php @@ -0,0 +1,151 @@ +makeIntegration(); + $this->assertEquals('Altcha', $integration->getName()); + } + + /** + * Test: getDisplayName returns the human-readable name + * + * @test + */ + public function testGetDisplayName(): void { + $integration = $this->makeIntegration(); + $this->assertEquals('ALTCHA', $integration->getDisplayName()); + } + + /** + * Test: getAuthenticationType is "none" (self-hosted, no third-party OAuth) + * + * @test + */ + public function testGetAuthenticationType(): void { + $integration = $this->makeIntegration(); + $this->assertEquals('none', $integration->getAuthenticationType()); + } + + /** + * Test: getRequiredKeyFields intentionally returns [] so that self-hosted and + * Sentinel fields can be filled independently without Mautic forcing all of them. + * + * @test + */ + public function testGetRequiredKeyFields(): void { + $integration = $this->makeIntegration(); + $this->assertSame([], $integration->getRequiredKeyFields()); + } + + /** + * Test: getSecretKeys includes hmac_secret and sentinel_api_secret so Mautic + * masks them in the UI and encrypts them at rest. + * + * @test + */ + public function testGetSecretKeys(): void { + $integration = $this->makeIntegration(); + $secretKeys = $integration->getSecretKeys(); + + $this->assertContains('hmac_secret', $secretKeys); + $this->assertContains('sentinel_api_secret', $secretKeys); + } + + /** + * Property Test: isConfigured returns true for any non-empty hmac_secret + * + * Generator: random alphanumeric strings (20-64 chars) + * Iterations: 100 + * + * @test + */ + public function testHmacKeyPersistence(): void { + $iterations = 100; + $failures = []; + + for ($i = 0; $i < $iterations; $i++) { + $secret = $this->randomAlphanumeric(rand(20, 64)); + + $integration = $this->makeIntegrationWithKeys(['hmac_secret' => $secret]); + + if (!$integration->isConfigured()) { + $failures[] = ['iteration' => $i, 'secret_length' => strlen($secret)]; + } + } + + $this->assertEmpty($failures, sprintf( + "isConfigured() returned false for a valid hmac_secret in %d/%d iterations:\n%s", + count($failures), $iterations, json_encode($failures, JSON_PRETTY_PRINT) + )); + } + + /** + * Test: isConfigured returns false when no credentials are set + * + * @test + */ + public function testIsNotConfiguredWithEmptyKeys(): void { + $integration = $this->makeIntegrationWithKeys([]); + $this->assertFalse($integration->isConfigured()); + } + + /** + * Test: isConfigured returns true when all three Sentinel fields are present + * + * @test + */ + public function testIsConfiguredWithSentinelCredentials(): void { + $integration = $this->makeIntegrationWithKeys([ + 'sentinel_domain' => 'https://sentinel.example.com', + 'sentinel_api_key' => 'key_abc', + 'sentinel_api_secret' => 'secret_xyz', + ]); + + $this->assertTrue($integration->isConfigured()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Instantiate without parent constructor dependencies. */ + private function makeIntegration(): AltchaIntegration { + return new class extends AltchaIntegration { + public function __construct() {} + }; + } + + /** Instantiate and stub getKeys() to return the given array. */ + private function makeIntegrationWithKeys(array $keys): AltchaIntegration { + $integration = $this->getMockBuilder(AltchaIntegration::class) + ->disableOriginalConstructor() + ->onlyMethods(['getKeys']) + ->getMock(); + $integration->method('getKeys')->willReturn($keys); + return $integration; + } + + private function randomAlphanumeric(int $length): string { + $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $result = ''; + for ($i = 0; $i < $length; $i++) { + $result .= $chars[rand(0, strlen($chars) - 1)]; + } + return $result; + } + +} diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..4ab1fb0 --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,53 @@ +# MauticMultiCaptchaBundle Tests + +## Overview + +This directory contains property-based and unit tests for the ALTCHA integration. + +## Running Tests + +Since this is a Mautic plugin, tests should be run within a Mautic installation context: + +### Option 1: Within Mautic Installation + +```bash +# From your Mautic root directory +php bin/phpunit plugins/MauticMultiCaptchaBundle/Tests +``` + +### Option 2: Standalone (requires dependencies) + +```bash +# Install dependencies first +composer install + +# Run tests +vendor/bin/phpunit +``` + +## Test Structure + +### Property-Based Tests + +Property-based tests verify universal properties across many random inputs (100 iterations minimum). + +- **AltchaIntegrationTest::testHmacKeyPersistence**: Verifies that the integration correctly defines the hmac_key field for persistence + +### Unit Tests + +Unit tests verify specific behaviors: + +- **testGetName**: Verifies integration name is "ALTCHA" +- **testGetDisplayName**: Verifies display name is "ALTCHA" +- **testGetAuthenticationType**: Verifies authentication type is "none" +- **testGetRequiredKeyFields**: Verifies hmac_key field is defined + +## Requirements + +- PHP 8.1 or higher +- PHPUnit 9.5 or higher +- Mautic 5.x, 6.x, or 7.x + +## Notes + +Tests are designed to run without external dependencies where possible, using mocks to simulate Mautic's integration system. diff --git a/Tests/Resources/AltchaTemplateTest.php b/Tests/Resources/AltchaTemplateTest.php new file mode 100644 index 0000000..4eddbfe --- /dev/null +++ b/Tests/Resources/AltchaTemplateTest.php @@ -0,0 +1,265 @@ +fail("Template file not found: {$templatePath}"); + } + + $templateContent = file_get_contents($templatePath); + + for ($i = 0; $i < $iterations; $i++) { + // Generate random widget configuration + $config = $this->generateRandomWidgetConfig(); + + // Extract all script sources from template + $scriptSources = $this->extractScriptSources($templateContent); + + // Verify each script source + foreach ($scriptSources as $source) { + $domain = $this->extractDomain($source); + + // Check if domain is approved + $isApproved = false; + foreach ($approvedDomains as $approvedDomain) { + if (empty($approvedDomain)) { + // Relative path (local) + if (!preg_match('/^https?:\/\//', $source)) { + $isApproved = true; + break; + } + } elseif (strpos($domain, $approvedDomain) !== false) { + $isApproved = true; + break; + } + } + + // Check if domain is blocked + $isBlocked = false; + foreach ($blockedDomains as $blockedDomain) { + if (strpos($domain, $blockedDomain) !== false) { + $isBlocked = true; + break; + } + } + + if (!$isApproved || $isBlocked) { + $failures[] = [ + 'iteration' => $i, + 'config' => $config, + 'source' => $source, + 'domain' => $domain, + 'approved' => $isApproved, + 'blocked' => $isBlocked, + ]; + } + } + } + + // Assert no failures occurred + $this->assertEmpty( + $failures, + sprintf( + "Local resource loading validation failed in %d/%d iterations:\n%s", + count($failures), + $iterations, + json_encode($failures, JSON_PRETTY_PRINT) + ) + ); + } + + /** + * Generate random widget configuration + */ + private function generateRandomWidgetConfig(): array { + return [ + 'maxNumber' => rand(1000, 1000000), + 'expires' => rand(10, 300), + 'invisible' => (bool) rand(0, 1), + 'showLabel' => (bool) rand(0, 1), + ]; + } + + /** + * Extract script sources from template content + */ + private function extractScriptSources(string $content): array { + $sources = []; + + // Match + + + + {% if autoSolve %} + {# Cap's own anti-tamper logic actively restores its branding link + whenever the widget host is visible, so hiding just the link isn't + possible - only hiding the entire widget (auto_hidden) is honored. #} + + {% endif %} + + + {% endif %} +
+{% endblock %} diff --git a/Service/CapClient.php b/Service/CapClient.php new file mode 100644 index 0000000..088fa27 --- /dev/null +++ b/Service/CapClient.php @@ -0,0 +1,79 @@ +Class CapClient + * + * @package MauticPlugin\MauticMultiCaptchaBundle\Service + * + * @authors see: composer.json + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html + */ +class CapClient { + + private ?string $serverUrl; + private ?string $secretKey; + + private GuzzleClientInterface $httpClient; + + /** + *

CapClient constructor.

+ * + * @param IntegrationHelper $integrationHelper + * @param GuzzleClientInterface|null $httpClient Injectable for testing; a real Guzzle client is used when omitted. + */ + public function __construct(IntegrationHelper $integrationHelper, ?GuzzleClientInterface $httpClient = null) { + $integrationObject = $integrationHelper->getIntegrationObject(CapIntegration::INTEGRATION_NAME); + + if($integrationObject instanceof AbstractIntegration) { + $keys = $integrationObject->getKeys(); + + $this->serverUrl = isset($keys["server_url"]) ? rtrim($keys["server_url"], "/") : null; + $this->secretKey = $keys["secret_key"] ?? null; + } + + $this->httpClient = $httpClient ?? new GuzzleClient([ + "timeout" => 10 + ]); + } + + /** + *

verify

+ * + * Verifies a redeemed Cap token against the self-hosted Cap Standalone + * instance's /siteverify endpoint. The site key does not need to be sent + * separately, it is embedded in the redeemed token itself. + * + * @param string $token + * + * @throws GuzzleException + * @throws JsonException + * + * @return bool + */ + public function verify(string $token): bool { + $guzzleResponse = $this->httpClient->post("{$this->serverUrl}/siteverify", [ + "json" => [ + "secret" => $this->secretKey, + "response" => $token + ] + ]); + + $response = json_decode($guzzleResponse->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + + return array_key_exists("success", $response) && $response["success"] === true; + } + +} diff --git a/Tests/Controller/CapAssetControllerTest.php b/Tests/Controller/CapAssetControllerTest.php new file mode 100644 index 0000000..1922d7b --- /dev/null +++ b/Tests/Controller/CapAssetControllerTest.php @@ -0,0 +1,61 @@ + tag, but it fetches its WASM PoW + * solver via fetch(), which enforces CORS. This controller serves that + * binary with an explicit Access-Control-Allow-Origin header instead of + * relying on the plugin's raw static asset path. + */ +class CapAssetControllerTest extends TestCase { + + /** + * @test + */ + public function testWasmActionReturnsBinaryWithCorsHeader(): void { + $controller = new CapAssetController(); + $response = $controller->wasmAction(new Request()); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertEquals('*', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertEquals('application/wasm', $response->headers->get('Content-Type')); + } + + /** + * @test + */ + public function testWasmActionServesTheActualBundledFile(): void { + $controller = new CapAssetController(); + $response = $controller->wasmAction(new Request()); + + $expectedPath = realpath(__DIR__ . '/../../Assets/js/cap_wasm_bg.wasm'); + + $this->assertEquals($expectedPath, realpath($response->getFile()->getPathname())); + } + + /** + * @test + */ + public function testWasmActionHandlesCorsPreflightRequest(): void { + $controller = new CapAssetController(); + $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'OPTIONS']); + + $response = $controller->wasmAction($request); + + $this->assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); + $this->assertEquals('*', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertEquals('GET, OPTIONS', $response->headers->get('Access-Control-Allow-Methods')); + } + +} diff --git a/Tests/EventListener/CapFormSubscriberTest.php b/Tests/EventListener/CapFormSubscriberTest.php new file mode 100644 index 0000000..27e34ae --- /dev/null +++ b/Tests/EventListener/CapFormSubscriberTest.php @@ -0,0 +1,321 @@ +assertArrayHasKey(FormEvents::FORM_ON_BUILD, $events); + $this->assertArrayHasKey(CaptchaEvents::CAP_ON_FORM_VALIDATE, $events); + } + + /** + * @test + */ + public function testOnFormBuildAddsFieldWhenFullyConfigured(): void { + $subscriber = $this->createSubscriber( + $this->createMock(CapClient::class), + $this->createMock(LeadModel::class), + [ + 'server_url' => 'https://cap.example.com', + 'site_key' => 'site-key-123', + 'secret_key' => 'secret-abc' + ] + ); + + $event = new FormBuilderEvent($this->createMock(SymfonyTranslatorInterface::class)); + $subscriber->onFormBuild($event); + + $fields = $event->getFormFields(); + $this->assertArrayHasKey('plugin.cap', $fields); + $this->assertEquals('https://cap.example.com', $fields['plugin.cap']['server_url']); + $this->assertEquals('site-key-123', $fields['plugin.cap']['site_key']); + + // getValidators() organises by fieldType, not by key + $validators = $event->getValidators(); + $this->assertArrayHasKey('plugin.cap', $validators); + $this->assertEquals(CaptchaEvents::CAP_ON_FORM_VALIDATE, $validators['plugin.cap']); + } + + /** + * Property Test: onFormBuild is a no-op when any required key is missing + * + * @test + */ + public function testOnFormBuildSkipsFieldWhenNotFullyConfigured(): void { + $incompleteConfigs = [ + [], + ['server_url' => 'https://cap.example.com'], + ['server_url' => 'https://cap.example.com', 'site_key' => 'site-key-123'], + ['site_key' => 'site-key-123', 'secret_key' => 'secret-abc'], + ]; + + foreach ($incompleteConfigs as $keys) { + $subscriber = $this->createSubscriber( + $this->createMock(CapClient::class), + $this->createMock(LeadModel::class), + $keys + ); + + $event = new FormBuilderEvent($this->createMock(SymfonyTranslatorInterface::class)); + $subscriber->onFormBuild($event); + + $this->assertEmpty($event->getFormFields()); + // getValidators() always returns at least ['form' => []] - confirm no fieldType validators added + $validators = $event->getValidators(); + $this->assertArrayNotHasKey('plugin.cap', $validators); + } + } + + /** + * @test + */ + public function testOnFormValidatePassesWhenTokenVerifies(): void { + $_POST['cap-token'] = 'sitekey:id:secret'; + + $capClient = $this->createMock(CapClient::class); + $capClient->expects($this->once()) + ->method('verify') + ->with('sitekey:id:secret') + ->willReturn(true); + + $subscriber = $this->createSubscriber($capClient, $this->createMock(LeadModel::class)); + + $event = new ValidationEvent(new Field(), 'sitekey:id:secret'); + $subscriber->onFormValidate($event); + + $this->assertTrue($event->isValid()); + } + + /** + * @test + */ + public function testOnFormValidateTreatsMissingTokenAsEmptyString(): void { + unset($_POST['cap-token']); + + $capClient = $this->createMock(CapClient::class); + $capClient->expects($this->once()) + ->method('verify') + ->with('') + ->willReturn(false); + + $subscriber = $this->createSubscriber($capClient, $this->createMock(LeadModel::class)); + + $event = new ValidationEvent(new Field(), ''); + $subscriber->onFormValidate($event); + + $this->assertFalse($event->isValid()); + } + + /** + * @test + */ + public function testOnFormValidateIsNoOpWhenNotConfigured(): void { + $capClient = $this->createMock(CapClient::class); + $capClient->expects($this->never())->method('verify'); + + $subscriber = $this->createSubscriber($capClient, $this->createMock(LeadModel::class), []); + + $event = new ValidationEvent(new Field(), ''); + $subscriber->onFormValidate($event); + + $this->assertTrue($event->isValid()); + } + + /** + * Property Test: Lead cleanup after failed Cap validation + * + * @test + */ + public function testLeadCleanupAfterFailedValidation(): void { + $_POST['cap-token'] = 'sitekey:id:bad-secret'; + + $registeredListeners = []; + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('addListener') + ->willReturnCallback(function ($eventName, $listener, $priority = 0) use (&$registeredListeners) { + $registeredListeners[] = ['event' => $eventName, 'listener' => $listener, 'priority' => $priority]; + }); + + $capClient = $this->createMock(CapClient::class); + $capClient->method('verify')->willReturn(false); + + $leadModel = $this->createMock(LeadModel::class); + $leadDeleteCalled = false; + $leadModel->method('getEntity')->willReturn($this->createMock(Lead::class)); + $leadModel->method('deleteEntity') + ->willReturnCallback(function () use (&$leadDeleteCalled) { + $leadDeleteCalled = true; + }); + + $subscriber = $this->createSubscriber($capClient, $leadModel, [ + 'server_url' => 'https://cap.example.com', + 'site_key' => 'site-key-123', + 'secret_key' => 'secret-abc' + ], $eventDispatcher); + + $validationEvent = new ValidationEvent(new Field(), 'sitekey:id:bad-secret'); + $subscriber->onFormValidate($validationEvent); + + $this->assertFalse($validationEvent->isValid()); + + $leadPostSaveListener = null; + foreach ($registeredListeners as $listener) { + if ($listener['event'] === LeadEvents::LEAD_POST_SAVE) { + $leadPostSaveListener = $listener; + break; + } + } + + $this->assertNotNull($leadPostSaveListener, 'LEAD_POST_SAVE listener should be registered'); + $this->assertEquals(-255, $leadPostSaveListener['priority']); + + $lead = new Lead(); + $lead->setId(123); + $leadEvent = new LeadEvent($lead, true); + + $registeredListeners = []; + ($leadPostSaveListener['listener'])($leadEvent); + + $kernelTerminateListener = null; + foreach ($registeredListeners as $listener) { + if ($listener['event'] === 'kernel.terminate') { + $kernelTerminateListener = $listener; + break; + } + } + + $this->assertNotNull($kernelTerminateListener, 'kernel.terminate listener should be registered'); + + ($kernelTerminateListener['listener'])(); + + $this->assertTrue($leadDeleteCalled, 'Lead should be deleted after kernel.terminate'); + } + + /** + * @test + */ + public function testLeadCleanupSkipsExistingLeads(): void { + $_POST['cap-token'] = 'sitekey:id:bad-secret'; + + $registeredListeners = []; + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('addListener') + ->willReturnCallback(function ($eventName, $listener, $priority = 0) use (&$registeredListeners) { + $registeredListeners[] = ['event' => $eventName, 'listener' => $listener, 'priority' => $priority]; + }); + + $capClient = $this->createMock(CapClient::class); + $capClient->method('verify')->willReturn(false); + + $leadModel = $this->createMock(LeadModel::class); + $leadDeleteCalled = false; + $leadModel->method('deleteEntity') + ->willReturnCallback(function () use (&$leadDeleteCalled) { + $leadDeleteCalled = true; + }); + + $subscriber = $this->createSubscriber($capClient, $leadModel, [ + 'server_url' => 'https://cap.example.com', + 'site_key' => 'site-key-123', + 'secret_key' => 'secret-abc' + ], $eventDispatcher); + + $validationEvent = new ValidationEvent(new Field(), 'sitekey:id:bad-secret'); + $subscriber->onFormValidate($validationEvent); + + $leadPostSaveListener = null; + foreach ($registeredListeners as $listener) { + if ($listener['event'] === LeadEvents::LEAD_POST_SAVE) { + $leadPostSaveListener = $listener; + break; + } + } + + $this->assertNotNull($leadPostSaveListener); + + $lead = new Lead(); + $lead->setId(456); + $leadEvent = new LeadEvent($lead, false); // not new + + $registeredListeners = []; + ($leadPostSaveListener['listener'])($leadEvent); + + $hasKernelTerminate = false; + foreach ($registeredListeners as $listener) { + if ($listener['event'] === 'kernel.terminate') { + $hasKernelTerminate = true; + } + } + + $this->assertFalse($hasKernelTerminate); + $this->assertFalse($leadDeleteCalled); + } + + /** + * Helper: Create a CapFormSubscriber with mocked dependencies. + */ + private function createSubscriber( + CapClient $capClient, + LeadModel $leadModel, + array $keys = ['server_url' => 'https://cap.example.com', 'site_key' => 'site-key-123', 'secret_key' => 'secret-abc'], + ?EventDispatcherInterface $eventDispatcher = null + ): CapFormSubscriber { + $integrationHelper = $this->createMock(IntegrationHelper::class); + + $integration = $this->createMock(AbstractIntegration::class); + $integration->method('getKeys')->willReturn($keys); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturn('Cap CAPTCHA verification failed.'); + $integration->method('getTranslator')->willReturn($translator); + + $integrationHelper->method('getIntegrationObject') + ->with(CapIntegration::INTEGRATION_NAME) + ->willReturn($integration); + + return new CapFormSubscriber( + $eventDispatcher ?? $this->createMock(EventDispatcherInterface::class), + $capClient, + $leadModel, + $integrationHelper + ); + } + +} diff --git a/Tests/Form/Type/CapTypeTest.php b/Tests/Form/Type/CapTypeTest.php new file mode 100644 index 0000000..ffb7702 --- /dev/null +++ b/Tests/Form/Type/CapTypeTest.php @@ -0,0 +1,92 @@ +formFactory = Forms::createFormFactoryBuilder()->getFormFactory(); + } + + /** + * @test + */ + public function testFormHasOnlyModeField(): void { + $form = $this->formFactory->create(CapType::class); + + $this->assertCount(1, $form->all()); + $this->assertTrue($form->has('mode')); + } + + /** + * @test + */ + public function testModeDefaultsToManual(): void { + $form = $this->formFactory->create(CapType::class, []); + + $this->assertEquals(CapType::MODE_MANUAL, $form->get('mode')->getData()); + } + + /** + * Property Test: every documented mode value is a valid, submittable choice + * + * @test + */ + public function testEveryModeChoiceIsSubmittable(): void { + foreach ([CapType::MODE_MANUAL, CapType::MODE_AUTO, CapType::MODE_AUTO_HIDDEN] as $mode) { + $form = $this->formFactory->create(CapType::class); + + $form->submit(['mode' => $mode]); + + $this->assertTrue($form->isValid(), "mode={$mode} should be a valid submission"); + $this->assertEquals($mode, $form->get('mode')->getData()); + } + } + + /** + * @test + */ + public function testModeRespectsStoredValue(): void { + $form = $this->formFactory->create(CapType::class, [ + 'mode' => CapType::MODE_AUTO_HIDDEN + ]); + + $this->assertEquals(CapType::MODE_AUTO_HIDDEN, $form->get('mode')->getData()); + } + + /** + * @test + */ + public function testBlockPrefixMatchesIntegrationName(): void { + $type = new CapType(); + + $this->assertEquals(CapIntegration::INTEGRATION_NAME, $type->getBlockPrefix()); + } + + /** + * @test + */ + public function testFormRespectsCustomAction(): void { + $form = $this->formFactory->create(CapType::class, null, [ + 'action' => '/some/custom/action' + ]); + + $this->assertEquals('/some/custom/action', $form->getConfig()->getAction()); + } + +} diff --git a/Tests/Integration/CapIntegrationTest.php b/Tests/Integration/CapIntegrationTest.php new file mode 100644 index 0000000..26e1bf2 --- /dev/null +++ b/Tests/Integration/CapIntegrationTest.php @@ -0,0 +1,97 @@ +assertEquals('Cap', $this->createIntegration()->getName()); + } + + /** + * @test + */ + public function testGetDisplayName(): void { + $this->assertEquals('Cap CAPTCHA', $this->createIntegration()->getDisplayName()); + } + + /** + * @test + */ + public function testGetAuthenticationType(): void { + $this->assertEquals('none', $this->createIntegration()->getAuthenticationType()); + } + + /** + * @test + */ + public function testGetRequiredKeyFields(): void { + $fields = $this->createIntegration()->getRequiredKeyFields(); + + $this->assertIsArray($fields); + $this->assertArrayHasKey('server_url', $fields); + $this->assertArrayHasKey('site_key', $fields); + $this->assertArrayHasKey('secret_key', $fields); + + $this->assertEquals('strings.cap.settings.server_url', $fields['server_url']); + $this->assertEquals('strings.cap.settings.site_key', $fields['site_key']); + $this->assertEquals('strings.cap.settings.secret_key', $fields['secret_key']); + } + + /** + * Property Test: Required key field persistence + * + * @test + */ + public function testRequiredKeyFieldsPersistence(): void { + $integration = $this->createIntegration(); + $requiredFields = $integration->getRequiredKeyFields(); + $failures = []; + + for ($i = 0; $i < 100; $i++) { + $values = [ + 'server_url' => 'https://cap-' . bin2hex(random_bytes(4)) . '.example.com', + 'site_key' => bin2hex(random_bytes(8)), + 'secret_key' => bin2hex(random_bytes(16)) + ]; + + foreach (array_keys($requiredFields) as $fieldName) { + $storage = [$fieldName => $values[$fieldName]]; + $retrieved = $storage[$fieldName] ?? null; + + if ($retrieved !== $values[$fieldName]) { + $failures[] = [ + 'iteration' => $i, + 'field' => $fieldName, + 'expected' => $values[$fieldName], + 'actual' => $retrieved + ]; + } + } + } + + $this->assertEmpty($failures, sprintf( + "Required key field persistence failed in %d cases:\n%s", + count($failures), + json_encode($failures, JSON_PRETTY_PRINT) + )); + } + +} diff --git a/Tests/Resources/CapTemplateTest.php b/Tests/Resources/CapTemplateTest.php new file mode 100644 index 0000000..38e0096 --- /dev/null +++ b/Tests/Resources/CapTemplateTest.php @@ -0,0 +1,168 @@ +assertFileExists(self::TEMPLATE_PATH, 'Cap template file should exist'); + } + + /** + * @test + */ + public function testTemplateContainsWidget(): void { + $content = file_get_contents(self::TEMPLATE_PATH); + + $this->assertStringContainsString('cap-widget', $content); + $this->assertStringContainsString('data-cap-api-endpoint', $content); + } + + /** + * @test + */ + public function testTemplateContainsErrorContainer(): void { + $content = file_get_contents(self::TEMPLATE_PATH); + + $this->assertStringContainsString('mauticform-errormsg', $content); + } + + /** + * @test + */ + public function testTemplateLoadsWidgetScriptFromLocalAssets(): void { + $content = file_get_contents(self::TEMPLATE_PATH); + + $this->assertStringContainsString( + 'plugins/MauticMultiCaptchaBundle/Assets/js/cap.min.js', + $content + ); + } + + /** + * @test + */ + public function testTemplatePointsWasmSolverAtCorsEnabledRoute(): void { + $content = file_get_contents(self::TEMPLATE_PATH); + + $this->assertStringContainsString('CAP_CUSTOM_WASM_URL', $content); + + // Must go through the CORS-enabled controller route, not the raw + // static asset path - the widget fetches this via fetch(), which + // enforces CORS, unlike its own