-
-
Notifications
You must be signed in to change notification settings - Fork 13
Add Cap proof‑of‑work CAPTCHA guard (privacy‑friendly bot protection) #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| <?php | ||
|
|
||
| namespace tobimori\DreamForm\Guards; | ||
|
|
||
| use Kirby\Http\Remote; | ||
| use tobimori\DreamForm\DreamForm; | ||
| use tobimori\DreamForm\Models\SubmissionPage; | ||
|
|
||
| class CapGuard extends Guard | ||
| { | ||
| public const TYPE = 'cap'; | ||
|
|
||
| public static function endpoint(): string|null | ||
| { | ||
| return DreamForm::option('guards.cap.endpoint'); | ||
| } | ||
|
|
||
| protected static function secretKey(): string|null | ||
| { | ||
| return DreamForm::option('guards.cap.secretKey'); | ||
| } | ||
|
|
||
| public function run(): void | ||
| { | ||
| $data = [ | ||
| 'secret' => static::secretKey(), | ||
| 'response' => SubmissionPage::valueFromBody('cap-token') | ||
| ]; | ||
tobimori marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| $remote = Remote::post(DreamForm::option('guards.cap.endpoint') . 'siteverify', [ | ||
| 'data' => $data | ||
| ]); | ||
|
Comment on lines
+30
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Build verification URL robustly and add timeout Be resilient to missing/trailing slashes and set a reasonable timeout to avoid hanging requests. - $remote = Remote::post(DreamForm::option('guards.cap.endpoint') . 'siteverify', [
- 'data' => $data
- ]);
+ $endpoint = static::endpoint();
+ $verifyUrl = rtrim((string)$endpoint, '/') . '/siteverify';
+ $remote = Remote::post($verifyUrl, [
+ 'data' => $data,
+ 'timeout' => 5,
+ ]);
🧰 Tools🪛 PHPMD (2.15.0)30-32: Avoid using static access to class '\Kirby\Http\Remote' in method 'run'. (Clean Code Rules) (StaticAccess) 30-30: Avoid using static access to class '\tobimori\DreamForm\DreamForm' in method 'run'. (Clean Code Rules) (StaticAccess) 🤖 Prompt for AI Agents |
||
|
|
||
| $result = $remote->json(); | ||
|
|
||
| if ( | ||
| $result['success'] !== true | ||
| ) { | ||
| $this->cancel(t('dreamform.submission.error.captcha')); | ||
| } | ||
| } | ||
|
Comment on lines
+34
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against network/JSON failures and undefined indexes Remote::post/json() can fail or return non-array; accessing $result['success'] directly risks warnings. Handle HTTP errors and parse safely. - $result = $remote->json();
-
- if (
- $result['success'] !== true
- ) {
- $this->cancel(t('dreamform.submission.error.captcha'));
- }
+ $result = $remote->json();
+
+ $ok = method_exists($remote, 'code') ? ($remote->code() >= 200 && $remote->code() < 400) : true;
+ $success = is_array($result) ? ($result['success'] ?? false) === true : false;
+
+ if (!$ok || !$success) {
+ $this->cancel(t('dreamform.submission.error.captcha'));
+ return;
+ }🤖 Prompt for AI Agents |
||
|
|
||
| public static function hasSnippet(): bool | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| public static function isAvailable(): bool | ||
| { | ||
| return static::endpoint() !== null | ||
| && static::secretKey() !== null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||
| title: Cap | ||||||||||||||||||||||||||
| description: Cap proof-of-work captcha integration | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Capitalize “CAPTCHA” for consistency with the rest of the doc Use consistent capitalization across title/description/body. -description: Cap proof-of-work captcha integration
+description: Cap proof-of-work CAPTCHA integration📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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://<instance_url>/<site_key>/) | ||||||||||||||||||||||||||
| '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, | ||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||
|
Comment on lines
+21
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainClarify endpoint format and script loading behavior
Please confirm the actual runtime precedence used by CapGuard/snippet so the docs can match it precisely. 🏁 Script executed: #!/bin/bash
# Search for injectScript configuration references and implementation
rg -n "injectScript" -A5 -B5
# Search for useAssetServer configuration references and implementation
rg -n "useAssetServer" -A5 -B5Length of output: 10107 🏁 Script executed: #!/bin/bash
# Display the full injection logic in the Cap guard snippet for precise behavior
rg -n "if (DreamForm::option('guards.cap.injectScript')" -A20 -B5 snippets/guards/cap.phpLength of output: 200 🏁 Script executed: #!/bin/bash
# Show the full Cap guard snippet to confirm script injection logic
sed -n '1,200p' snippets/guards/cap.phpLength of output: 2034 Clarify CAP endpoint format and widget‐script loading
Locations to update:
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Minor grammar polish: “e.g., …” Add a comma after “e.g.” per style guides. -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.
+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.📝 Committable suggestion
Suggested change
🧰 Tools🪛 LanguageTool[grammar] ~34-~34: Use commas correctly (QB_NEW_EN_OTHER_ERROR_IDS_33) [grammar] ~34-~34: Use a comma after introductory words or phrases (QB_NEW_EN_OTHER_ERROR_IDS_19) [grammar] ~34-~34: Use correct spacing (QB_NEW_EN_OTHER_ERROR_IDS_5) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## How it works | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| - When a form includes the `cap` guard, DreamForm renders a `<cap-widget>` 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://<instance_url>/<site_key>/`). | | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Nit: remove the stray space inside parentheses Small formatting tidy-up in the description cell. -| tobimori.dreamform.guards.cap.endpoint | `null` | `string\|callable` | Base API endpoint of your Cap server ( `https://<instance_url>/<site_key>/`). |
+| tobimori.dreamform.guards.cap.endpoint | `null` | `string\|callable` | Base API endpoint of your Cap server (`https://<instance_url>/<site_key>/`). |📝 Committable suggestion
Suggested change
🧰 Tools🪛 markdownlint-cli2 (0.17.2)50-50: Table column count (MD056, table-column-count) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| | 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:///<instance_url>/widget.js` based on the configured endpoint. | | ||||||||||||||||||||||||||
|
Comment on lines
+48
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix table rendering (MD056) and a broken URL
These currently break table parsing and show an invalid URL. -| tobimori.dreamform.guards.cap.endpoint | `null` | `string|callable` | Base API endpoint of your Cap server ( `https://<instance_url>/<site_key>/`). |
-| tobimori.dreamform.guards.cap.secretKey | `null` | `string|callable` | Secret key used by DreamForm to verify tokens with your Cap server. |
+| tobimori.dreamform.guards.cap.endpoint | `null` | `string\|callable` | Base API endpoint of your Cap server (`https://<instance_url>/<site_key>/`). |
+| 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.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:///<instance_url>/widget.js` based on the configured endpoint. |
+| tobimori.dreamform.guards.cap.useAssetServer | `false` | `boolean` | If `true`, load the widget from your Cap server at `https://<instance_url>/widget.js` based on the configured endpoint. |📝 Committable suggestion
Suggested change
🧰 Tools🪛 LanguageTool[grammar] ~51-~51: There might be a mistake here. (QB_NEW_EN_OTHER) [grammar] ~53-~53: There might be a mistake here. (QB_NEW_EN_OTHER) 🪛 markdownlint-cli2 (0.17.2)50-50: Table column count (MD056, table-column-count) 51-51: Table column count (MD056, table-column-count) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @var tobimori\DreamForm\Models\FormPage $form | ||
| * @var tobimori\DreamForm\Guards\CapGuard $guard | ||
| */ | ||
|
|
||
| use tobimori\DreamForm\DreamForm; | ||
| use tobimori\DreamForm\Support\Htmx; | ||
|
|
||
| $attrs = [ | ||
| 'id' => '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 '<cap-widget ' . attr($attrs) . '></cap-widget>'; | ||
|
|
||
tobimori marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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'; | ||
| } | ||
| } | ||
| } | ||
| ?> | ||
| <script src="<?= $scriptSrc ?>"></script> | ||
tobimori marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <?php if (Htmx::isActive()) : ?> | ||
| <script> | ||
| if (typeof htmx !== 'undefined') { | ||
| htmx.on("htmx:beforeSwap", () => { | ||
| const el = document.querySelector("cap-widget"); | ||
| if (el) { | ||
| const newEl = el.cloneNode(true); | ||
| el.parentNode.replaceChild(newEl, el); | ||
| } | ||
| }); | ||
| } | ||
| </script> | ||
| <?php endif; ?> | ||
| <?php endif; ?> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Czech CAP i18n keys added — minor UX nit Keys are consistent. Consider using a distinct “solved” string (e.g., “Ověřeno”) to make state changes clearer for screen readers, instead of repeating “Jsem člověk”. 🤖 Prompt for AI Agents |
||
| "justNow": "právě teď", | ||
| "license.activate": "Aktivovat licenci", | ||
| "license.activate.domain": "Vaše licence bude aktivována pro doménu <strong>{{ domain }}</strong>.", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) German CAP i18n keys added — minor UX nit All good. You may want a distinct solved label (e.g., “Verifiziert”) rather than repeating “Ich bin ein Mensch” to improve a11y clarity. 🤖 Prompt for AI Agents |
||
| "justNow": "gerade eben", | ||
| "license.activate": "Lizenz aktivieren", | ||
| "license.activate.domain": "Deine Lizenz wird für die Domain <strong>{{ domain }}</strong> aktiviert.", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) English CAP i18n keys added — minor UX nit LGTM. Consider changing the solved label to “Verified” (or similar) so the state change is obvious to screen readers and visual users. 🤖 Prompt for AI Agents |
||
| "justNow": "just now", | ||
| "license.activate": "Activate license", | ||
| "license.activate.domain": "Your license will be activated for the domain <strong>{{ domain }}</strong>.", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Spanish CAP i18n keys added — minor UX nit Looks good. Optionally differentiate the solved label (e.g., “Verificado”) instead of repeating “Soy un humano” to better convey state change for assistive tech. 🤖 Prompt for AI Agents |
||
| "justNow": "justo ahora", | ||
| "license.activate": "Activar licencia", | ||
| "license.activate.domain": "Tu licencia será activada para el dominio <strong>{{ domain }}</strong>.", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.