From 16be1c8d928667d67b731c4e0be05b2e5a913a10 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 13:11:19 -0300 Subject: [PATCH] Add configurable donation options --- src/cmdforge/web/app.py | 2 + src/cmdforge/web/donations.py | 69 ++++++++++++++++ src/cmdforge/web/routes.py | 5 +- src/cmdforge/web/templates/pages/donate.html | 85 ++++++++++++++++++- tests/test_web_donations.py | 86 ++++++++++++++++++++ 5 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/cmdforge/web/donations.py create mode 100644 tests/test_web_donations.py diff --git a/src/cmdforge/web/app.py b/src/cmdforge/web/app.py index ff0817f..5b258f1 100644 --- a/src/cmdforge/web/app.py +++ b/src/cmdforge/web/app.py @@ -29,6 +29,7 @@ from cmdforge.registry import app as registry_app from . import web_bp from .auth import login, register, logout +from .donations import load_donation_options from .filters import register_filters from .forum import forum_bp from .sessions import SQLiteSessionInterface, cleanup_expired_sessions @@ -48,6 +49,7 @@ def create_web_app() -> Flask: app.config["SESSION_COOKIE_NAME"] = "cmdforge_session" app.config["SESSION_COOKIE_SECURE"] = os.environ.get("CMDFORGE_ENV") == "production" app.config["SHOW_ADS"] = os.environ.get("CMDFORGE_SHOW_ADS", "").lower() == "true" + app.config["DONATION_OPTIONS"] = load_donation_options(os.environ) # CSRF token generator app.config["CSRF_GENERATOR"] = lambda: secrets.token_urlsafe(32) diff --git a/src/cmdforge/web/donations.py b/src/cmdforge/web/donations.py new file mode 100644 index 0000000..9812f9a --- /dev/null +++ b/src/cmdforge/web/donations.py @@ -0,0 +1,69 @@ +"""Validation and presentation helpers for public donation settings.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Mapping +from urllib.parse import urlencode, urlparse + + +_BASE58_BITCOIN_ADDRESS = re.compile(r"^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$") +_BECH32_BITCOIN_ADDRESS = re.compile(r"^bc1[ac-hj-np-z02-9]{11,71}$") +_PAYPAL_HOSTS = {"paypal.com", "www.paypal.com", "paypal.me", "www.paypal.me"} + + +@dataclass(frozen=True) +class DonationOptions: + """Public donation methods safe to render as links.""" + + bitcoin_address: str = "" + bitcoin_uri: str = "" + paypal_url: str = "" + + @property + def enabled(self) -> bool: + return bool(self.bitcoin_address or self.paypal_url) + + +def _valid_bitcoin_address(value: str) -> bool: + if _BASE58_BITCOIN_ADDRESS.fullmatch(value): + return True + # Bech32 addresses may be all lower- or uppercase, but never mixed case. + if value.lower() != value and value.upper() != value: + return False + return bool(_BECH32_BITCOIN_ADDRESS.fullmatch(value.lower())) + + +def _valid_paypal_url(value: str) -> bool: + parsed = urlparse(value) + return ( + parsed.scheme == "https" + and parsed.hostname is not None + and parsed.hostname.lower() in _PAYPAL_HOSTS + and not parsed.username + and not parsed.password + ) + + +def load_donation_options(environment: Mapping[str, str]) -> DonationOptions: + """Load public donation details, ignoring malformed environment values.""" + + bitcoin_address = environment.get("CMDFORGE_DONATION_BITCOIN_ADDRESS", "").strip() + if not _valid_bitcoin_address(bitcoin_address): + bitcoin_address = "" + + paypal_url = environment.get("CMDFORGE_DONATION_PAYPAL_URL", "").strip() + if not _valid_paypal_url(paypal_url): + paypal_url = "" + + bitcoin_uri = "" + if bitcoin_address: + query = urlencode({"label": "CmdForge", "message": "Support CmdForge development"}) + bitcoin_uri = f"bitcoin:{bitcoin_address}?{query}" + + return DonationOptions( + bitcoin_address=bitcoin_address, + bitcoin_uri=bitcoin_uri, + paypal_url=paypal_url, + ) diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index 4225c8a..ab47ea5 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -876,7 +876,10 @@ def philosophy(): @web_bp.route("/donate", endpoint="donate") def donate(): - return render_template("pages/donate.html") + return render_template( + "pages/donate.html", + donations=current_app.config["DONATION_OPTIONS"], + ) @web_bp.route("/privacy", endpoint="privacy") diff --git a/src/cmdforge/web/templates/pages/donate.html b/src/cmdforge/web/templates/pages/donate.html index c0fdd16..d418077 100644 --- a/src/cmdforge/web/templates/pages/donate.html +++ b/src/cmdforge/web/templates/pages/donate.html @@ -2,7 +2,7 @@ {% block title %}Support CmdForge{% endblock %} -{% block meta_description %}Support CmdForge by contributing code, tools, or feedback.{% endblock %} +{% block meta_description %}Support the open-source CmdForge project with Bitcoin, PayPal, tools, code, or feedback.{% endblock %} {% block content %}
@@ -11,13 +11,76 @@

Support CmdForge

- CmdForge is a passion project. The best way to support it is to use it and share what you build. + Help keep an independent, open-source toolkit growing. Every contribution supports hosting, + maintenance, documentation, and the next generation of tools.

+ {% if donations.enabled %} +
+
+

Make a Contribution

+

+ Choose the method that works for you. Donations are voluntary and do not unlock product features. +

+
+
+ {% if donations.bitcoin_address %} +
+
+
₿
+
+

Bitcoin

+

Send BTC directly to the project wallet.

+
+
+ +
+ {{ donations.bitcoin_address }} + +
+

+ + Open Bitcoin Wallet + +

Always verify the full address in your wallet before sending.

+
+ {% endif %} + + {% if donations.paypal_url %} +
+
+
P
+
+

PayPal

+

Contribute with PayPal or a supported card.

+
+
+

+ PayPal handles the payment securely on its own website. CmdForge never receives your payment credentials. +

+ + Donate with PayPal + +
+ {% endif %} +
+
+ {% endif %} + +
+

Other Ways to Help

+

Money is never the only way to make a meaningful contribution.

+
@@ -85,3 +148,21 @@
{% endblock %} + +{% block scripts %} +{{ super() }} +{% if donations.bitcoin_address %} + +{% endif %} +{% endblock %} diff --git a/tests/test_web_donations.py b/tests/test_web_donations.py new file mode 100644 index 0000000..b298bec --- /dev/null +++ b/tests/test_web_donations.py @@ -0,0 +1,86 @@ +"""Tests for safe, configuration-driven donation methods.""" + +from cmdforge.web.donations import load_donation_options + + +def _donation_page(tmp_path, monkeypatch, **environment): + monkeypatch.setenv("CMDFORGE_REGISTRY_DB", str(tmp_path / "registry.db")) + monkeypatch.delenv("CMDFORGE_DONATION_BITCOIN_ADDRESS", raising=False) + monkeypatch.delenv("CMDFORGE_DONATION_PAYPAL_URL", raising=False) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + from cmdforge.web.app import create_web_app + + app = create_web_app() + app.config["TESTING"] = True + return app.test_client().get("/donate") + + +def test_loads_valid_bitcoin_and_paypal_options(): + options = load_donation_options( + { + "CMDFORGE_DONATION_BITCOIN_ADDRESS": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "CMDFORGE_DONATION_PAYPAL_URL": "https://www.paypal.com/donate/?hosted_button_id=EXAMPLE", + } + ) + + assert options.enabled + assert options.bitcoin_address == "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" + assert options.bitcoin_uri.startswith("bitcoin:bc1qxy2") + assert "label=CmdForge" in options.bitcoin_uri + assert options.paypal_url.startswith("https://www.paypal.com/donate/") + + +def test_accepts_legacy_base58_bitcoin_address(): + options = load_donation_options( + {"CMDFORGE_DONATION_BITCOIN_ADDRESS": "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"} + ) + + assert options.bitcoin_address == "1BoatSLRHtKNngkdXEeobR76b53LETtpyT" + + +def test_rejects_malformed_or_unsafe_values(): + options = load_donation_options( + { + "CMDFORGE_DONATION_BITCOIN_ADDRESS": "not-a-bitcoin-address", + "CMDFORGE_DONATION_PAYPAL_URL": "javascript:alert(1)", + } + ) + + assert not options.enabled + assert options.bitcoin_address == "" + assert options.bitcoin_uri == "" + assert options.paypal_url == "" + + +def test_rejects_paypal_lookalike_and_embedded_credentials(): + assert not load_donation_options( + {"CMDFORGE_DONATION_PAYPAL_URL": "https://paypal.com.example.test/donate"} + ).enabled + assert not load_donation_options( + {"CMDFORGE_DONATION_PAYPAL_URL": "https://user:pass@paypal.com/donate"} + ).enabled + + +def test_donation_page_renders_configured_methods(tmp_path, monkeypatch): + response = _donation_page( + tmp_path, + monkeypatch, + CMDFORGE_DONATION_BITCOIN_ADDRESS="bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + CMDFORGE_DONATION_PAYPAL_URL="https://paypal.me/cmdforge", + ) + + assert response.status_code == 200 + assert b"Make a Contribution" in response.data + assert b"Open Bitcoin Wallet" in response.data + assert b"Donate with PayPal" in response.data + assert b"https://paypal.me/cmdforge" in response.data + + +def test_donation_page_hides_payment_section_when_unconfigured(tmp_path, monkeypatch): + response = _donation_page(tmp_path, monkeypatch) + + assert response.status_code == 200 + assert b"Make a Contribution" not in response.data + assert b"Other Ways to Help" in response.data