#!/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()