koopa-admin-log/scripts/taler-bank/demo-withdraw-api.py
Hernâni Marques bb7bd5e73c
bank: auto-account username prefix goa-bank-acc-<id>
Personal auto-accounts use usernames of the form goa-bank-acc- plus a random
hex id so they are easy to spot in admin lists and distinct from explorer
or hand-registered users.
2026-07-10 20:43:45 +02:00

254 lines
8.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""
HTTP helper for bank landing:
- GET /demo-withdraw.json — mint shared-pool (explorer) demo withdraw
- GET /auto-account.json — create a personal bank account (balance 0)
and return one-time credentials for the user to copy
Listens on 127.0.0.1:19096 (only inside bank container / localhost).
Nginx proxies /intro/*.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 secrets
import ssl
import string
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()),
}
def _rand_username() -> str:
# goa-bank-acc-<uuid> — unique guest id, bank-safe [a-z0-9-]
return "goa-bank-acc-" + secrets.token_hex(8)
def _rand_password() -> str:
# readable enough to type; no ambiguous 0/O/1/l
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
return "".join(secrets.choice(alphabet) for _ in range(14))
def create_personal_account() -> dict:
"""Public registration: auto username/password, balance starts at 0."""
username = _rand_username()
password = _rand_password()
# short label from id suffix
suffix = username.rsplit("-", 1)[-1][:8]
name = "GOA bank acc " + suffix
code, body = http_json(
"POST",
f"{BANK}/accounts",
{
"username": username,
"password": password,
"name": name,
},
)
if code not in (200, 201, 204):
# retry once on conflict
if code in (409, 400):
username = _rand_username()
suffix = username.rsplit("-", 1)[-1][:8]
name = "GOA bank acc " + suffix
code, body = http_json(
"POST",
f"{BANK}/accounts",
{
"username": username,
"password": password,
"name": name,
},
)
if code not in (200, 201, 204):
raise RuntimeError(f"register failed HTTP {code}: {body}")
payto = ""
if isinstance(body, dict):
payto = body.get("internal_payto_uri") or body.get("payto_uri") or ""
if not payto:
payto = f"payto://x-taler-bank/bank.hacktivism.ch/{username}?receiver-name={username}"
return {
"ok": True,
"created_for_you": True,
"username": username,
"password": password,
"name": name,
"balance": "GOA:0",
"balance_note": "Starts at zero — not the shared community pool.",
"payto_uri": payto,
"webui": "https://bank.hacktivism.ch/webui/",
"hint": (
"This bank account was created for you automatically. "
"Copy username and password now — we do not store them for later recovery. "
"Balance starts at GOA:0."
),
"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]
try:
if path in (
"/",
"/demo-withdraw.json",
"/intro/demo-withdraw.json",
):
body = mint_withdraw()
elif path in (
"/auto-account.json",
"/intro/auto-account.json",
):
body = create_personal_account()
else:
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
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()