Files
simplex-chat/apps/simplex-badge-service/web/mock/server.py
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

324 lines
14 KiB
Python

"""Stands in for the Haskell service AND for Stripe and BTCPay, so the whole
browser flow can be driven without any of them. A test fixture: no signatures,
no persistence, no money, and not a specification of the real service.
Standard library only. Threaded, because the wait endpoint holds a connection.
Environment:
MOCK_HOLD_SECONDS how long GET /api/invoice/:id?wait= holds (default 30)
STRIPE_PUBLISHABLE_KEY substituted into the
served index.html. Public by design, but still not
committed: unset, the page has NO card form and
renders the development stand-in instead, whose
button does what a successful confirm does and whose
settling is POST /control/settle/<invoiceId> below.
Set it to a `pk_test_...` key to drive the real
Stripe path; a secret key here is refused at start.
"""
import json, os, re, secrets, sys, threading
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
ROOT = Path(__file__).resolve().parent.parent
HOLD_SECONDS = float(os.environ.get("MOCK_HOLD_SECONDS", "30"))
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "").strip()
KEY_META = re.compile(r'(<meta id="stripe-publishable-key"[^>]*content=")[^"]*(")')
CATALOG = {
"price_supporter": {"badgeType": "supporter", "monthPrice": 700},
"price_legend": {"badgeType": "legend", "monthPrice": 7000},
}
OFFERS = {
"offer_3m": {"months": 3, "free": 1},
"offer_12m": {"months": 12, "discount": 50},
"offer_3m_s": {"months": 3, "free": 1},
"offer_12m_s": {"months": 12, "discount": 50},
}
MIME = {".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".svg": "image/svg+xml", ".json": "application/json", ".webmanifest": "application/manifest+json"}
# BTCPay's default speed policy asks for one confirmation.
REQUIRED_CONFIRMATIONS = 1
ADDRESSES = {"btc": "bc1qexampleaddress0k3jq2wvcgmqz", "xmr": "48HqK2XmVexampleAddress9fRtWc"}
LOCK = threading.Lock()
INVOICES = {}
HASHES = {}
EVENTS = {}
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def total(price_id, offer_id):
price = CATALOG.get(price_id)
if price is None:
return None
if not offer_id:
return {"months": 1, "amount": price["monthPrice"]}
offer = OFFERS.get(offer_id)
if offer is None:
return None
gross = price["monthPrice"] * offer["months"]
if "free" in offer:
amount = price["monthPrice"] * (offer["months"] - offer["free"])
else:
amount = (gross * (100 - offer["discount"])) // 100
return {"months": offer["months"], "amount": amount}
def payment_mark(inv):
"""The figure the page shows and the verdict it shows it under, which together decide
which screen it is on."""
return (inv.get("cryptoAmountPaid") or "", inv.get("paidInFull") is True)
def swap_event(invoice_id):
"""Called under LOCK; returns the event to fire once it is released. A fresh Event for
whoever parks next, so a waiter that read the pre-change status but calls wait() after we
fire this one still catches its own (already-set) event instead of racing a clear() on a
shared one and missing the wake."""
old = EVENTS.get(invoice_id)
EVENTS[invoice_id] = threading.Event()
return old
def public_view(inv):
"""What the browser may see. Note what is absent: no code, no code hash — the service
never has the code."""
view = {
"status": inv["status"], "badgeType": inv["badgeType"], "months": inv["months"],
"amount": inv["amount"], "currency": inv["currency"],
"expiresAt": inv["expiresAt"],
}
for k in ("amountPaid", "cryptoAmountPaid", "cryptoAmountDue", "settledAt"):
if inv.get(k) is not None:
view[k] = inv[k]
if inv.get("paidInFull") is not None:
view["paidInFull"] = inv["paidInFull"]
if inv["method"] == "card":
view["clientSecret"] = inv["clientSecret"]
else:
view["address"] = inv["address"]
view["cryptoAmount"] = inv["cryptoAmount"]
view["cryptoCurrency"] = inv["method"]
view["requiredConfirmations"] = REQUIRED_CONFIRMATIONS
return view
def with_publishable_key(html):
"""The publishable key is compiled into the page. Here it comes
from the environment, so nothing that could be a real key is ever written
back into public/index.html. Unset leaves the committed empty value, which
is what selects the development stand-in."""
if not STRIPE_PUBLISHABLE_KEY:
return html
text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode())
if found != 1:
raise RuntimeError("mock: the shell has no stripe-publishable-key meta element to fill")
return text.encode()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def _send(self, status, payload, ctype="application/json"):
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
try:
self.send_response(status)
self.send_header("content-type", ctype)
self.send_header("content-length", str(len(body)))
self.send_header("cache-control", "no-store")
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
# A client that drops during a long poll leaves the invoice untouched and the page
# reissues on its next load, so there is nothing to retry.
self.close_connection = True
def _read_json(self):
length = int(self.headers.get("content-length") or 0)
try:
return json.loads(self.rfile.read(length) or b"{}")
except Exception:
return None
def do_POST(self):
path = urlparse(self.path).path
if path.startswith("/control/"):
parts = path.strip("/").split("/")
if len(parts) != 3:
return self._send(400, {"error": "bad_request"})
_, action, invoice_id = parts
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
if action == "settle":
inv["status"] = "paid"
inv["amountPaid"] = inv["amount"]
inv["settledAt"] = now_iso()
inv["paidInFull"] = True
if inv["method"] != "card":
inv["cryptoAmountPaid"] = inv["cryptoAmount"]
inv["cryptoAmountDue"] = "0.000"
elif action == "expire":
inv["status"] = "expired"
elif action == "confirming":
inv["amountPaid"] = inv["amount"]
inv["paidInFull"] = True
if inv["method"] != "card":
inv["cryptoAmountPaid"] = inv["cryptoAmount"]
inv["cryptoAmountDue"] = "0.000"
elif action == "verdict":
# Monero reports the payment as paid in full while its figures are still zero.
inv["paidInFull"] = True
elif action == "partial":
inv["amountPaid"] = inv["amount"] // 2
inv["paidInFull"] = False
if inv["method"] != "card":
inv["cryptoAmountPaid"] = "0.734"
inv["cryptoAmountDue"] = "0.752"
else:
return self._send(400, {"error": "bad_request"})
status = inv["status"]
old_event = swap_event(invoice_id)
if old_event is not None:
old_event.set()
return self._send(200, {"ok": True, "status": status})
if path.startswith("/api/invoice/") and path.endswith("/cancel"):
invoice_id = path[len("/api/invoice/"):-len("/cancel")]
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
if inv["status"] != "open":
return self._send(409, {"error": "not_open"})
if payment_mark(inv) != ("", False) or inv.get("amountPaid"):
# Cancelling a funded invoice would strand what the buyer already sent.
return self._send(409, {"error": "funded"})
inv["status"] = "expired"
payload = {"invoiceId": invoice_id, **public_view(inv)}
old_event = swap_event(invoice_id)
if old_event is not None:
old_event.set()
return self._send(200, payload)
if path == "/api/invoice":
body = self._read_json()
if not body or not isinstance(body.get("codeHash"), str) or not body["codeHash"]:
return self._send(400, {"error": "bad_request"})
if body.get("method") not in ("card", "btc", "xmr"):
return self._send(400, {"error": "bad_request"})
with LOCK:
if body["codeHash"] in HASHES:
return self._send(409, {"error": "code_conflict"})
t = total(body.get("priceId"), body.get("offerId"))
if t is None:
return self._send(400, {"error": "catalog_changed"})
invoice_id = secrets.token_urlsafe(16)
is_card = body["method"] == "card"
inv = {
"invoiceId": invoice_id, "method": body["method"], "status": "open",
"badgeType": CATALOG[body["priceId"]]["badgeType"], "months": t["months"],
"amount": t["amount"], "currency": "usd",
"expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1))
.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"clientSecret": f"cs_test_{secrets.token_hex(12)}" if is_card else None,
"address": None if is_card else ADDRESSES[body["method"]],
"cryptoAmount": None if is_card else "1.482",
}
INVOICES[invoice_id] = inv
HASHES[body["codeHash"]] = invoice_id
EVENTS[invoice_id] = threading.Event()
payload = {"invoiceId": invoice_id, **public_view(inv)}
return self._send(200, payload)
return self._send(405, {"error": "bad_request"})
def do_GET(self):
parsed = urlparse(self.path)
# An empty `seenPaid=` means the page rendered no figure, which differs from the parameter
# being absent, so blank values are kept.
path, query = parsed.path, parse_qs(parsed.query, keep_blank_values=True)
if path.startswith("/api/invoice/"):
invoice_id = path[len("/api/invoice/"):]
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
current = inv["status"]
held = payment_mark(inv)
event = EVENTS.get(invoice_id)
wait = (query.get("wait") or [None])[0]
# A payment recorded before this request arrived cannot fire the event this request
# would wait on, so holding then would strand the buyer on the payment screen.
seen = ((query.get("seenPaid") or [""])[0], (query.get("seenFull") or ["0"])[0] == "1")
unseen = "seenPaid" in query and seen != held
if wait is not None and wait == current and not unseen and event is not None:
# The event is never cleared; settle, expire and partial replace it with a fresh
# one under the lock, so a set event always means a change happened after this
# reference was taken.
event.wait(timeout=HOLD_SECONDS)
with LOCK:
inv = INVOICES[invoice_id]
payload = {"invoiceId": invoice_id, **public_view(inv)}
return self._send(200, payload)
rel = "index.html" if path == "/" else path.lstrip("/")
for base in ("public", "dist"):
base_resolved = (ROOT / base).resolve()
candidate = (base_resolved / rel).resolve()
try:
candidate.relative_to(base_resolved)
except ValueError:
# The path escaped this base directory, so move to the next one.
continue
if candidate.is_file():
ctype = MIME.get(candidate.suffix, "application/octet-stream")
body = candidate.read_bytes()
if candidate.name == "index.html":
body = with_publishable_key(body)
return self._send(200, body, ctype)
return self._send(404, {"error": "not_found"})
class Server(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address):
# socketserver's default prints a traceback for a dropped client, which is routine under
# a long poll, so suppress it while letting anything else surface.
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
def main():
port = 8099
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
if STRIPE_PUBLISHABLE_KEY and not STRIPE_PUBLISHABLE_KEY.startswith("pk_"):
# A secret or restricted key (sk_, rk_) would be written into the page for anyone to read,
# so only a publishable key (pk_) is allowed.
sys.exit("mock: STRIPE_PUBLISHABLE_KEY must be a publishable key (pk_...)")
server = Server(("127.0.0.1", port), Handler)
print(f"mock badge service on http://localhost:{port}", flush=True)
print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form"
if STRIPE_PUBLISHABLE_KEY
else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"),
flush=True)
server.serve_forever()
if __name__ == "__main__":
main()