#!/bin/bash
# ============================================================
# alpha-licmount — the PUBLIC address of the Licensing Manager add-on
# (panel: Licensing Manager ▸ API ▸ Public address).
#
#   alpha-licmount hosts                 JSON {ok, hosts:[{host,file}], current:{host,path}}
#   alpha-licmount status                JSON {ok, mounted, host, path, url, portal}
#   alpha-licmount set <host> <path>     serve the add-on's API at https://<host><path>/v1/… and its
#                                        customer portal at https://<host><path>/ — on ANY host this
#                                        server already serves (apex, www, a site), e.g. example.net /Licensing
#   alpha-licmount unset                 remove the mount everywhere
#   alpha-licmount apply                 re-apply the address saved in the add-on's settings (provisioning)
#
# WHY: the add-on licenses a CUSTOMER's own software, so its endpoints get an
# address of the customer's choosing and never collide with the panel's own
# Licensing Portal (licenses.<domain>, api.<domain>/v1/dist/*). Two regex
# locations are injected after the server_name of every 443 block that answers
# <host>, between markers, so the mount can always be found and removed again:
#   <path>/v1/*  → /var/www/alpha/api.php          with PATH_INFO /v1/license/*
#   <path>/*     → Addons/licensing/v1.0.0/portal.php (the add-on's portal)
# Refused outright: the admin host, api.<domain> (its /Licensing/downloads is the
# vendor portal's), licenses.<domain>. nginx is reloaded only after `nginx -t`
# passes; a refused edit is put back byte for byte.
# ============================================================
set -uo pipefail

SITES=/etc/nginx/sites-enabled
die() { jq -n --arg m "$*" '{ok:false,msg:$m}'; exit 1; }
conf() { ( . /etc/alpha-panel.conf 2>/dev/null; eval "printf '%s' \"\${$1:-}\"" ); }
PANEL="$(conf PANEL_SUBDOMAIN | tr 'A-Z' 'a-z')"
DOMAIN="$(conf PRIMARY_DOMAIN | tr 'A-Z' 'a-z')"
ROOT="$(conf PANEL_ROOT)"; [ -n "$ROOT" ] || ROOT=/var/www/alpha
ADDON="$ROOT/Addons/licensing/v1.0.0"
SETTINGS=/var/www/alpha-private/licensing/settings.json

fpm_sock() { for v in 8.3 8.4 8.2 8.1; do [ -S "/run/php/php$v-fpm.sock" ] && { echo "/run/php/php$v-fpm.sock"; return; }; done; echo /run/php/php8.3-fpm.sock; }
valid_host() { [ ${#1} -le 253 ] && [[ "$1" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]; }
valid_path() { [[ "$1" =~ ^(/[A-Za-z0-9._-]{1,40}){1,3}$ ]]; }
norm() { printf '%s' "${1:-}" | tr 'A-Z' 'a-z' | sed 's/\.$//'; }
refused_host() {   # the three hosts the add-on must never shadow
  local h="$1"
  [ -n "$PANEL" ] && [ "$h" = "$PANEL" ] && { echo "That is the admin panel's own address — nothing public may live there."; return 0; }
  [ -n "$DOMAIN" ] && [ "$h" = "api.$DOMAIN" ] && { echo "api.$DOMAIN is the panel's API host (and serves the Licensing Portal's downloads) — pick another host."; return 0; }
  [ -n "$DOMAIN" ] && [ "$h" = "licenses.$DOMAIN" ] && { echo "licenses.$DOMAIN is the panel's own Licensing Portal — this add-on gets its own address."; return 0; }
  return 1
}
# every enabled vhost file that has a 443 block naming this host
files_for() {
  local h="$1" f
  for f in "$SITES"/*; do
    [ -f "$f" ] || continue
    LICM_HOST="$h" python3 - "$f" <<'PY' && echo "$f"
import os,re,sys
host=os.environ['LICM_HOST']; txt=open(sys.argv[1],encoding='utf-8',errors='replace').read()
ok=False
for blk in re.split(r'(?m)^\s*server\s*\{', txt)[1:]:
    if re.search(r'(?m)^\s*listen[^;]*\b443\b', blk) and re.search(r'(?m)^\s*server_name[^;]*(?<![\w.-])'+re.escape(host)+r'(?![\w.-])', blk): ok=True
sys.exit(0 if ok else 1)
PY
  done
}
current() {   # prints "host path" from the first marker found, or nothing
  grep -Rhs -m1 -oE '# ALPHA-LICAPI-START host=[^ ]+ path=[^ ]+' "$SITES" 2>/dev/null | head -1 | sed -E 's/.*host=([^ ]+) path=([^ ]+)/\1 \2/'
}
block() {   # <path>
  local p="$1" sock; sock="$(fpm_sock)"
  cat <<EOF
    # ALPHA-LICAPI-START host=$2 path=$p
    # Licensing Manager add-on — public address written by /usr/local/sbin/alpha-licmount.
    # The API first (a regex location wins by order), then the add-on's customer portal.
    location ~ ^$p/v1(?:/(.*))?\$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $ROOT/api.php;
        fastcgi_param SCRIPT_NAME /api.php;
        fastcgi_param PATH_INFO /v1/license/\$1;
        fastcgi_param ALPHA_LICAPI_BASE https://$2$p;
        fastcgi_read_timeout 60;
        fastcgi_pass unix:$sock;
    }
    location ~ ^$p(?:/(.*))?\$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $ADDON/portal.php;
        fastcgi_param SCRIPT_NAME $p/index.php;
        fastcgi_param PATH_INFO /\$1;
        fastcgi_param ALPHA_LICAPI_BASE https://$2$p;
        fastcgi_pass unix:$sock;
    }
    # ALPHA-LICAPI-END
EOF
}
# strip every marker block from one file (prints the result)
strip_file() {
  python3 - "$1" <<'PY'
import re,sys
txt=open(sys.argv[1],encoding='utf-8',errors='replace').read()
txt=re.sub(r'(?ms)^[ \t]*# ALPHA-LICAPI-START.*?# ALPHA-LICAPI-END[ \t]*\n', '', txt)
sys.stdout.write(txt)
PY
}
# insert $INJ after the server_name line of every 443 block naming $h (prints the result)
inject_file() {
  LICM_HOST="$2" LICM_INJ="$INJ" python3 - "$1" <<'PY'
import os,re,sys
host=os.environ['LICM_HOST']; inj=os.environ['LICM_INJ'].rstrip('\n')+'\n'
txt=open(sys.argv[1],encoding='utf-8',errors='replace').read()
out=[]; i=0; n=len(txt)
# walk top-level server blocks by brace depth (nginx site files are flat: no http{} wrapper)
while i<n:
    m=re.compile(r'(?m)^\s*server\s*\{').search(txt,i)
    if not m: out.append(txt[i:]); break
    out.append(txt[i:m.start()])
    depth=0; j=m.start()
    while j<n:
        c=txt[j]
        if c=='{': depth+=1
        elif c=='}':
            depth-=1
            if depth==0: j+=1; break
        j+=1
    blk=txt[m.start():j]
    if re.search(r'(?m)^\s*listen[^;]*\b443\b', blk) and re.search(r'(?m)^\s*server_name[^;]*(?<![\w.-])'+re.escape(host)+r'(?![\w.-])', blk):
        blk=re.sub(r'(?m)^(\s*server_name[^\n]*\n)', lambda mm: mm.group(1)+inj, blk, count=1)
    out.append(blk); i=j
sys.stdout.write(''.join(out))
PY
}
# which site already serves <path> on this host (a WordPress folder, an attached site…)? markers of our own do not count
path_taken() {
  local f="$1" p="$2"
  strip_file "$f" | grep -qE "^[[:space:]]*location[[:space:]]+(=|\^~)?[[:space:]]*${p//./\\.}(/|[[:space:]]|$)" && return 0
  return 1
}
reload_or_restore() {   # $1 = space-separated "file=backup" pairs
  if ! nginx -t >/dev/null 2>&1; then
    local pair; for pair in $1; do cp -a "${pair#*=}" "${pair%%=*}"; done
    nginx -t >/dev/null 2>&1 && systemctl reload nginx
    return 1
  fi
  systemctl reload nginx; return 0
}

case "${1:-}" in
  hosts)
    cur="$(current)"
    python3 - "$SITES" "$PANEL" "$DOMAIN" "$cur" <<'PY'
import glob,json,re,sys,os
sites,panel,domain,cur=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
seen={}
for f in sorted(glob.glob(sites+'/*')):
    try: txt=open(f,encoding='utf-8',errors='replace').read()
    except Exception: continue
    for blk in re.split(r'(?m)^\s*server\s*\{', txt)[1:]:
        if not re.search(r'(?m)^\s*listen[^;]*\b443\b', blk): continue
        for sn in re.findall(r'(?m)^\s*server_name\s+([^;]+);', blk):
            for h in sn.split():
                h=h.lower().strip()
                if not h or h=='_' or '*' in h or h==panel or (domain and h in ('api.'+domain,'licenses.'+domain)): continue
                seen.setdefault(h, os.path.basename(f))
hosts=[{'host':h,'file':fn} for h,fn in sorted(seen.items(), key=lambda x:(x[0].count('.'),x[0]))]
c=cur.split() if cur else []
print(json.dumps({'ok':True,'hosts':hosts,'current':{'host':c[0],'path':c[1]} if len(c)==2 else None}))
PY
    ;;

  status)
    cur="$(current)"; set -- $cur
    if [ -n "${1:-}" ] && [ -n "${2:-}" ]; then
      jq -n --arg h "$1" --arg p "$2" '{ok:true, mounted:true, host:$h, path:$p, url:("https://"+$h+$p+"/v1"), portal:("https://"+$h+$p+"/")}'
    else
      jq -n '{ok:true, mounted:false, host:"", path:"", url:"", portal:""}'
    fi
    ;;

  set)
    h="$(norm "${2:-}")"; p="${3:-}"
    valid_host "$h" || die "That is not a valid host name (e.g. example.net)."
    valid_path "$p" || die "The path is one to three plain segments, e.g. /Licensing or /apps/licensing."
    why="$(refused_host "$h")" && die "$why"
    [ -f "$ADDON/portal.php" ] || die "The Licensing Manager add-on is not installed at $ADDON."
    mapfile -t files < <(files_for "$h")
    [ ${#files[@]} -gt 0 ] || die "No enabled site serves https://$h on this server — create or secure that site first."
    for f in "${files[@]}"; do path_taken "$f" "$p" && die "https://$h$p is already served by $(basename "$f") — pick another path."; done
    INJ="$(block "$p" "$h")"
    # a mount lives in exactly one place: strip any earlier one everywhere, then inject
    pairs=""; tmpd="$(mktemp -d)"
    for f in "$SITES"/*; do
      [ -f "$f" ] || continue
      real="$(readlink -f "$f")"; bak="$tmpd/$(basename "$f").bak"; cp -a "$real" "$bak"; pairs="$pairs $real=$bak"
      # ⚠️ through a temp file, never a pipe: the python inside inject_file reads ITS OWN
      # program from stdin, so /dev/stdin would hand it the script, not the config
      cur="$tmpd/cur.conf"; strip_file "$real" > "$cur"
      for g in "${files[@]}"; do
        if [ "$(readlink -f "$g")" = "$real" ]; then inject_file "$cur" "$h" > "$cur.2" && mv "$cur.2" "$cur"; fi
      done
      cat "$cur" > "$real"
    done
    if ! grep -qs 'ALPHA-LICAPI-START' "$(readlink -f "${files[0]}")"; then
      for pair in $pairs; do cp -a "${pair#*=}" "${pair%%=*}"; done; rm -rf "$tmpd"
      die "The mount could not be written into $(basename "${files[0]}") — nothing changed."
    fi
    if ! reload_or_restore "$pairs"; then rm -rf "$tmpd"; die "nginx refused the new location blocks — every site was put back as it was."; fi
    rm -rf "$tmpd"
    jq -n --arg h "$h" --arg p "$p" '{ok:true, host:$h, path:$p, url:("https://"+$h+$p+"/v1"), portal:("https://"+$h+$p+"/"), msg:("Serving the API at https://"+$h+$p+"/v1/… and the customer portal at https://"+$h+$p+"/")}'
    ;;

  unset)
    pairs=""; tmpd="$(mktemp -d)"; n=0
    for f in "$SITES"/*; do
      [ -f "$f" ] || continue
      grep -qs 'ALPHA-LICAPI-START' "$f" || continue
      real="$(readlink -f "$f")"; bak="$tmpd/$(basename "$f").bak"; cp -a "$real" "$bak"; pairs="$pairs $real=$bak"
      strip_file "$real" > "$tmpd/new"; cat "$tmpd/new" > "$real"; n=$((n+1))
    done
    [ $n -gt 0 ] || { rm -rf "$tmpd"; jq -n '{ok:true, msg:"No public address was mounted — the default API host applies."}'; exit 0; }
    if ! reload_or_restore "$pairs"; then rm -rf "$tmpd"; die "nginx refused the edit — nothing changed."; fi
    rm -rf "$tmpd"
    jq -n '{ok:true, msg:"Public address removed — the API answers on the default api host again; the add-on portal is unpublished."}'
    ;;

  apply)
    # provisioning: a fresh box re-mounts whatever the add-on's settings remember (nothing saved = nothing to do)
    [ -f "$SETTINGS" ] || { jq -n '{ok:true, msg:"no settings"}'; exit 0; }
    h="$(jq -r '.api_host // ""' "$SETTINGS" 2>/dev/null)"; p="$(jq -r '.api_path // ""' "$SETTINGS" 2>/dev/null)"
    [ -n "$h" ] && [ -n "$p" ] || { jq -n '{ok:true, msg:"no public address saved"}'; exit 0; }
    cur="$(current)"; [ "$cur" = "$h $p" ] && { jq -n --arg h "$h" --arg p "$p" '{ok:true, msg:("already mounted at https://"+$h+$p)}'; exit 0; }
    exec "$0" set "$h" "$p"
    ;;

  *)
    echo "usage: alpha-licmount hosts | status | set <host> <path> | unset | apply" >&2
    exit 2
    ;;
esac
