diff --git a/classes/Guards/CapGuard.php b/classes/Guards/CapGuard.php new file mode 100644 index 0000000..d79f833 --- /dev/null +++ b/classes/Guards/CapGuard.php @@ -0,0 +1,53 @@ + static::secretKey(), + 'response' => SubmissionPage::valueFromBody('cap-token') + ]; + + $remote = Remote::post(DreamForm::option('guards.cap.endpoint') . 'siteverify', [ + 'data' => $data + ]); + + $result = $remote->json(); + + if ( + $result['success'] !== true + ) { + $this->cancel(t('dreamform.submission.error.captcha')); + } + } + + public static function hasSnippet(): bool + { + return true; + } + + public static function isAvailable(): bool + { + return static::endpoint() !== null + && static::secretKey() !== null; + } +} diff --git a/config/options.php b/config/options.php index 123b8b0..81319f7 100644 --- a/config/options.php +++ b/config/options.php @@ -65,6 +65,12 @@ 'injectScript' => true, 'customTheme' => null // Custom theme configuration (Pro/Enterprise only) ], + 'cap' => [ + 'endpoint' => null, + 'secretKey' => null, + 'injectScript' => true, + 'useAssetServer' => false + ], 'ratelimit' => [ 'limit' => 10, 'interval' => 3 diff --git a/docs/4_guards/2_cap.md b/docs/4_guards/2_cap.md new file mode 100644 index 0000000..ec439a2 --- /dev/null +++ b/docs/4_guards/2_cap.md @@ -0,0 +1,53 @@ +--- +title: Cap +description: Cap proof-of-work captcha integration +--- + +DreamForm has built-in support for [Cap](https://capjs.js.org/), a lightweight, privacy-friendly CAPTCHA that uses SHA-256 proof-of-work instead of tracking or fingerprinting. It’s fast, self-hostable, and easy to integrate. + +## Adding Cap + +Follow the official Cap docs to deploy a Cap server (Node/Bun/Deno or standalone Docker) and create your keys. Then add the guard and required options to your `config.php`. + +```php +// site/config/config.php + +return [ + 'tobimori.dreamform' => [ + 'guards' => [ + 'available' => ['cap', /* other guards here */], + 'cap' => [ + // Base API endpoint of your Cap server (https:////) + 'endpoint' => fn () => env('CAP_ENDPOINT'), + // Server secret used to verify the token server-to-server + 'secretKey' => fn () => env('CAP_SECRET_KEY'), + // Automatically inject the widget script + 'injectScript' => true, + // Load widget from your Cap server's asset endpoint instead of the CDN + 'useAssetServer' => false, + ], + ], + ], +]; +``` + +Ideally, you should not commit these keys to your repository, but instead load them from environment variables, e.g. using the [kirby-dotenv plugin by Bruno Meilick](https://github.com/bnomei/kirby3-dotenv), as shown in the example above. + +## How it works + +- When a form includes the `cap` guard, DreamForm renders a `` element. +- The widget obtains a challenge from your Cap server and, after solving the PoW, writes a token into the form as `cap-token`. +- On submit, DreamForm verifies the token against your Cap server using the configured `endpoint` and `secretKey`. + +Cap supports customization via CSS variables, among other features. See the official docs for details: [Cap documentation](https://capjs.js.org/guide/). + +Note: If you use HTMX, the integration automatically resets the widget between swaps for a clean state. + +## Options + +| Option | Default | Accepts | Description | +| --- | --- | --- | --- | +| tobimori.dreamform.guards.cap.endpoint | `null` | `string|callable` | Base API endpoint of your Cap server ( `https:////`). | +| tobimori.dreamform.guards.cap.secretKey | `null` | `string|callable` | Secret key used by DreamForm to verify tokens with your Cap server. | +| tobimori.dreamform.guards.cap.injectScript | `true` | `boolean` | Inject the client-side widget script automatically. Falls back to CDN `https://cdn.jsdelivr.net/npm/@cap.js/widget`. | +| tobimori.dreamform.guards.cap.useAssetServer | `false` | `boolean` | If `true`, load the widget from your Cap server at `https:////widget.js` based on the configured endpoint. | diff --git a/index.php b/index.php index 7364bc4..a5a8669 100644 --- a/index.php +++ b/index.php @@ -45,7 +45,8 @@ \tobimori\DreamForm\Guards\HCaptchaGuard::class, \tobimori\DreamForm\Guards\TurnstileGuard::class, \tobimori\DreamForm\Guards\RatelimitGuard::class, - \tobimori\DreamForm\Guards\AkismetGuard::class + \tobimori\DreamForm\Guards\AkismetGuard::class, + \tobimori\DreamForm\Guards\CapGuard::class ); // register plugin @@ -127,6 +128,7 @@ 'dreamform/guards/honeypot' => __DIR__ . '/snippets/guards/honeypot.php', 'dreamform/guards/turnstile' => __DIR__ . '/snippets/guards/turnstile.php', 'dreamform/guards/hcaptcha' => __DIR__ . '/snippets/guards/hcaptcha.php', + 'dreamform/guards/cap' => __DIR__ . '/snippets/guards/cap.php' ], // get all files from /translations and register them as language files 'translations' => A::keyBy( diff --git a/snippets/guards/cap.php b/snippets/guards/cap.php new file mode 100644 index 0000000..35e3e5f --- /dev/null +++ b/snippets/guards/cap.php @@ -0,0 +1,49 @@ + 'cap', + 'data-cap-api-endpoint' => $guard::endpoint(), + 'data-cap-i18n-verifying-label' => t('dreamform.guards.cap.i18n.verifying'), + 'data-cap-i18n-initial-state' => t('dreamform.guards.cap.i18n.initial'), + 'data-cap-i18n-solved-label' => t('dreamform.guards.cap.i18n.solved'), + 'data-cap-i18n-error-label' => t('dreamform.guards.cap.i18n.error') +]; + +echo ''; + +if (DreamForm::option('guards.cap.injectScript')) : + $scriptSrc = 'https://cdn.jsdelivr.net/npm/@cap.js/widget'; + if (DreamForm::option('guards.cap.useAssetServer')) { + $endpoint = $guard::endpoint(); + if (is_string($endpoint) && $endpoint !== '') { + $parts = parse_url($endpoint); + if ($parts && isset($parts['scheme'], $parts['host'])) { + $serverUrl = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''); + $scriptSrc = rtrim($serverUrl, '/') . '/assets/widget.js'; + } + } + } +?> + + + + + diff --git a/translations/cs.json b/translations/cs.json index 32bfd81..364d3ed 100644 --- a/translations/cs.json +++ b/translations/cs.json @@ -182,6 +182,10 @@ "forms": "Formuláře", "forms.empty": "Žádné formuláře", "fromField": "Z uživatelského vstupu", + "guards.cap.i18n.verifying": "Ověřování...", + "guards.cap.i18n.initial": "Jsem člověk", + "guards.cap.i18n.solved": "Jsem člověk", + "guards.cap.i18n.error": "Chyba", "justNow": "právě teď", "license.activate": "Aktivovat licenci", "license.activate.domain": "Vaše licence bude aktivována pro doménu {{ domain }}.", diff --git a/translations/de.json b/translations/de.json index 35313b2..485ffe2 100644 --- a/translations/de.json +++ b/translations/de.json @@ -182,6 +182,10 @@ "forms": "Formulare", "forms.empty": "Keine Formulare", "fromField": "Aus Eingabefeld", + "guards.cap.i18n.verifying": "Verifizieren...", + "guards.cap.i18n.initial": "Ich bin ein Mensch", + "guards.cap.i18n.solved": "Ich bin ein Mensch", + "guards.cap.i18n.error": "Fehler", "justNow": "gerade eben", "license.activate": "Lizenz aktivieren", "license.activate.domain": "Deine Lizenz wird für die Domain {{ domain }} aktiviert.", diff --git a/translations/en.json b/translations/en.json index 6755d22..549de00 100644 --- a/translations/en.json +++ b/translations/en.json @@ -182,6 +182,10 @@ "forms": "Forms", "forms.empty": "No Forms", "fromField": "From User Input", + "guards.cap.i18n.verifying": "Verifying...", + "guards.cap.i18n.initial": "I'm a human", + "guards.cap.i18n.solved": "I'm a human", + "guards.cap.i18n.error": "Error", "justNow": "just now", "license.activate": "Activate license", "license.activate.domain": "Your license will be activated for the domain {{ domain }}.", diff --git a/translations/es.json b/translations/es.json index 4a47483..b940e65 100644 --- a/translations/es.json +++ b/translations/es.json @@ -182,6 +182,10 @@ "forms": "Formularios", "forms.empty": "No hay formularios", "fromField": "De entrada de usuario", + "guards.cap.i18n.verifying": "Verificando...", + "guards.cap.i18n.initial": "Soy un humano", + "guards.cap.i18n.solved": "Soy un humano", + "guards.cap.i18n.error": "Error", "justNow": "justo ahora", "license.activate": "Activar licencia", "license.activate.domain": "Tu licencia será activada para el dominio {{ domain }}.", diff --git a/translations/fr.json b/translations/fr.json index bfc5653..b168e48 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -182,6 +182,10 @@ "forms": "Formulaires", "forms.empty": "Aucun formulaire", "fromField": "De l'entrée utilisateur", + "guards.cap.i18n.verifying": "Vérification...", + "guards.cap.i18n.initial": "Je suis un humain", + "guards.cap.i18n.solved": "Je suis un humain", + "guards.cap.i18n.error": "Erreur", "justNow": "à l'instant", "license.activate": "Activer la licence", "license.activate.domain": "Votre licence sera activée pour le domaine {{ domain }}.", diff --git a/translations/it.json b/translations/it.json index f57f24f..dc4b344 100644 --- a/translations/it.json +++ b/translations/it.json @@ -182,6 +182,10 @@ "forms": "Moduli", "forms.empty": "Nessun Modulo", "fromField": "Da input utente", + "guards.cap.i18n.verifying": "Verifica...", + "guards.cap.i18n.initial": "Sono un umano", + "guards.cap.i18n.solved": "Sono un umano", + "guards.cap.i18n.error": "Errore", "justNow": "proprio ora", "license.activate": "Attiva licenza", "license.activate.domain": "La tua licenza sarà attivata per il dominio {{ domain }}.", diff --git a/translations/nl.json b/translations/nl.json index 321ec6d..765e1d1 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -182,6 +182,10 @@ "forms": "Formulieren", "forms.empty": "Geen formulieren", "fromField": "Veld uit formulier", + "guards.cap.i18n.verifying": "Verifiëren...", + "guards.cap.i18n.initial": "Ik ben een mens", + "guards.cap.i18n.solved": "Ik ben een mens", + "guards.cap.i18n.error": "Fout", "justNow": "zojuist", "license.activate": "Activeer licentie", "license.activate.domain": "Je licentie zal worden geactiveerd voor domein {{ domain }}.",