Add configurable donation options

This commit is contained in:
rob
2026-09-29 13:11:19 -03:00
parent 2e38c5d629
commit 16be1c8d92
5 changed files with 244 additions and 3 deletions
+2
View File
@@ -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)
+69
View File
@@ -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,
)
+4 -1
View File
@@ -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")
+83 -2
View File
@@ -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 %}
<div class="bg-gray-50 min-h-screen">
@@ -11,13 +11,76 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<h1 class="text-3xl font-bold text-gray-900">Support CmdForge</h1>
<p class="mt-4 text-lg text-gray-600">
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.
</p>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{% if donations.enabled %}
<section class="mb-12" aria-labelledby="financial-support-heading">
<div class="text-center mb-8">
<h2 id="financial-support-heading" class="text-2xl font-bold text-gray-900">Make a Contribution</h2>
<p class="mt-3 text-gray-600">
Choose the method that works for you. Donations are voluntary and do not unlock product features.
</p>
</div>
<div class="grid grid-cols-1 {% if donations.bitcoin_address and donations.paypal_url %}md:grid-cols-2{% endif %} gap-6 max-w-4xl mx-auto">
{% if donations.bitcoin_address %}
<article class="bg-white rounded-xl border border-amber-200 p-7 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-amber-100 rounded-full flex items-center justify-center text-amber-700 font-bold text-xl">&#8383;</div>
<div>
<h3 class="text-xl font-semibold text-gray-900">Bitcoin</h3>
<p class="text-sm text-gray-500">Send BTC directly to the project wallet.</p>
</div>
</div>
<label for="bitcoin-address" class="block mt-6 text-sm font-medium text-gray-700">Bitcoin address</label>
<div class="mt-2 flex flex-col sm:flex-row gap-2">
<code id="bitcoin-address" class="min-w-0 flex-1 px-3 py-3 bg-gray-50 border border-gray-200 rounded-md text-xs text-gray-800 break-all select-all">{{ donations.bitcoin_address }}</code>
<button type="button" id="copy-bitcoin-address"
class="px-4 py-2 text-sm font-medium text-amber-800 border border-amber-300 rounded-md hover:bg-amber-50"
data-address="{{ donations.bitcoin_address }}">
Copy
</button>
</div>
<p id="copy-bitcoin-status" class="mt-2 text-sm text-green-700 min-h-5" role="status" aria-live="polite"></p>
<a href="{{ donations.bitcoin_uri }}"
class="mt-2 inline-flex items-center justify-center w-full px-5 py-3 text-sm font-medium text-white bg-amber-600 rounded-md hover:bg-amber-700">
Open Bitcoin Wallet
</a>
<p class="mt-3 text-xs text-gray-500">Always verify the full address in your wallet before sending.</p>
</article>
{% endif %}
{% if donations.paypal_url %}
<article class="bg-white rounded-xl border border-blue-200 p-7 shadow-sm flex flex-col">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center text-blue-700 font-bold text-xl">P</div>
<div>
<h3 class="text-xl font-semibold text-gray-900">PayPal</h3>
<p class="text-sm text-gray-500">Contribute with PayPal or a supported card.</p>
</div>
</div>
<p class="mt-6 text-gray-600 flex-1">
PayPal handles the payment securely on its own website. CmdForge never receives your payment credentials.
</p>
<a href="{{ donations.paypal_url }}" target="_blank" rel="noopener noreferrer"
class="mt-6 inline-flex items-center justify-center w-full px-5 py-3 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Donate with PayPal
</a>
</article>
{% endif %}
</div>
</section>
{% endif %}
<!-- Ways to help -->
<div class="text-center mb-8">
<h2 class="text-2xl font-bold text-gray-900">Other Ways to Help</h2>
<p class="mt-3 text-gray-600">Money is never the only way to make a meaningful contribution.</p>
</div>
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
@@ -85,3 +148,21 @@
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{% if donations.bitcoin_address %}
<script>
document.getElementById('copy-bitcoin-address')?.addEventListener('click', async function () {
const status = document.getElementById('copy-bitcoin-status');
try {
await navigator.clipboard.writeText(this.dataset.address);
status.textContent = 'Address copied.';
this.textContent = 'Copied';
} catch (error) {
status.textContent = 'Select the address above and copy it manually.';
}
});
</script>
{% endif %}
{% endblock %}
+86
View File
@@ -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:[email protected]/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