mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +00:00
* 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>
324 lines
14 KiB
Python
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()
|