bank: demo-withdraw API mints one-shot withdrawals from explorer pool

This commit is contained in:
Hernâni Marques 2026-07-10 20:09:35 +02:00
parent 468fdb5fe1
commit 3d4d7251a5
No known key found for this signature in database

View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
HTTP helper for bank landing: mint a fresh demo withdrawal from the shared
explorer pool so app users get GOA without registering a bank account.
Listens on 127.0.0.1:19096 (only inside bank container / localhost).
Nginx proxies GET /intro/demo-withdraw.json this service.
Env:
BANK_URL default http://127.0.0.1:9012
BANK_USER default explorer
BANK_PASS or /root/bank-explorer-password.txt
AMOUNT default GOA:10
LANDING_DIR default /var/www/bank-landing
"""
from __future__ import annotations
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
BANK = os.environ.get("BANK_URL", "http://127.0.0.1:9012").rstrip("/")
USER = os.environ.get("BANK_USER", "explorer")
AMOUNT = os.environ.get("AMOUNT", "GOA:10")
LANDING = Path(os.environ.get("LANDING_DIR", "/var/www/bank-landing"))
LISTEN = ("127.0.0.1", int(os.environ.get("DEMO_WITHDRAW_PORT", "19096")))
def load_pass() -> str:
p = os.environ.get("BANK_PASS", "").strip()
if p:
return p
for f in (
Path(f"/root/bank-{USER}-password.txt"),
Path("/root/bank-explorer-password.txt"),
):
if f.is_file():
return f.read_text().strip()
raise RuntimeError("no BANK_PASS / explorer password file")
def http_json(method: str, url: str, body=None, headers=None, auth=None):
data = None if body is None else json.dumps(body).encode()
h = dict(headers or {})
if body is not None:
h["Content-Type"] = "application/json"
if auth:
import base64
token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
h["Authorization"] = f"Basic {token}"
req = urllib.request.Request(url, data=data, method=method, headers=h)
ctx = ssl.create_default_context()
try:
with urllib.request.urlopen(req, context=ctx, timeout=20) as r:
raw = r.read().decode()
return r.status, json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"raw": raw[:500]}
def mint_withdraw() -> dict:
pw = load_pass()
code, tok = http_json(
"POST",
f"{BANK}/accounts/{USER}/token",
{"scope": "readwrite", "refreshable": True},
auth=(USER, pw),
)
if code != 200 or not tok.get("access_token"):
raise RuntimeError(f"token failed HTTP {code}: {tok}")
access = tok["access_token"]
code, wd = http_json(
"POST",
f"{BANK}/accounts/{USER}/withdrawals",
{"suggested_amount": AMOUNT},
headers={"Authorization": f"Bearer {access}"},
)
if code not in (200, 201):
raise RuntimeError(f"withdrawal create HTTP {code}: {wd}")
uri = wd.get("taler_withdraw_uri") or ""
wid = wd.get("withdrawal_id") or ""
if not uri:
raise RuntimeError(f"no taler_withdraw_uri: {wd}")
if not wid:
wid = uri.rstrip("/").split("/")[-1]
# wallets often prefer no default HTTPS port in host
uri_clean = uri.replace(":443/", "/").replace(":443?", "?")
LANDING.mkdir(parents=True, exist_ok=True)
(LANDING / "withdraw.uri").write_text(uri_clean + "\n")
(LANDING / "withdraw.amount").write_text(AMOUNT + "\n")
(LANDING / "withdraw.created").write_text(
time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()) + "\n"
)
watch = LANDING / "withdraw-watch.ids"
ids = set()
if watch.is_file():
ids = {ln.strip() for ln in watch.read_text().splitlines() if ln.strip()}
ids.add(wid)
watch.write_text("\n".join(sorted(ids)) + "\n")
return {
"ok": True,
"taler_withdraw_uri": uri_clean,
"withdrawal_id": wid,
"amount": AMOUNT,
"pool_account": USER,
"hint": "Open in GNU Taler Wallet (iOS/Android/desktop). No bank registration.",
"created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
sys_stderr = __import__("sys").stderr
sys_stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Cache-Control", "no-store")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
path = self.path.split("?", 1)[0]
if path not in ("/", "/demo-withdraw.json", "/intro/demo-withdraw.json"):
self.send_response(404)
self._cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok":false,"error":"not found"}')
return
try:
body = mint_withdraw()
raw = json.dumps(body).encode()
self.send_response(200)
self._cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
except Exception as e:
raw = json.dumps({"ok": False, "error": str(e)}).encode()
self.send_response(500)
self._cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def main():
httpd = ThreadingHTTPServer(LISTEN, Handler)
print(f"demo-withdraw-api on http://{LISTEN[0]}:{LISTEN[1]}/", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()