docs: new root as prior history lost (orphan + GC); ~215 commits not recoverable
This commit is contained in:
commit
96961f23f5
268 changed files with 24161 additions and 0 deletions
316
scripts/taler-bank/demo-withdraw-api.py
Executable file
316
scripts/taler-bank/demo-withdraw-api.py
Executable file
|
|
@ -0,0 +1,316 @@
|
|||
#!/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("/")
|
||||
# Public HTTPS base (Caddy) — absolute webui / login links
|
||||
BANK_PUBLIC = os.environ.get("BANK_PUBLIC", "https://bank.hacktivism.ch").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 public_webui_url() -> str:
|
||||
return f"{BANK_PUBLIC}/webui/"
|
||||
|
||||
|
||||
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]
|
||||
# Keep host:port from libeufin (e.g. bank.hacktivism.ch:443). Stripping :443
|
||||
# breaks taler-integration withdraw links / main landing QR on HTTPS banks.
|
||||
uri = str(uri).strip()
|
||||
LANDING.mkdir(parents=True, exist_ok=True)
|
||||
(LANDING / "withdraw.uri").write_text(uri + "\n")
|
||||
(LANDING / "withdraw.amount").write_text(AMOUNT + "\n")
|
||||
(LANDING / "withdraw.created").write_text(
|
||||
time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()) + "\n"
|
||||
)
|
||||
watch_withdrawal(wid)
|
||||
return {
|
||||
"ok": True,
|
||||
"taler_withdraw_uri": uri,
|
||||
"withdrawal_id": wid,
|
||||
"amount": AMOUNT,
|
||||
"pool_account": USER,
|
||||
"taler_integration_base": f"{BANK_PUBLIC}/taler-integration/",
|
||||
"hint": "Open in GNU Taler Wallet (iOS/Android/desktop). No bank registration.",
|
||||
"created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()),
|
||||
}
|
||||
|
||||
|
||||
def watch_withdrawal(wid: str) -> None:
|
||||
"""Queue withdrawal_id for auto-confirm loop (shared pool + personal)."""
|
||||
if not wid:
|
||||
return
|
||||
LANDING.mkdir(parents=True, exist_ok=True)
|
||||
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(str(wid).strip())
|
||||
watch.write_text("\n".join(sorted(ids)) + "\n")
|
||||
|
||||
|
||||
|
||||
# Funny stems for usernames (bank-safe [a-z0-9-]).
|
||||
# Source: hand-curated in this file only — not scraped from the web.
|
||||
# Keep culture-neutral: light space / physics wordplay, no animals, foods,
|
||||
# body parts, religion, politics, or slang that can offend.
|
||||
_FUNNY_STEMS = (
|
||||
"nebula-nudge",
|
||||
"orbit-echo",
|
||||
"voidwave-vibe",
|
||||
"comet-crumb",
|
||||
"quark-pulse",
|
||||
"plasma-spark",
|
||||
"astro-glint",
|
||||
"lunar-loop",
|
||||
"warp-ripple",
|
||||
"photon-bloom",
|
||||
"galaxy-drift",
|
||||
"rocket-ribbon",
|
||||
"satellite-swirl",
|
||||
"meteor-mint",
|
||||
"stardust-swirl",
|
||||
"hyperdrive-hum",
|
||||
"cosmic-coral",
|
||||
"space-spark",
|
||||
"nova-nibble",
|
||||
"aurora-arc",
|
||||
"solar-swish",
|
||||
"pulsar-pop",
|
||||
"comet-cloud",
|
||||
"orbit-opal",
|
||||
"zenith-zip",
|
||||
"eclipse-echo",
|
||||
"horizon-hum",
|
||||
"starlight-step",
|
||||
)
|
||||
|
||||
|
||||
def _rand_username_and_name() -> tuple[str, str]:
|
||||
# Shown as goa-account-<funnypiece>-<tag> (e.g. goa-account-space-potato-k3m9x)
|
||||
stem = secrets.choice(_FUNNY_STEMS)
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
tag = "".join(secrets.choice(alphabet) for _ in range(5))
|
||||
username = f"goa-account-{stem}-{tag}"
|
||||
name = username
|
||||
return username, name
|
||||
|
||||
|
||||
def _rand_password() -> str:
|
||||
# Embed "pleasechangeme" with random material before and after.
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
prefix = "".join(secrets.choice(alphabet) for _ in range(4))
|
||||
suffix = "".join(secrets.choice(alphabet) for _ in range(6))
|
||||
return prefix + "pleasechangeme" + suffix
|
||||
|
||||
|
||||
def create_personal_account() -> dict:
|
||||
"""Public registration: auto username/password, balance starts at 0."""
|
||||
username, name = _rand_username_and_name()
|
||||
password = _rand_password()
|
||||
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, name = _rand_username_and_name()
|
||||
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}")
|
||||
webui = public_webui_url()
|
||||
# Same shared-pool withdraw as step 2 (explorer + auto-confirm)
|
||||
wd = mint_withdraw()
|
||||
withdraw_uri = wd["taler_withdraw_uri"]
|
||||
return {
|
||||
"ok": True,
|
||||
"created_for_you": True,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"name": name,
|
||||
"display_name": name,
|
||||
"balance": "GOA:0",
|
||||
"balance_note": "Starts at zero — not the shared community pool.",
|
||||
"taler_withdraw_uri": withdraw_uri,
|
||||
"withdrawal_id": wd["withdrawal_id"],
|
||||
"withdraw_amount": wd.get("amount") or AMOUNT,
|
||||
"pool_account": wd.get("pool_account") or USER,
|
||||
"qr_payload": withdraw_uri,
|
||||
"webui": webui,
|
||||
"account_url": webui,
|
||||
"login_url": webui,
|
||||
"hint": (
|
||||
f"Login at {webui} with username {username} and the password shown "
|
||||
"(not stored for recovery). Wallet QR is taler://withdraw/… from the "
|
||||
f"shared pool ({USER}), same as step 2."
|
||||
),
|
||||
"created": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()),
|
||||
"created_human": time.strftime("%Y-%m-%d %H:%M %Z", time.localtime()),
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue