#!/bin/bash # Generate public landing stats JSON for bank.hacktivism.ch intro page. # # *** Run INSIDE the bank container *** (taler-hacktivism-bank). # Writes: $LANDING_DIR/stats.json → https://bank.hacktivism.ch/intro/stats.json # # Pure bash + curl + awk (no python). Times in Europe/Zurich (CET/CEST). # # Documented: configs/bank-landing/README.md set -euo pipefail LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" # Demo funding account (for recent withdraw list / flow) BANK_USER="${BANK_USER:-explorer}" # Admin lists all accounts + can read other accounts' txs ADMIN_USER="${ADMIN_USER:-admin}" # Per-account transaction window (libeufin delta). Was -100 → systematically # undercounted credits/withdraws on active accounts (broken public stats). TX_DELTA="${TX_DELTA:--50000}" # Max accounts to list + scan (was 80 → missed later accounts; bank has 100+). MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-500}" # Account-list page size for GET /accounts?delta=… (must cover all users) ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--500}" # curl timeout per account (deeper history needs more headroom) TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-25}" export TZ="${TZ:-Europe/Zurich}" PASS="${BANK_PASS:-}" ADMIN_PASS="${BANK_ADMIN_PASS:-}" detect_bank() { if [ -n "${BANK_URL:-}" ]; then echo "${BANK_URL%/}" return fi local port="" conf u code for conf in /etc/libeufin/bank-overrides.conf /etc/libeufin/libeufin-bank.conf; do if [ -f "$conf" ]; then port=$(grep -E '^\s*PORT\s*=' "$conf" 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}') [ -n "$port" ] && break fi done port="${port:-9012}" for u in "http://127.0.0.1:${port}" "http://127.0.0.1:9012" "http://127.0.0.1:8080"; do code=$(curl -sS -m 2 -o /dev/null -w '%{http_code}' "$u/config" 2>/dev/null || echo 000) if [ "$code" = "200" ]; then echo "$u" return fi done echo "http://127.0.0.1:${port}" } BANK="$(detect_bank)" BANK="${BANK%/}" read_pass_file() { local f for f in "$@"; do if [ -f "$f" ] && [ -r "$f" ]; then tr -d '\n' <"$f" return 0 fi done return 1 } if [ -z "$PASS" ]; then PASS=$(read_pass_file \ "/root/bank-${BANK_USER}-password.txt" \ /root/bank-explorer-password.txt \ "/etc/libeufin/secrets/bank-${BANK_USER}-password.txt" || true) fi if [ -z "$ADMIN_PASS" ]; then ADMIN_PASS=$(read_pass_file \ /root/bank-admin-password.txt \ /etc/libeufin/secrets/bank-admin-password.txt || true) fi WORKDIR=$(mktemp -d) trap 'rm -rf "$WORKDIR"' EXIT OUT="$LANDING_DIR/stats.json" RUN="$LANDING_DIR/stats-run.json" TMP="${OUT}.tmp.$$" mkdir -p "$LANDING_DIR" now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } now_unix() { date +%s; } now_human() { date +"%Y-%m-%d %H:%M %Z"; } iso_from_unix() { local u="$1" date -d "@${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ || date -r "${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \ || echo "" } # Human CEST/CET label for display: "2026-07-09 20:49 CEST" human_from_unix() { local u="$1" date -d "@${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ || date -r "${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \ || echo "" } json_str() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ')" } # Public run status for intro page. Never wipe stats.json on failure. write_run() { local ok_json="$1" msg="${2:-}" cat >"$RUN" </dev/null || true echo "error: $msg (stats.json left unchanged; stats-run.json updated)" >&2 } token_for() { local user="$1" pass="$2" out="$3" echo '{"scope":"readonly"}' >"$WORKDIR/tok-req.json" curl -sS -m 12 -u "${user}:${pass}" \ -H 'Content-Type: application/json' \ -d @"$WORKDIR/tok-req.json" \ "${BANK}/accounts/${user}/token" >"$out" || true awk -F'"' '/access_token/ { for (i=1;i<=NF;i++) if ($i=="access_token") { print $(i+2); exit } }' "$out" 2>/dev/null || true } extract_field() { # extract "key":"value" or "key": number from a JSON blob (first hit) local key="$1" file="$2" awk -v key="$key" ' function between(s, k, p, rest, q2, r) { p = index(s, "\"" k "\"") if (p == 0) return "" rest = substr(s, p + length(k) + 2) while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) if (substr(rest, 1, 1) == "\"") { rest = substr(rest, 2) q2 = index(rest, "\"") if (q2 == 0) return "" return substr(rest, 1, q2 - 1) } r = "" while (rest ~ /^[0-9]/) { r = r substr(rest, 1, 1) rest = substr(rest, 2) } return r } { print between($0, key); exit } ' "$file" 2>/dev/null } # --- tokens --- if [ -z "$PASS" ] && [ -z "$ADMIN_PASS" ]; then write_err "no bank password (explorer or admin) for stats" exit 1 fi ADMIN_TOKEN="" if [ -n "$ADMIN_PASS" ]; then ADMIN_TOKEN=$(token_for "$ADMIN_USER" "$ADMIN_PASS" "$WORKDIR/admin-tok.json") fi USER_TOKEN="" if [ -n "$PASS" ]; then USER_TOKEN=$(token_for "$BANK_USER" "$PASS" "$WORKDIR/user-tok.json") fi # Prefer admin for everything when available AUTH_TOKEN="${ADMIN_TOKEN:-$USER_TOKEN}" if [ -z "$AUTH_TOKEN" ]; then write_err "token failed (admin/explorer)" exit 1 fi # --- account list --- ACCOUNTS_N=0 ACCOUNTS_USERS=0 : >"$WORKDIR/usernames.txt" if [ -n "$ADMIN_TOKEN" ]; then # Use a large negative delta so we list *all* accounts (delta=-80 truncated # the roster and undercounted bank_accounts + flow). curl -sS -m 30 -H "Authorization: Bearer ${ADMIN_TOKEN}" \ "${BANK}/accounts?delta=${ACCOUNTS_DELTA}" >"$WORKDIR/accounts.json" || true # usernames from "username":"..." tr -d '\n' <"$WORKDIR/accounts.json" \ | sed 's/},{/}\n{/g' \ | while IFS= read -r line; do u=$(printf '%s' "$line" | sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') [ -n "$u" ] && echo "$u" done >"$WORKDIR/usernames.txt" || true ACCOUNTS_N=$(grep -cve '^\s*$' "$WORKDIR/usernames.txt" 2>/dev/null || echo 0) ACCOUNTS_USERS=$(grep -Eve '^(admin|exchange)$' "$WORKDIR/usernames.txt" 2>/dev/null | grep -cve '^\s*$' || echo 0) else echo "$BANK_USER" >"$WORKDIR/usernames.txt" ACCOUNTS_N=1 ACCOUNTS_USERS=1 fi # --- scan txs: distinguish incoming (credit) vs withdraw (Taler debit) --- # all-wd.tsv: amount \t t_s \t subject \t username \t reserve # all-in.tsv: amount \t t_s \t subject \t username # flow.tsv: kind \t amount_num kind = incoming | withdraw | other_out : >"$WORKDIR/all-wd.tsv" : >"$WORKDIR/all-in.tsv" : >"$WORKDIR/flow.tsv" SCAN_OK=0 SCAN_EMPTY=0 SCAN_FAIL=0 # Scan customer accounts only (not exchange/admin — exchange credits would double-count) { echo "$BANK_USER" grep -Eve "^(admin|exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true } | awk 'NF && !seen[$0]++' | head -n "$MAX_SCAN_ACCOUNTS" >"$WORKDIR/scan-users.txt" while IFS= read -r uname; do [ -n "$uname" ] || continue case "$uname" in admin|exchange) continue ;; esac # Safe filename (usernames are mostly [A-Za-z0-9_-]) safe=$(printf '%s' "$uname" | tr -c 'A-Za-z0-9._-' '_') code=$(curl -sS -m "${TX_CURL_TIMEOUT}" -o "$WORKDIR/tx-${safe}.json" -w '%{http_code}' \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ "${BANK}/accounts/${uname}/transactions?delta=${TX_DELTA}" 2>/dev/null || echo 000) # 204 / empty body: account with no transactions — normal, not an error if [ "$code" = "204" ] || [ ! -s "$WORKDIR/tx-${safe}.json" ]; then SCAN_EMPTY=$((SCAN_EMPTY + 1)) continue fi if [ "$code" != "200" ]; then SCAN_FAIL=$((SCAN_FAIL + 1)) continue fi SCAN_OK=$((SCAN_OK + 1)) tr -d '\n' <"$WORKDIR/tx-${safe}.json" \ | sed 's/},{/}\n{/g' \ >"$WORKDIR/tx-lines.txt" 2>/dev/null || continue awk -v uname="$uname" -v flowf="$WORKDIR/flow.tsv" -v inf="$WORKDIR/all-in.tsv" ' function between(s, key, p, rest, q2, r) { p = index(s, "\"" key "\"") if (p == 0) return "" rest = substr(s, p + length(key) + 2) while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2) if (substr(rest, 1, 1) == "\"") { rest = substr(rest, 2) q2 = index(rest, "\"") if (q2 == 0) return "" return substr(rest, 1, q2 - 1) } r = "" while (rest ~ /^[0-9]/) { r = r substr(rest, 1, 1) rest = substr(rest, 2) } return r } function amt_n(a, p) { p = index(a, ":") if (p == 0) return 0 return substr(a, p + 1) + 0 } { line = $0 dir = between(line, "direction") amt = between(line, "amount") ts = between(line, "t_s") subj = between(line, "subject") low = tolower(subj) if (amt == "") next n = amt_n(amt) if (dir == "credit") { # Incoming: bank credits into customer accounts (admin top-up, transfers in) print "incoming\t" n >> flowf printf "%s\t%s\t%s\t%s\n", amt, ts, subj, uname >> inf } else if (dir == "debit" && index(low, "withdraw") > 0) { # Withdraw: Taler withdrawal debit → exchange → wallet coins print "withdraw\t" n >> flowf res = subj sub(/^.*[Ww]ithdrawal[ ]+/, "", res) gsub(/[^A-Za-z0-9]/, "", res) printf "%s\t%s\t%s\t%s\t%s\n", amt, ts, subj, uname, res } else if (dir == "debit") { # Other debits (non-withdraw transfers) print "other_out\t" n >> flowf } } ' "$WORKDIR/tx-lines.txt" >>"$WORKDIR/all-wd.tsv" 2>/dev/null || true done <"$WORKDIR/scan-users.txt" # Sum by kind TOTAL_IN_N=$(awk -F'\t' '$1=="incoming"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) TOTAL_WD_FLOW_N=$(awk -F'\t' '$1=="withdraw"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) TOTAL_OTHER_OUT_N=$(awk -F'\t' '$1=="other_out"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) N_INCOMING=$(awk -F'\t' '$1=="incoming"{c++} END{print c+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0) # total out = withdraw + other debits TOTAL_OUT_N=$(awk -v a="${TOTAL_WD_FLOW_N:-0}" -v b="${TOTAL_OTHER_OUT_N:-0}" 'BEGIN{printf "%.8f", a+b}') # Sort by t_s descending sort -t$'\t' -k2,2nr "$WORKDIR/all-wd.tsv" -o "$WORKDIR/all-wd-sorted.tsv" 2>/dev/null \ || cp "$WORKDIR/all-wd.tsv" "$WORKDIR/all-wd-sorted.tsv" sort -t$'\t' -k2,2nr "$WORKDIR/all-in.tsv" -o "$WORKDIR/all-in-sorted.tsv" 2>/dev/null \ || cp "$WORKDIR/all-in.tsv" "$WORKDIR/all-in-sorted.tsv" # Unique reserves = individual wallet withdraws (each wallet reserve_pub) WALLETS_N=$(awk -F'\t' '$5!=""{print $5}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) # Accounts that funded at least one withdraw ACCOUNTS_WITH_WD=$(awk -F'\t' '$4!=""{print $4}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0) # Aggregates NOW=$(now_unix) GEN_ISO=$(now_iso) GEN_HUMAN=$(date +"%Y-%m-%d %H:%M %Z") # Build recent withdraws JSON array (up to 10) with CEST times — landing shows all 10 RECENT_WD_N="${RECENT_WD_N:-10}" : >"$WORKDIR/recent.jsonl" N_WD=0 TOTAL_WD=0 W24=0; N24=0; W7=0; N7=0 DAY=$((NOW - 86400)) WEEK=$((NOW - 7 * 86400)) LAST_AMT=""; LAST_TS=""; LAST_SUBJ="" while IFS=$'\t' read -r amt ts subj uname res; do [ -n "$amt" ] || continue n=$(printf '%s' "$amt" | awk -F: '{print $2+0}') N_WD=$((N_WD + 1)) TOTAL_WD=$(awk -v a="$TOTAL_WD" -v b="$n" 'BEGIN{printf "%.8f", a+b}') ts_n=${ts:-0} if [ "$ts_n" -ge "$DAY" ] 2>/dev/null; then W24=$(awk -v a="$W24" -v b="$n" 'BEGIN{printf "%.8f", a+b}') N24=$((N24 + 1)) fi if [ "$ts_n" -ge "$WEEK" ] 2>/dev/null; then W7=$(awk -v a="$W7" -v b="$n" 'BEGIN{printf "%.8f", a+b}') N7=$((N7 + 1)) fi if [ -z "$LAST_AMT" ]; then LAST_AMT=$amt LAST_TS=$ts_n LAST_SUBJ=$subj fi if [ "$N_WD" -le "$RECENT_WD_N" ]; then at_h=""; at_iso="" if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then at_h=$(human_from_unix "$ts_n") at_iso=$(iso_from_unix "$ts_n") fi printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" "$res" >>"$WORKDIR/recent.jsonl" fi done <"$WORKDIR/all-wd-sorted.tsv" fmt_goa() { awk -v n="$1" 'BEGIN{ if (n+0 == int(n+0)) printf "GOA:%d", int(n+0); else printf "GOA:%.8g", n+0; }' } TOTAL_AMT=$(fmt_goa "$TOTAL_WD") TOTAL_IN_AMT=$(fmt_goa "${TOTAL_IN_N:-0}") TOTAL_OUT_AMT=$(fmt_goa "${TOTAL_OUT_N:-0}") TOTAL_WD_FLOW_AMT=$(fmt_goa "${TOTAL_WD_FLOW_N:-0}") TOTAL_OTHER_OUT_AMT=$(fmt_goa "${TOTAL_OTHER_OUT_N:-0}") W24_AMT=$(fmt_goa "$W24") W7_AMT=$(fmt_goa "$W7") LAST_AT_H=""; LAST_AT_ISO="" if [ -n "${LAST_TS:-}" ] && [ "$LAST_TS" != "0" ]; then LAST_AT_H=$(human_from_unix "$LAST_TS") LAST_AT_ISO=$(iso_from_unix "$LAST_TS") fi # recent_withdraws JSON RECENT_JSON="[" first=1 while IFS=$'\t' read -r amt ts_n at_h at_iso uname res; do [ -n "$amt" ] || continue if [ "$first" = 1 ]; then first=0; else RECENT_JSON="${RECENT_JSON},"; fi RECENT_JSON="${RECENT_JSON} { \"kind\": \"withdraw\", \"amount\": $(json_str "$amt"), \"at\": $(json_str "$at_h"), \"at_iso\": $(json_str "$at_iso"), \"at_unix\": ${ts_n:-null}, \"account\": $(json_str "$uname"), \"reserve\": $(json_str "$res") }" done <"$WORKDIR/recent.jsonl" RECENT_JSON="${RECENT_JSON} ]" # recent incoming (up to 5) : >"$WORKDIR/recent-in.jsonl" N_IN_LIST=0 while IFS=$'\t' read -r amt ts subj uname; do [ -n "$amt" ] || continue N_IN_LIST=$((N_IN_LIST + 1)) [ "$N_IN_LIST" -le 5 ] || break ts_n=${ts:-0} at_h=""; at_iso="" if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then at_h=$(human_from_unix "$ts_n") at_iso=$(iso_from_unix "$ts_n") fi printf '%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" >>"$WORKDIR/recent-in.jsonl" done <"$WORKDIR/all-in-sorted.tsv" RECENT_IN_JSON="[" first=1 while IFS=$'\t' read -r amt ts_n at_h at_iso uname; do [ -n "$amt" ] || continue if [ "$first" = 1 ]; then first=0; else RECENT_IN_JSON="${RECENT_IN_JSON},"; fi RECENT_IN_JSON="${RECENT_IN_JSON} { \"kind\": \"incoming\", \"amount\": $(json_str "$amt"), \"at\": $(json_str "$at_h"), \"at_iso\": $(json_str "$at_iso"), \"at_unix\": ${ts_n:-null}, \"account\": $(json_str "$uname") }" done <"$WORKDIR/recent-in.jsonl" RECENT_IN_JSON="${RECENT_IN_JSON} ]" # Balance of explorer (optional display not required in footer) BALANCE="GOA:0" if [ -n "${USER_TOKEN:-$AUTH_TOKEN}" ]; then curl -sS -m 10 -H "Authorization: Bearer ${USER_TOKEN:-$AUTH_TOKEN}" \ "${BANK}/accounts/${BANK_USER}" >"$WORKDIR/acct.json" || true BALANCE=$(awk -F'"' '/"amount"/ { for (i=1;i<=NF;i++) if ($i=="amount") { print $(i+2); exit } }' "$WORKDIR/acct.json" 2>/dev/null || echo "GOA:0") fi # Demo block kept for page QR logic only (not shown in footer) DEMO_URI=""; DEMO_AMT=""; DEMO_CREATED=""; DEMO_WID=""; DEMO_STATUS="" [ -f "$LANDING_DIR/withdraw.uri" ] && DEMO_URI=$(tr -d '\n' <"$LANDING_DIR/withdraw.uri") [ -f "$LANDING_DIR/withdraw.amount" ] && DEMO_AMT=$(tr -d '\n' <"$LANDING_DIR/withdraw.amount") [ -f "$LANDING_DIR/withdraw.created" ] && DEMO_CREATED=$(tr -d '\n' <"$LANDING_DIR/withdraw.created") if [ -n "$DEMO_URI" ]; then DEMO_WID=$(basename "$DEMO_URI") curl -sS -m 8 \ "${BANK}/taler-integration/withdrawal-operation/${DEMO_WID}" \ >"$WORKDIR/demo-wd.json" 2>/dev/null || true DEMO_STATUS=$(awk -F'"' '/"status"/ { for (i=1;i<=NF;i++) if ($i=="status") { print $(i+2); exit } }' "$WORKDIR/demo-wd.json" 2>/dev/null || true) fi DEMO_READY=false case "$DEMO_STATUS" in pending|selected) DEMO_READY=true ;; "") [ -n "$DEMO_URI" ] && DEMO_READY=true ;; esac [ "$DEMO_STATUS" = "confirmed" ] && DEMO_READY=false [ "$DEMO_STATUS" = "aborted" ] && DEMO_READY=false # Sanity: never publish an empty-looking success if admin scan should have accounts if [ -n "$ADMIN_TOKEN" ] && [ "${ACCOUNTS_N:-0}" = "0" ]; then write_err "accounts list empty after admin scan" exit 1 fi # Live performance probes (same idea as exchange landing-stats) # Milliseconds as integer, rounded (not truncated) so sub-ms loopback does not # always show 0 when we fall back to in-container URLs. measure_ms() { local url="$1" t t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") [ -z "$t" ] && { echo "null"; return; } awk -v t="$t" 'BEGIN{ ms = (t+0)*1000 if (ms > 0 && ms < 1) ms = 1 printf "%d", int(ms + 0.5) }' } num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } # Prefer public URL for real client latency (Caddy → bank); fall back to loopback. # Integration used to probe only $BANK (127.0.0.1:9012) → ~0–1 ms and not comparable # to /config which already used the public host. BANK_PUBLIC_BASE="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}" CONFIG_MS=$(measure_ms "${BANK_PUBLIC_BASE}/config") CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/config" 2>/dev/null || echo "000") if [ "$CONFIG_HTTP" != "200" ]; then CONFIG_MS=$(measure_ms "${BANK}/config") CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/config" 2>/dev/null || echo "000") fi INT_MS=$(measure_ms "${BANK_PUBLIC_BASE}/taler-integration/config") INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/taler-integration/config" 2>/dev/null || echo "000") if [ "$INT_HTTP" != "200" ]; then INT_MS=$(measure_ms "${BANK}/taler-integration/config") INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/taler-integration/config" 2>/dev/null || echo "000") fi WEBUI_MS=$(measure_ms "${BANK_PUBLIC_BASE}/webui/") WEBUI_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") LOADAVG="" [ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) MEM_JSON='"container_rss_human": "—"' MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" if [ -f "$MEM_HELPER" ]; then # shellcheck disable=SC1090 . "$MEM_HELPER" mem_snapshot_json || true fi cat >"$TMP" < $OUT"