#!/bin/bash
# ============================================================
# alpha-addoninstall — lay down the root-owned pieces an add-on declares.
#
#   alpha-addoninstall apply  <addon-dir>     install helpers/sudoers/cron
#   alpha-addoninstall remove <addon-id>      undo what we installed
#   alpha-addoninstall list   <addon-id>      what is installed for it
#
# The panel runs as www-data and cannot write /usr/local/sbin,
# /etc/sudoers.d or /etc/cron.d. A marketplace install therefore lands the
# CODE itself (www-data can write Addons/) and calls this for the rest.
#
# Everything installed is recorded in a per-add-on manifest under
# /var/lib/alpha-addons so removal is exact — never a guess based on name
# prefixes, which is how unrelated files get deleted.
#
# ⚠️ A malformed sudoers file locks the box out of sudo ENTIRELY. Every
# rule is validated with `visudo -c` in isolation BEFORE it is committed,
# and the whole set is re-validated afterwards with rollback on failure.
# ============================================================
set -euo pipefail

STATE=/var/lib/alpha-addons
SBIN=/usr/local/sbin
SUDOD=/etc/sudoers.d
CROND=/etc/cron.d

# Where the panel lives. Read from the panel config so an install that is not at
# the default path still works — hardcoding it made this refuse every panel
# except one, which is not a guard, it is a bug with a good excuse.
[ -r /etc/alpha-panel.conf ] && . /etc/alpha-panel.conf
PANEL_ROOT="${PANEL_ROOT:-/var/www/alpha}"
ADDONS_ROOT="$PANEL_ROOT/Addons"

die() { echo "ERR: $1" >&2; exit 2; }
[ "$(id -u)" = 0 ] || die "must run as root"
mkdir -p "$STATE"; chmod 750 "$STATE"

ACTION="${1:-}"
TARGET="${2:-}"
[ -n "$ACTION" ] || die "usage: apply <dir> | remove <id> | list <id>"

# Only ever touch names that look like helper names — no paths, no traversal.
safe_name() { [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$ ]]; }

case "$ACTION" in

  apply)
    DIR="$TARGET"
    [ -d "$DIR" ] || die "no such add-on directory"
    # Refuse anything outside the panel's Addons tree: this runs as root and
    # copies files the caller names, so the source must be somewhere only the
    # panel can populate.
    case "$(readlink -f "$DIR")" in
      "$ADDONS_ROOT"/*) : ;;
      *) die "add-on directory must live under $ADDONS_ROOT" ;;
    esac

    MAN="$DIR/manifest.json"
    [ -f "$MAN" ] && [ -s "$MAN" ] || { echo "OK: no manifest.json — nothing to install"; exit 0; }

    ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("id",""))' "$MAN" 2>/dev/null || echo "")"
    safe_name "$ID" || die "manifest has no usable id"

    RECORD="$STATE/$ID.list"
    : > "$RECORD.new"

    jq_list() { python3 -c '
import json,sys
m=json.load(open(sys.argv[1])).get("install",{}) or {}
for x in (m.get(sys.argv[2]) or []): print(x)
' "$MAN" "$1" 2>/dev/null || true; }

    # ---------- helpers ----------
    for h in $(jq_list helpers); do
      safe_name "$h" || { echo "SKIP bad helper name: $h" >&2; continue; }
      src="$DIR/install/helpers/$h"
      [ -f "$src" ] || { echo "SKIP missing helper: $h" >&2; continue; }
      install -o root -g root -m 0750 "$src" "$SBIN/$h"
      echo "helper:$SBIN/$h" >> "$RECORD.new"
      echo "installed helper $h"
    done

    # ---------- sudoers ----------
    # Validated one file at a time in a temp location first: a bad rule that
    # reaches /etc/sudoers.d takes sudo down for everything, including the
    # helpers the panel needs to undo it.
    SUDO_ADDED=()
    for s in $(jq_list sudoers); do
      safe_name "$s" || { echo "SKIP bad sudoers name: $s" >&2; continue; }
      src="$DIR/install/sudoers/$s"
      [ -f "$src" ] || { echo "SKIP missing sudoers: $s" >&2; continue; }
      tmp="$(mktemp)"; cp "$src" "$tmp"; chmod 0440 "$tmp"
      if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
        rm -f "$tmp"; echo "SKIP invalid sudoers: $s" >&2; continue
      fi
      rm -f "$tmp"
      install -o root -g root -m 0440 "$src" "$SUDOD/$s"
      SUDO_ADDED+=("$SUDOD/$s")
      echo "sudoers:$SUDOD/$s" >> "$RECORD.new"
      echo "installed sudoers $s"
    done
    # whole-set re-validation, with rollback
    if [ "${#SUDO_ADDED[@]}" -gt 0 ] && ! visudo -c >/dev/null 2>&1; then
      for f in "${SUDO_ADDED[@]}"; do rm -f "$f"; done
      rm -f "$RECORD.new"
      die "sudoers became invalid — the add-on's rules were rolled back"
    fi

    # ---------- cron ----------
    for c in $(jq_list cron); do
      safe_name "$c" || { echo "SKIP bad cron name: $c" >&2; continue; }
      src="$DIR/install/cron/$c"
      [ -f "$src" ] || { echo "SKIP missing cron: $c" >&2; continue; }
      install -o root -g root -m 0644 "$src" "$CROND/$c"
      echo "cron:$CROND/$c" >> "$RECORD.new"
      echo "installed cron $c"
    done

    # merge with anything a previous version installed, de-duplicated
    [ -f "$RECORD" ] && cat "$RECORD" >> "$RECORD.new"
    sort -u "$RECORD.new" > "$RECORD"; rm -f "$RECORD.new"
    chmod 640 "$RECORD"
    echo "OK: $(wc -l < "$RECORD") item(s) recorded for $ID"
    ;;

  remove)
    ID="$TARGET"; safe_name "$ID" || die "bad add-on id"
    RECORD="$STATE/$ID.list"
    [ -f "$RECORD" ] || { echo "OK: nothing recorded"; exit 0; }
    n=0
    while IFS=: read -r kind path; do
      [ -n "${path:-}" ] || continue
      # only remove from the three directories we install into
      case "$path" in
        "$SBIN"/*|"$SUDOD"/*|"$CROND"/*) rm -f "$path" && n=$((n+1)) ;;
        *) echo "SKIP unexpected path: $path" >&2 ;;
      esac
    done < "$RECORD"
    rm -f "$RECORD"
    visudo -c >/dev/null 2>&1 || echo "WARN: sudoers reports a problem after removal" >&2
    echo "OK: removed $n item(s) for $ID"
    ;;

  list)
    ID="$TARGET"; safe_name "$ID" || die "bad add-on id"
    [ -f "$STATE/$ID.list" ] && cat "$STATE/$ID.list" || echo ""
    ;;

  *) die "unknown action: $ACTION" ;;
esac
