256 lines
8.6 KiB
Python
Executable file
256 lines
8.6 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:
|
|
# Shown to the user: goa-account-<random> (bank-safe [a-z0-9-])
|
|
alphabet = string.ascii_lowercase + string.digits
|
|
rnd = "".join(secrets.choice(alphabet) for _ in range(12))
|
|
return "goa-account-" + rnd
|
|
|
|
|
|
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 = _rand_username()
|
|
password = _rand_password()
|
|
# Display name mirrors username for webui clarity
|
|
name = username
|
|
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()
|
|
name = username
|
|
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()
|