Add configurable donation options
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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">₿</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 %}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user