#!/bin/bash # Run INSIDE taler-hacktivism. Writes /var/www/merchant-landing/stats.json # # Current taler-merchant schema: # merchant.merchant_instances → merchant_serial, merchant_id # merchant_instance_.* → per-instance tables # # IMPORTANT: # - cron uses a minimal PATH. Without /usr/sbin, runuser is missing and # every query used to fail silently → all zeros (and overwrote good data). # - Never write stats.json on failure: leave the previous good file in place. # - Avoid: psql -F$'\t' -v ON_ERROR_STOP=… (if -F loses the tab arg, -v is # eaten as fieldsep and ON_ERROR_STOP becomes the *username*). set -euo pipefail export TZ="${TZ:-Europe/Zurich}" export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" LANDING_DIR="${LANDING_DIR:-/var/www/merchant-landing}" OUT="$LANDING_DIR/stats.json" RUN="$LANDING_DIR/stats-run.json" TMP="${OUT}.tmp.$$" DB="${MERCHANT_DB:-taler-merchant}" ACTIVITY_LIMIT="${ACTIVITY_LIMIT:-12}" ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-merchant.err}" 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_human() { date +"%Y-%m-%d %H:%M %Z"; } json_str() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ' | sed 's/ */ /g')" } # Public run status for the intro page (never wipe stats.json on failure). write_run() { local ok_json="$1" msg="${2:-}" cat >"$RUN" <&2 rm -f "$TMP" /tmp/merch_cur_$$.tsv /tmp/merch_act_$$.tsv 2>/dev/null || true exit 1 } as_postgres() { if command -v runuser >/dev/null 2>&1; then runuser -u postgres -- "$@" elif command -v su >/dev/null 2>&1; then su -s /bin/bash postgres -c "$*" else return 127 fi } # pipe SQL on stdin — never -f on root-owned temps (postgres cannot read them) psql_pipe() { local fs=$'\t' ec set +e as_postgres psql -d "$DB" -At -F"$fs" 2>>"$ERRLOG" ec=$? set -e return "$ec" } # Required single-value query. Empty/fail → abort (no site write). psqlq() { local sql="$1" out ec set +e out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") ec=$? set -e if [ "$ec" -ne 0 ]; then abort "psql failed (ec=$ec): ${sql:0:120}" fi # strip trailing newlines only printf '%s' "$out" | tr -d '\r' } # "64.03" or "64.03000001" → clean CUR:value using decimal string (no binary float) fmt_cur_num() { local c="$1" n="$2" awk -v c="$c" -v n="$n" 'BEGIN{ gsub(/ /,"",n) if (n == "" || n+0 == 0 && n !~ /[1-9]/) { printf "%s:0", c; exit } if (n ~ /[eE]/) { x = n+0 s = sprintf("%.8f", x) } else { s = n } if (s ~ /\./) { split(s, a, ".") whole = a[1]; frac = substr(a[2] "00000000", 1, 8) extra = substr(a[2], 9, 1) if (extra != "" && extra+0 >= 5) { f = frac+0 + 1 if (f >= 100000000) { whole = whole+1; f = f - 100000000 } frac = sprintf("%08d", f) } sub(/0+$/, "", frac) if (frac == "") printf "%s:%s", c, whole else printf "%s:%s.%s", c, whole, frac } else { printf "%s:%s", c, s } }' } # --- preflight: tools + DB reachability (fail closed) --- command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" PROBE=$(psqlq "SELECT 1;") [ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" SERIALS=$(psqlq "SELECT merchant_serial::text FROM merchant.merchant_instances ORDER BY merchant_serial;") # If table exists but we got nothing, still ok (empty install). If table missing, psqlq aborted. # Build list of existing instance schemas SCHEMAS="" INSTANCES=0 while read -r serial; do [ -z "$serial" ] && continue # only pure integers [[ "$serial" =~ ^[0-9]+$ ]] || abort "bad merchant_serial='$serial'" sch="merchant_instance_${serial}" exists=$(psqlq "SELECT 1 FROM pg_namespace WHERE nspname = '${sch}';") if [ "$exists" != "1" ]; then # schema lag — skip, do not abort (instance row without schema yet) continue fi INSTANCES=$((INSTANCES + 1)) SCHEMAS="${SCHEMAS}${SCHEMAS:+ }$sch:$serial" done <<<"$SERIALS" # Sanity: if instance schemas exist in PG but SERIALS empty → query path broken NS_COUNT=$(psqlq "SELECT count(*)::text FROM pg_namespace WHERE nspname LIKE 'merchant_instance_%';") NS_COUNT=$(printf '%s' "$NS_COUNT" | tr -d '[:space:]') if [ "${NS_COUNT:-0}" -gt 0 ] && [ "$INSTANCES" -eq 0 ]; then abort "pg has ${NS_COUNT} merchant_instance_* schemas but resolved INSTANCES=0 (query/PATH bug)" fi # --- dual-currency aggregation entirely in PostgreSQL (numeric) --- { echo "SELECT" echo " coalesce(nullif(split_part(c.contract_terms->>'amount', ':', 1), ''), '?') AS currency," echo " count(*)::bigint," echo " count(*) FILTER (WHERE c.paid)::bigint," echo " count(*) FILTER (WHERE NOT c.paid)::bigint," echo " count(*) FILTER (WHERE c.wired)::bigint," echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric), 0), 8)::text," echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric) FILTER (WHERE c.paid), 0), 8)::text" echo "FROM (" first=1 for item in $SCHEMAS; do sch=${item%%:*} if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi echo "SELECT contract_terms, paid, wired FROM ${sch}.merchant_contract_terms" done if [ "$first" = 1 ]; then echo "SELECT NULL::jsonb AS contract_terms, false AS paid, false AS wired WHERE false" fi echo ") c" echo "WHERE c.contract_terms ? 'amount'" echo "GROUP BY 1 ORDER BY 1;" } | psql_pipe >"/tmp/merch_cur_$$.tsv" || abort "currency aggregate query failed" CUR_TSV=$(cat "/tmp/merch_cur_$$.tsv" 2>/dev/null || true) rm -f "/tmp/merch_cur_$$.tsv" TOTAL_CONTRACTS=0; TOTAL_PAID=0; TOTAL_WIRED=0; TOTAL_UNPAID=0 declare -A C_CONTRACTS C_PAID C_UNPAID C_WIRED C_AMT C_AMT_PAID while IFS=$'\t' read -r cur contracts paid unpaid wired amt amt_paid; do [ -z "${cur:-}" ] && continue TOTAL_CONTRACTS=$((TOTAL_CONTRACTS + contracts)) TOTAL_PAID=$((TOTAL_PAID + paid)) TOTAL_UNPAID=$((TOTAL_UNPAID + unpaid)) TOTAL_WIRED=$((TOTAL_WIRED + wired)) C_CONTRACTS[$cur]=$contracts C_PAID[$cur]=$paid C_UNPAID[$cur]=$unpaid C_WIRED[$cur]=$wired C_AMT[$cur]=$amt C_AMT_PAID[$cur]=$amt_paid done <<<"$CUR_TSV" # deposits / refunds DEPOSITS=0; REFUNDS=0 for item in $SCHEMAS; do sch=${item%%:*} d=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_deposits;") r=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_refunds;") d=$(printf '%s' "$d" | tr -d '[:space:]') r=$(printf '%s' "$r" | tr -d '[:space:]') [[ "$d" =~ ^[0-9]+$ ]] || abort "bad deposits count for $sch: '$d'" [[ "$r" =~ ^[0-9]+$ ]] || abort "bad refunds count for $sch: '$r'" DEPOSITS=$((DEPOSITS + d)) REFUNDS=$((REFUNDS + r)) done # --- recent activity: 5 latest events per currency (GOA + CHF), payments + refunds --- ACT_PER_CURRENCY="${ACT_PER_CURRENCY:-5}" # Query latest ACT_PER_CURRENCY events for one currency code (payments ∪ refunds). # Writes TSV rows: ts kind order_id amount summary status query_activity_for_currency() { local cur="$1" local out="$2" { echo "SELECT * FROM (" echo "SELECT * FROM (" first=1 for item in $SCHEMAS; do sch=${item%%:*} if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi cat <>'amount','') AS amount, left(translate(coalesce(contract_terms->>'summary',''), E'\t\n\r', ' '), 64) AS summary, CASE WHEN wired THEN 'wired' ELSE 'paid' END AS status FROM ${sch}.merchant_contract_terms WHERE paid AND upper(split_part(coalesce(contract_terms->>'amount',''), ':', 1)) = upper('${cur}') SQL done if [ "$first" = 1 ]; then echo "SELECT 0::bigint AS ts, ''::text AS kind, ''::text AS order_id, ''::text AS amount, ''::text AS summary, ''::text AS status WHERE false" fi echo " UNION ALL " first=1 for item in $SCHEMAS; do sch=${item%%:*} if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi cat <"$out" || abort "activity query failed for $cur" } # Build JSON array of activity items from a TSV file activity_tsv_to_json() { local tsv_file="$1" local json="[" local af=1 local ts kind oid amt sum st sec human_fmt iso while IFS=$'\t' read -r ts kind oid amt sum st; do [ -z "${ts:-}" ] && continue [ "$ts" = "0" ] && [ -z "$kind" ] && continue sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') human_fmt=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") iso=$(date -d "@${sec}" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' || true) if [ "$af" = 1 ]; then af=0; else json="${json},"; fi json="${json} { \"ts_us\": ${ts:-0}, \"ts\": $(json_str "$iso"), \"ts_human\": $(json_str "$human_fmt"), \"kind\": $(json_str "$kind"), \"order_id\": $(json_str "$oid"), \"amount\": $(json_str "$amt"), \"summary\": $(json_str "$sum"), \"status\": $(json_str "$st") }" done <"$tsv_file" json="${json} ]" printf '%s' "$json" } query_activity_for_currency GOA "/tmp/merch_act_goa_$$.tsv" query_activity_for_currency CHF "/tmp/merch_act_chf_$$.tsv" ACT_GOA_JSON=$(activity_tsv_to_json "/tmp/merch_act_goa_$$.tsv") ACT_CHF_JSON=$(activity_tsv_to_json "/tmp/merch_act_chf_$$.tsv") rm -f "/tmp/merch_act_goa_$$.tsv" "/tmp/merch_act_chf_$$.tsv" # Flat recent_activity: GOA then CHF (compat; landing prefers by_currency) ACT_JSON="[" af=1 for block in "$ACT_GOA_JSON" "$ACT_CHF_JSON"; do # strip outer [ ] and inject if non-empty inner=$(printf '%s' "$block" | sed '1s/^\s*\[//; $s/\]\s*$//') # empty array → skip if printf '%s' "$inner" | grep -q '"kind"'; then if [ "$af" = 1 ]; then af=0; else ACT_JSON="${ACT_JSON},"; fi ACT_JSON="${ACT_JSON}${inner}" fi done ACT_JSON="${ACT_JSON} ]" ACT_BY_CUR_JSON="[ { \"currency\": \"GOA\", \"limit\": ${ACT_PER_CURRENCY}, \"items\": ${ACT_GOA_JSON} }, { \"currency\": \"CHF\", \"limit\": ${ACT_PER_CURRENCY}, \"items\": ${ACT_CHF_JSON} } ]" # by_currency JSON — GOA then CHF then rest CUR_JSON="[" cf=1 emit_cur() { local cur="$1" [ -n "${C_CONTRACTS[$cur]+x}" ] || return 1 local amt_fmt paid_fmt amt_fmt=$(fmt_cur_num "$cur" "${C_AMT[$cur]}") paid_fmt=$(fmt_cur_num "$cur" "${C_AMT_PAID[$cur]}") if [ "$cf" = 1 ]; then cf=0; else CUR_JSON="${CUR_JSON},"; fi CUR_JSON="${CUR_JSON} { \"currency\": $(json_str "$cur"), \"contracts\": ${C_CONTRACTS[$cur]:-0}, \"paid\": ${C_PAID[$cur]:-0}, \"unpaid\": ${C_UNPAID[$cur]:-0}, \"wired\": ${C_WIRED[$cur]:-0}, \"amount_sum\": $(json_str "$amt_fmt"), \"amount_paid_sum\": $(json_str "$paid_fmt") }" } emit_cur GOA || true emit_cur CHF || true for cur in "${!C_CONTRACTS[@]}"; do case "$cur" in GOA|CHF) continue ;; esac emit_cur "$cur" || true done CUR_JSON="${CUR_JSON} ]" GEN=$(now_iso) HUMAN=$(now_human) # Live performance probes (exchange-style) measure_ms() { local url="$1" t t=$(curl -skS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") [ -z "$t" ] && { echo "null"; return; } awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' } num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } MERCHANT_PUBLIC_BASE="${MERCHANT_PUBLIC_URL:-https://taler.hacktivism.ch}" MERCHANT_LOCAL="${MERCHANT_LOCAL_URL:-https://127.0.0.1:9010}" CONFIG_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/config") CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/config" 2>/dev/null || echo "000") if [ "$CONFIG_HTTP" != "200" ]; then CONFIG_MS=$(measure_ms "${MERCHANT_LOCAL}/config") CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_LOCAL}/config" 2>/dev/null || echo "000") fi TERMS_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/terms") TERMS_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' -H "Accept: text/html" "${MERCHANT_PUBLIC_BASE}/terms" 2>/dev/null || echo "000") WEBUI_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/webui/") WEBUI_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_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 # Atomic write only after full success cat >"$TMP" <", "dual_currency": true, "currencies_note": "CHF (taler-ops) + GOA (hacktivism)", "timezone": $(json_str "$TZ"), "generated_at": $(json_str "$GEN"), "generated_at_human": $(json_str "$HUMAN"), "instances": ${INSTANCES:-0}, "orders": ${TOTAL_CONTRACTS:-0}, "paid": ${TOTAL_PAID:-0}, "unpaid": ${TOTAL_UNPAID:-0}, "wired": ${TOTAL_WIRED:-0}, "deposits": ${DEPOSITS:-0}, "refunds": ${REFUNDS:-0}, "by_currency": $CUR_JSON, "recent_activity_limit_per_currency": ${ACT_PER_CURRENCY}, "recent_activity_by_currency": $ACT_BY_CUR_JSON, "recent_activity": $ACT_JSON, "performance": { "config_http": $(json_str "$CONFIG_HTTP"), "config_ms": $(num_or_null "$CONFIG_MS"), "terms_http": $(json_str "$TERMS_HTTP"), "terms_ms": $(num_or_null "$TERMS_MS"), "webui_http": $(json_str "$WEBUI_HTTP"), "webui_ms": $(num_or_null "$WEBUI_MS"), "loadavg": $(json_str "${LOADAVG:-}"), "memory": { ${MEM_JSON} } } } EOF # basic JSON sanity before publish grep -q '"ok": true' "$TMP" || abort "tmp json missing ok:true" mv -f "$TMP" "$OUT" write_run true echo "ok merchant instances=$INSTANCES orders=$TOTAL_CONTRACTS paid=$TOTAL_PAID refunds=$REFUNDS -> $OUT"