#!/bin/bash
# alpha-storage — find reclaimable disk space, and reclaim it ONE CATEGORY AT A
# TIME, on demand. Nothing here runs on a timer and nothing deletes as a side
# effect of scanning.
#
#   scan            read-only JSON report of what could be freed
#   clean <cat>     reclaim exactly one category (see CATEGORIES below)
#   dupes [root]    duplicate-file report (REPORT ONLY — never deletes)
#
# ⚠️ THE RULE THAT KEEPS THIS SAFE: a category may only ever remove files that
# are re-creatable or provably dead — package caches, rotated logs, journald
# beyond a retention window, Docker's own dangling layers. Anything that could
# be someone's data (named volumes, tagged images, the recycle bin, site files)
# is REPORTED with its size and left alone; the admin removes those one by one
# from the panel, having seen what they are.
#
# ⚠️ PROTECTED never appears in any delete path, at any depth. If you add a
# category, it must not touch these — they are the difference between a tidy
# server and an unbootable one.
set -u

PROTECTED='^/(boot|etc|usr|bin|sbin|lib|lib64|opt|home|root|srv|dev|proc|sys)(/|$)|^/var/(lib/(mysql|postgresql|docker|lxd|alpha-qemu)|snap|backups)(/|$)|^/var/www/(alpha|alpha-private|sites|containers|visualization|velxio-skin)(/|$)'

die() { echo "ERR $*" >&2; exit 2; }

# Refuse any path that touches something protected. Used by every rm below.
safe_path() {
  case "$1" in
    ''|'/'|*..*) return 1;;
  esac
  echo "$1" | grep -Eq "$PROTECTED" && return 1
  return 0
}

human() { numfmt --to=iec --suffix=B "${1:-0}" 2>/dev/null || echo "${1:-0}"; }

# --- measurements (all read-only) -------------------------------------------

m_apt_cache()  { du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1+0}'; }
m_journal()    { journalctl --disk-usage 2>/dev/null | grep -oE '[0-9.]+[KMGT]?' | head -1 \
                 | awk '{u=$0; n=u+0; if (u ~ /K/) n*=1024; else if (u ~ /M/) n*=1048576;
                         else if (u ~ /G/) n*=1073741824; else if (u ~ /T/) n*=1099511627776; print int(n)}'; }
m_oldlogs()    { find /var/log -type f \( -name '*.gz' -o -name '*.xz' -o -regex '.*\.[0-9]+$' \) \
                 -mtime +14 -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}'; }
m_tmp()        { find /tmp -mindepth 1 -maxdepth 2 -atime +7 -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}'; }
m_thumbs()     { du -sb /var/www/alpha-private/mail-thumbs 2>/dev/null | awk '{print $1+0}'; }
m_trash()      { du -sb /var/www/.alpha-trash 2>/dev/null | awk '{print $1+0}'; }
m_crash()      { du -sb /var/crash 2>/dev/null | awk '{print $1+0}'; }
# Live logs past 256 MB. Normal growth never gets there between rotations; a file
# this size means a FLOOD (a scanner a jail failed to block wrote 26 GB in days,
# 2026-10-08). Report only — the admin empties one per click, after fixing the cause.
# Rotated copies, the binary journal and the login records are never listed.
m_biglogs()    { find /var/log -xdev -type f -size +256M ! -path '/var/log/journal/*' \
                 ! -name '*.gz' ! -name '*.xz' ! -regex '.*\.[0-9]+$' \
                 ! -name 'btmp*' ! -name 'wtmp*' ! -name 'lastlog' \
                 -printf '%s %p\n' 2>/dev/null | sort -rn | head -20; }
biglogs_json() {
  local first=1 out='' sz path
  while read -r sz path; do
    [ -n "$path" ] || continue
    path=$(printf '%s' "$path" | sed 's/[\\"]/\&/g')
    [ $first -eq 1 ] || out="$out,"; first=0
    out="$out{\"path\":\"$path\",\"bytes\":$sz}"
  done < <(m_biglogs)
  echo "$out"
}

docker_json() {
  command -v docker >/dev/null 2>&1 || { echo '{"dangling":0,"buildcache":0,"volumes":[],"images":[]}'; return; }
  local dang bc
  dang=$(docker images -f dangling=true -q 2>/dev/null | wc -l)
  bc=$(docker system df --format '{{.Type}}:{{.Reclaimable}}' 2>/dev/null | awk -F: '/Build Cache/{print $2}' | head -1)
  # volumes nothing references, with size
  local vols='' first=1 v sz
  for v in $(docker volume ls -q -f dangling=true 2>/dev/null); do
    sz=$(docker system df -v --format '{{range .Volumes}}{{.Name}}:{{.Size}}
{{end}}' 2>/dev/null | awk -F: -v n="$v" '$1==n{print $2; exit}')
    [ $first -eq 1 ] || vols="$vols,"; first=0
    vols="$vols{\"name\":\"$v\",\"size\":\"${sz:-unknown}\"}"
  done
  # Images no container references. Compared on the FULL image ID, never on the
  # tag: a container's .Image may be a tag, a digest or an id depending on how
  # it was started, and a tag mismatch would report an IN-USE image as garbage.
  # ⚠️ `docker ps --format {{.ImageID}}` is NOT a field on this engine — the
  # template fails, the list comes back EMPTY and every image looks unused.
  # inspect each container instead: .Image is always the full sha256.
  local used_ids imgs='' ifirst=1 i itag isz c
  used_ids=$(for c in $(docker ps -aq 2>/dev/null); do
               docker inspect "$c" --format '{{.Image}}' 2>/dev/null
             done | sed 's/^sha256://' | sort -u)
  for i in $(docker images --no-trunc --format '{{.ID}}' 2>/dev/null | sed 's/^sha256://' | sort -u); do
    echo "$used_ids" | grep -qx "$i" && continue
    itag=$(docker images --no-trunc --format '{{.ID}} {{.Repository}}:{{.Tag}}' 2>/dev/null \
           | sed 's/^sha256://' | awk -v id="$i" '$1==id{print $2; exit}')
    case "${itag:-}" in ''|*'<none>'*) itag="${i%%[!0-9a-f]*}"; itag="${itag:0:12}";; esac
    isz=$(docker images --no-trunc --format '{{.ID}} {{.Size}}' 2>/dev/null \
          | sed 's/^sha256://' | awk -v id="$i" '$1==id{print $2; exit}')
    [ $ifirst -eq 1 ] || imgs="$imgs,"; ifirst=0
    imgs="$imgs{\"name\":\"$itag\",\"size\":\"${isz:-?}\"}"
  done
  echo "{\"dangling\":${dang:-0},\"buildcache\":\"${bc:-0B}\",\"volumes\":[$vols],\"images\":[$imgs]}"
}

case "${1:-scan}" in

  scan)
    apt=$(m_apt_cache); jrn=$(m_journal); old=$(m_oldlogs); tmp=$(m_tmp)
    th=$(m_thumbs); tr=$(m_trash); cr=$(m_crash)
    df_line=$(df -B1 --output=size,used,avail,pcent / | tail -1)
    dsize=$(echo "$df_line" | awk '{print $1}'); dused=$(echo "$df_line" | awk '{print $2}')
    davail=$(echo "$df_line" | awk '{print $3}'); dpct=$(echo "$df_line" | awk '{gsub("%","",$4); print $4}')
    cat <<JSON
{
  "disk": {"size": $dsize, "used": $dused, "avail": $davail, "pct": $dpct},
  "safe": {
    "apt_cache":  $apt,
    "journal":    $jrn,
    "old_logs":   $old,
    "tmp":        $tmp,
    "thumbs":     $th,
    "crash":      $cr
  },
  "review": { "trash": $tr },
  "biglogs": [$(biglogs_json)],
  "docker": $(docker_json)
}
JSON
    ;;

  clean)
    cat="${2:-}"
    case "$cat" in
      apt-cache)
        before=$(m_apt_cache); apt-get clean >/dev/null 2>&1
        echo "OK freed $(human $((before - $(m_apt_cache))))";;
      journal)
        before=$(m_journal); journalctl --vacuum-size=200M >/dev/null 2>&1
        echo "OK freed $(human $((before - $(m_journal)))) — 200 MB of recent logs kept";;
      old-logs)
        before=$(m_oldlogs)
        # rotated copies only: the LIVE log a service holds open is never touched
        find /var/log -type f \( -name '*.gz' -o -name '*.xz' -o -regex '.*\.[0-9]+$' \) -mtime +14 -delete 2>/dev/null
        echo "OK freed $(human $((before - $(m_oldlogs))))";;
      tmp)
        before=$(m_tmp)
        find /tmp -mindepth 1 -maxdepth 2 -atime +7 ! -name 'systemd-*' ! -name '.X11*' -exec rm -rf {} + 2>/dev/null
        echo "OK freed $(human $((before - $(m_tmp))))";;
      crash)
        before=$(m_crash); safe_path /var/crash && rm -rf /var/crash/* 2>/dev/null
        echo "OK freed $(human $((before - $(m_crash))))";;
      thumbs)
        before=$(m_thumbs)
        d=/var/www/alpha-private/mail-thumbs
        [ -d "$d" ] && find "$d" -type f -name '*.png' -mtime +2 -delete 2>/dev/null
        echo "OK freed $(human $((before - $(m_thumbs)))) — regenerates on demand";;
      docker-dangling)
        command -v docker >/dev/null 2>&1 || die "docker not installed"
        out=$(docker image prune -f 2>&1 | tail -1)
        echo "OK ${out:-nothing to remove}";;
      docker-buildcache)
        command -v docker >/dev/null 2>&1 || die "docker not installed"
        out=$(docker builder prune -f 2>&1 | tail -1)
        echo "OK ${out:-nothing to remove}";;
      docker-volume)
        # ONE named volume, by name, only when no container references it
        v="${3:-}"
        # ⚠️ 64 hex chars is exactly what Docker names an ANONYMOUS volume —
        # a 63-char cap rejects the very volumes this is meant to collect.
        echo "$v" | grep -Eq '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$' || die "bad volume name"
        docker volume ls -q -f dangling=true 2>/dev/null | grep -qx "$v" || die "volume '$v' is in use or does not exist"
        docker volume rm "$v" >/dev/null 2>&1 && echo "OK volume $v removed" || die "could not remove $v";;
      docker-image)
        i="${3:-}"
        echo "$i" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,200}$' || die "bad image ref"
        docker image rm "$i" >/dev/null 2>&1 && echo "OK image $i removed" || die "could not remove $i (still in use?)";;
      biglog)
        # ONE live log, by path, emptied IN PLACE: the writer keeps its handle and every
        # daemon here opens its log O_APPEND, so the next line lands at offset 0 and
        # nothing needs a restart. Only a file m_biglogs already reports qualifies —
        # under /var/log, past the cap, never the journal.
        f="${3:-}"
        case "$f" in /var/log/*) ;; *) die "not a log path";; esac
        safe_path "$f" || die "refusing $f"
        m_biglogs | cut -d' ' -f2- | grep -qxF "$f" || die "'$f' is not an oversized live log"
        before=$(stat -c %s "$f"); truncate -s0 "$f" || die "could not empty $f"
        case "$f" in /var/log/fail2ban.log) fail2ban-client flushlogs >/dev/null 2>&1;; esac
        echo "OK freed $(human "$before") — the service keeps writing to the same file";;
      *) die "unknown category '$cat'";;
    esac
    ;;

  dupes)
    # REPORT ONLY. Same size + same hash, under one root, skipping the places
    # where identical files are meant to exist (git objects, node_modules,
    # package caches) — those are not garbage, they are how the tools work.
    root="${2:-/var/www}"
    safe_path "$root" || die "refusing to scan $root"
    [ -d "$root" ] || die "no such directory"
    find "$root" -type f -size +1M \
      ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/vendor/*' \
      ! -path '*/alpha-private/*' ! -path '*/.alpha-trash/*' \
      -printf '%s %p\n' 2>/dev/null \
      | sort -n | awk '{sz[$1]=sz[$1]" "$2; c[$1]++} END {for (s in c) if (c[s]>1) print s, sz[s]}' \
      | while read -r size files; do
          md5sum $files 2>/dev/null | sort | awk -v sz="$size" '
            {if ($1==prev) {if (!shown) print "DUPE " sz " " prevfile; print "DUPE " sz " " $2; shown=1}
             else shown=0; prev=$1; prevfile=$2}'
        done | head -200
    echo "END"
    ;;

  *)
    echo "usage: alpha-storage {scan|clean <category> [arg]|dupes [root]}"; exit 2;;
esac
