splent-feature-cloudflare 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # Compiled frontend bundles
2
+ recursive-include src/splent_io/splent_feature_cloudflare/assets/dist *.js *.css *.map
3
+
4
+ # Jinja templates (feature views + hook fragments)
5
+ recursive-include src/splent_io/splent_feature_cloudflare/templates *.html
6
+
7
+ # Alembic migration config and scripts
8
+ recursive-include src/splent_io/splent_feature_cloudflare/migrations *.py *.ini *.mako
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_cloudflare
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.3.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "splent_feature_cloudflare"
7
+ version = "0.1.0"
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+
11
+ dependencies = [
12
+
13
+ ]
14
+
15
+ [tool.setuptools]
16
+ package-dir = { "" = "src" }
17
+ include-package-data = true
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+ exclude = ["*.tests", "*.tests.*"]
22
+
23
+ [tool.splent]
24
+ cli_version = "1.11.0"
25
+
26
+ # ── Feature Contract (auto-generated) ────────────────────────────────────────
27
+ # Do not edit manually — re-run `splent feature:contract --write` to refresh.
28
+ [tool.splent.contract]
29
+ description = "cloudflare feature"
30
+
31
+ [tool.splent.contract.provides]
32
+ routes = ["/admin/captcha"]
33
+ blueprints = []
34
+ models = []
35
+ commands = ["hello"]
36
+ hooks = ["layout.authenticated_sidebar"]
37
+ services = []
38
+ signals = []
39
+ translations = []
40
+ docker = []
41
+
42
+ [tool.splent.contract.requires]
43
+ features = []
44
+ env_vars = ["TURNSTILE_SECRET_KEY", "TURNSTILE_SITE_KEY"]
45
+ signals = ["comment-submitting", "contact-submitting"]
46
+
47
+ [tool.splent.contract.extensible]
48
+ services = []
49
+ templates = ["cloudflare/admin/settings.html"]
50
+ models = []
51
+ hooks = ["layout.authenticated_sidebar"]
52
+ routes = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_cloudflare
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,14 @@
1
+ MANIFEST.in
2
+ pyproject.toml
3
+ src/splent_feature_cloudflare.egg-info/PKG-INFO
4
+ src/splent_feature_cloudflare.egg-info/SOURCES.txt
5
+ src/splent_feature_cloudflare.egg-info/dependency_links.txt
6
+ src/splent_feature_cloudflare.egg-info/top_level.txt
7
+ src/splent_io/splent_feature_cloudflare/__init__.py
8
+ src/splent_io/splent_feature_cloudflare/commands.py
9
+ src/splent_io/splent_feature_cloudflare/config.py
10
+ src/splent_io/splent_feature_cloudflare/hooks.py
11
+ src/splent_io/splent_feature_cloudflare/routes.py
12
+ src/splent_io/splent_feature_cloudflare/services.py
13
+ src/splent_io/splent_feature_cloudflare/signals.py
14
+ src/splent_io/splent_feature_cloudflare/templates/cloudflare/admin/settings.html
@@ -0,0 +1,26 @@
1
+ from splent_framework.blueprints.base_blueprint import create_blueprint
2
+ from splent_framework.services.service_locator import register_service, service_proxy
3
+
4
+ from splent_io.splent_feature_cloudflare.services import CloudflareService
5
+
6
+ cloudflare_bp = create_blueprint(__name__)
7
+
8
+
9
+ def init_feature(app):
10
+ # Registered under the GENERIC "CaptchaService" name so consumers (e.g.
11
+ # comments) stay provider-agnostic. This is the Cloudflare Turnstile
12
+ # implementation of the 'captcha' alternative (recaptcha is the other).
13
+ register_service(app, "CaptchaService", CloudflareService)
14
+
15
+
16
+ def inject_context_vars(app):
17
+ # Generic captcha helpers so templates work with any provider:
18
+ # {{ captcha_script() }} (once on the page)
19
+ # {{ captcha_widget() }} (inside the form)
20
+ def captcha_widget():
21
+ return service_proxy("CaptchaService").widget()
22
+
23
+ def captcha_script():
24
+ return service_proxy("CaptchaService").script_tag()
25
+
26
+ return {"captcha_widget": captcha_widget, "captcha_script": captcha_script}
@@ -0,0 +1,21 @@
1
+ """
2
+ CLI commands contributed by splent_feature_cloudflare.
3
+
4
+ These commands are auto-discovered by the framework and exposed in the
5
+ SPLENT CLI under the ``feature:cloudflare`` group.
6
+
7
+ Usage::
8
+
9
+ splent feature:cloudflare hello
10
+ """
11
+
12
+ import click
13
+
14
+
15
+ @click.command("hello")
16
+ def hello():
17
+ """Example command — replace with your own."""
18
+ click.echo(" Hello from splent_feature_cloudflare!")
19
+
20
+
21
+ cli_commands = [hello]
@@ -0,0 +1,21 @@
1
+ """Cloudflare Turnstile configuration (a privacy-friendly anti-spam CAPTCHA).
2
+
3
+ Set TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY in the product's .env (dev/prod).
4
+ Defaults are Cloudflare's official TEST keys (always pass), so development works
5
+ out of the box — set real keys in production.
6
+ """
7
+
8
+ import os
9
+
10
+ # Cloudflare's documented test keys: always pass, safe for dev.
11
+ _TEST_SITE_KEY = "1x00000000000000000000AA"
12
+ _TEST_SECRET_KEY = "1x0000000000000000000000000000000AA"
13
+
14
+
15
+ def inject_config(app):
16
+ app.config.update(
17
+ {
18
+ "TURNSTILE_SITE_KEY": os.getenv("TURNSTILE_SITE_KEY", _TEST_SITE_KEY),
19
+ "TURNSTILE_SECRET_KEY": os.getenv("TURNSTILE_SECRET_KEY", _TEST_SECRET_KEY),
20
+ }
21
+ )
@@ -0,0 +1,23 @@
1
+ from flask import request, url_for
2
+
3
+ from splent_framework.hooks.template_hooks import register_template_hook
4
+
5
+
6
+ def cloudflare_admin_link():
7
+ """Sidebar entry for the Captcha (Cloudflare Turnstile) settings screen."""
8
+ active = (
9
+ "active"
10
+ if request.endpoint and request.endpoint.startswith("cloudflare.admin")
11
+ else ""
12
+ )
13
+ return (
14
+ f'<li class="sidebar-item {active}">'
15
+ f'<a class="sidebar-link" href="{url_for("cloudflare.admin_settings")}">'
16
+ '<i class="align-middle" data-feather="shield"></i> '
17
+ '<span class="align-middle">Captcha</span>'
18
+ "</a>"
19
+ "</li>"
20
+ )
21
+
22
+
23
+ register_template_hook("layout.authenticated_sidebar", cloudflare_admin_link)
@@ -0,0 +1,26 @@
1
+ from flask import flash, redirect, render_template, request, url_for
2
+ from flask_login import login_required
3
+
4
+ from splent_io.splent_feature_cloudflare import cloudflare_bp
5
+ from splent_framework.services.service_locator import service_proxy
6
+
7
+
8
+ # =====================================================================
9
+ # ADMIN — Captcha (Cloudflare Turnstile) settings
10
+ # =====================================================================
11
+ @cloudflare_bp.route("/admin/captcha", methods=["GET", "POST"])
12
+ @login_required
13
+ def admin_settings():
14
+ """Edit the Turnstile keys from the admin (runtime, no .env edit needed)."""
15
+ if request.method == "POST":
16
+ site_key = (request.form.get("site_key") or "").strip()
17
+ secret_key = (request.form.get("secret_key") or "").strip()
18
+ service_proxy("SettingsService").set_many(
19
+ {
20
+ "turnstile_site_key": site_key,
21
+ "turnstile_secret_key": secret_key,
22
+ }
23
+ )
24
+ flash("Captcha settings saved.", "success")
25
+ return redirect(url_for("cloudflare.admin_settings"))
26
+ return render_template("cloudflare/admin/settings.html")
@@ -0,0 +1,76 @@
1
+ import requests
2
+ from flask import current_app
3
+ from markupsafe import Markup
4
+
5
+ from splent_framework.services.service_locator import service_proxy
6
+
7
+ VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
8
+
9
+
10
+ class CloudflareService:
11
+ """Cloudflare Turnstile — a CAPTCHA that protects forms from spam.
12
+
13
+ A utility service with no DB model. Registered under the generic
14
+ ``CaptchaService`` name so consumers stay provider-agnostic. Templates use
15
+ the captcha_widget()/captcha_script() helpers; routes call
16
+ ``verify(token, remoteip)`` before accepting a submission.
17
+
18
+ Keys are read from the admin-editable SettingsService first (so they can be
19
+ changed at runtime from the admin panel), falling back to the product's
20
+ config/.env (TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY).
21
+ """
22
+
23
+ def _setting(self, key):
24
+ """Read an admin-editable setting, tolerating an absent SettingsService."""
25
+ try:
26
+ return service_proxy("SettingsService").get(key, None)
27
+ except Exception:
28
+ return None
29
+
30
+ def site_key(self):
31
+ return self._setting("turnstile_site_key") or current_app.config.get(
32
+ "TURNSTILE_SITE_KEY", ""
33
+ )
34
+
35
+ def secret_key(self):
36
+ return self._setting("turnstile_secret_key") or current_app.config.get(
37
+ "TURNSTILE_SECRET_KEY", ""
38
+ )
39
+
40
+ def enabled(self):
41
+ return bool(self.site_key())
42
+
43
+ def widget(self):
44
+ """The Turnstile widget markup to drop inside a form."""
45
+ key = self.site_key()
46
+ if not key:
47
+ return Markup("")
48
+ return Markup(f'<div class="cf-turnstile" data-sitekey="{key}"></div>')
49
+
50
+ def script_tag(self):
51
+ """The Turnstile JS, to include once on a page that renders the widget."""
52
+ if not self.enabled():
53
+ return Markup("")
54
+ return Markup(
55
+ '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"'
56
+ " async defer></script>"
57
+ )
58
+
59
+ def verify(self, token, remoteip=None):
60
+ """Validate a Turnstile token with Cloudflare. Returns True/False.
61
+
62
+ If no secret is configured, returns True (does not block submissions).
63
+ """
64
+ secret = self.secret_key()
65
+ if not secret:
66
+ return True
67
+ if not token:
68
+ return False
69
+ try:
70
+ data = {"secret": secret, "response": token}
71
+ if remoteip:
72
+ data["remoteip"] = remoteip
73
+ resp = requests.post(VERIFY_URL, data=data, timeout=10)
74
+ return bool(resp.json().get("success"))
75
+ except Exception:
76
+ return False
@@ -0,0 +1,24 @@
1
+ """Anti-spam: validate submitted comments with Cloudflare Turnstile.
2
+
3
+ Listens to the comments feature's ``comment-submitting`` signal. There is NO
4
+ hard dependency on comments — if comments isn't installed the signal is never
5
+ defined and this handler is simply skipped.
6
+ """
7
+
8
+ from splent_framework.signals.signal_utils import connect_signal
9
+
10
+
11
+ @connect_signal("comment-submitting", "splent_feature_cloudflare")
12
+ def validate_turnstile(sender, form=None, remoteip=None, **kwargs):
13
+ from splent_io.splent_feature_cloudflare.services import CloudflareService
14
+
15
+ token = form.get("cf-turnstile-response") if form else None
16
+ return CloudflareService().verify(token, remoteip)
17
+
18
+
19
+ @connect_signal("contact-submitting", "splent_feature_cloudflare")
20
+ def validate_turnstile_contact(sender, form=None, remoteip=None, **kwargs):
21
+ from splent_io.splent_feature_cloudflare.services import CloudflareService
22
+
23
+ token = form.get("cf-turnstile-response") if form else None
24
+ return CloudflareService().verify(token, remoteip)
@@ -0,0 +1,45 @@
1
+ {% extends "base_template.html" %}
2
+
3
+ {% block title %}Captcha{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="d-flex flex-column align-items-start gap-2 mb-4">
7
+ <div>
8
+ <h1 class="h3 mb-0">Captcha — Cloudflare Turnstile</h1>
9
+ <p class="text-muted mb-0">Keys used to protect forms from spam. Editable here, no .env edit needed.</p>
10
+ </div>
11
+ </div>
12
+
13
+ {% with messages = get_flashed_messages(with_categories=true) %}
14
+ {% for category, message in messages %}
15
+ <div class="alert alert-{{ 'danger' if category == 'danger' else 'success' }} py-2">{{ message }}</div>
16
+ {% endfor %}
17
+ {% endwith %}
18
+
19
+ <form method="POST" class="card">
20
+ <div class="card-body">
21
+ <div class="row g-3">
22
+ <div class="col-12">
23
+ <label class="form-label" for="site_key">Site key</label>
24
+ <input id="site_key" name="site_key" class="form-control" value="{{ setting('turnstile_site_key', '') }}" placeholder="1x00000000000000000000AA">
25
+ <div class="form-text">Public key, rendered in the page next to the widget.</div>
26
+ </div>
27
+ <div class="col-12">
28
+ <label class="form-label" for="secret_key">Secret key</label>
29
+ <input id="secret_key" name="secret_key" type="password" class="form-control" value="{{ setting('turnstile_secret_key', '') }}" placeholder="1x0000000000000000000000000000000AA">
30
+ <div class="form-text">Private key, used server-side to verify submissions. Keep it secret.</div>
31
+ </div>
32
+ <div class="col-12">
33
+ <div class="alert alert-info mb-0 py-2">
34
+ Cloudflare's official <strong>test keys</strong> are used by default, so development works out of the box (they always pass).
35
+ Get real keys for production from your
36
+ <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank" rel="noopener">Cloudflare Turnstile dashboard</a>.
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <div class="card-footer text-end">
42
+ <button class="btn btn-primary" type="submit">Save</button>
43
+ </div>
44
+ </form>
45
+ {% endblock %}