From 3d4d7251a5fbbb4f8ee88736704e92abe6c49bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 10 Jul 2026 20:09:35 +0200 Subject: [PATCH] bank: demo-withdraw API mints one-shot withdrawals from explorer pool --- scripts/taler-bank/demo-withdraw-api.py | 172 ++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100755 scripts/taler-bank/demo-withdraw-api.py diff --git a/scripts/taler-bank/demo-withdraw-api.py b/scripts/taler-bank/demo-withdraw-api.py new file mode 100755 index 0000000..2b175e5 --- /dev/null +++ b/scripts/taler-bank/demo-withdraw-api.py @@ -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()