#!/bin/bash

####################################
##  pac-du-quota
##
##  bestimme Plattenbenutzung oder
##  Gruppenquota fuer alle User eines
##  Hostsharing Pakets
##
## --------------------------------
##
##  Umgebung  : Hostsharing-Paket
##  Lizenz    : GPL
##  Autor     : Andreas Loesch, Timotheus Pokorra
##  Datum     : 07.10.2026
##  Version   : 1.3.0
##
## --------------------------------
##
##  Aufruf und Kurzbeschreibung:  pac-du-quota --help
##  Ausfuehrliche Anleitung (Werte, Berechnung, CSV, Beispiele):
##    pac-du-quota-manpage.txt
##
##  History
##  -------
##  1.3 07.10.26 - Features:
##                   Ausgabe -v (Tabelle), -j (JSON), -t (CSV) mit --table-header,
##                   --no-user, Anzeige Quota-/Inode-Ueberschreitung,
##                   Kurzhilfe -h, Anleitung pac-du-quota-manpage.txt
##                 Bugfixes:
##                   Nachkommastellen Paketquota/Limit (immer .000) und MB-Rest,
##                   MB einheitlich KiB/1024, Parser bei reiner Inode-Ueberschreitung,
##                   Admin-Platz ohne gleichnamige Verzeichnisse der User,
##                   Abbruch ohne users/ bzw. ohne Quota, Haenger bei sudo (-n),
##                   unsichere Temp-Dateien in /tmp, Dezimalpunkt (LC_ALL=C)
##                 (Andreas Loesch)
##  1.2 15.07.22 - Abbruch falls das Skript nicht als Paketbenutzer ausgeführt wird (Timotheus Pokorra)
##  1.1 18.06.22 - Formatierung von Byte Angaben, Modulo 1000 damit es immer bei 3 Stellen bleibt.
##                 Ausblenden von Benutzern die kein Storage benutzen. (Timotheus Pokorra)
##  1.0 13.09.21 Bugfix -- alle Bekannten Fehler raus :)
##  0.9  1309.21 - Lokalisierungsproblem geloest (bc immer dezimalpunkt, printf in de mit Komma...)
##  0.8.2 09.09.21 - Zahlen vergroessert
##  0.8.1 07.09.21 - mountpfade variablilisiert
##  0.8 07.09.21 - Messung Storage HDD hinzugefuegt
##  0.7 07.09.21 - CVS removed, CUT-Fehler gefixed
##  0.6 21.12.04 - CVS-Id-tag hinzugefuegt und in meinem Paket-CVS gespeichert
##  0.5 17.12.04 - quota-Abfrage auf variable Gruppen ID erweitert
###################################

set -o pipefail
shopt -s nullglob

# Dezimalpunkt in allen Zahlenformaten, egal was die Umgebung setzt
export LC_ALL=C

PAC=$(id -nu)
PACGROUP=$(id -gn)

# Ergebnisse von readQuota (Werte in KiB bzw. Anzahl)
BLOCKS='' PACQUOTA='' HARDQUOTA='' BLOCKGRACE=''
FILES='' FILEQUOTA='' FILELIMIT='' FILEGRACE=''

# Ergebnisse von collectSpace
NAMES=()
SPACES=()

# Fehlertext der letzten fehlgeschlagenen Ermittlung
ERR=''

################################################################
## Datenmodell
################################################################
#
# Pro Speicher <key> (ssd, hdd) liegen nach collectStorage Variablen
# der Form <key>_<feld> vor; lesen mit: get <key> <feld>
# Diese Feldnamen sind zugleich die JSON-Schluessel und die CSV-Spalten.
#
# Paketwerte (Typ: s = Text, n = Zahl, b = ja/nein):
#   gebucht              b  Speicher vorhanden
#   fehler               s  Fehlertext, leer wenn Ermittlung ok
#   summe_mb             n  Summe aller Benutzer laut du
#   summe_anteil_quota_pct n  summe_mb in % der Paketquota
#   belegt_mb            n  belegt laut quota
#   quota_mb             n  Paketquota (weiche Grenze)
#   limit_mb             n  hartes Limit
#   belegt_pct           n  belegt in % der Paketquota
#   limit_pct            n  belegt in % des harten Limits
#   ueber_quota          b  belegt > Paketquota
#   grace                s  Grace-Zeit, leer solange nicht ueberschritten
#   inodes               n  belegte Inodes laut quota
#   inodes_quota         n  Inode-Quota (weiche Grenze)
#   inodes_limit         n  hartes Inode-Limit
#   inodes_pct           n  Inodes in % der Inode-Quota
#   inodes_limit_pct     n  Inodes in % des Inode-Limits
#   inodes_ueber_quota   b  Inodes > Inode-Quota
#   inodes_grace         s  Grace-Zeit Inodes
#
# Benutzerwerte, je Benutzer eine Zeile in <key>_benutzer:
#   "<name>;<mb>;<anteil_summe_pct>;<anteil_quota_pct>"  (erste = Paketadmin)
#
# Groessen in MB = KiB / 1024 mit drei Nachkommastellen, Prozente mit zwei;
# gerechnet wird mit den KiB-Rohwerten. Ein leerer Wert heisst: kein Wert
# (nicht gebucht, Fehler, keine Grenze).

STORAGE_KEYS='ssd hdd'

PAC_FIELDS='gebucht:b fehler:s summe_mb:n summe_anteil_quota_pct:n
  belegt_mb:n quota_mb:n limit_mb:n belegt_pct:n limit_pct:n ueber_quota:b grace:s
  inodes:n inodes_quota:n inodes_limit:n inodes_pct:n inodes_limit_pct:n
  inodes_ueber_quota:b inodes_grace:s'
USER_FIELDS='mb anteil_summe_pct anteil_quota_pct'

set_() { printf -v "${1}_$2" '%s' "$3"; }
get()  { local v="${1}_$2"; printf '%s' "${!v}"; }

################################################################
## Hilfsfunktionen
################################################################

dividerLine()       { printf '%80s\n' '' | tr ' ' '-'; }
doubleDividerLine() { printf '%80s\n' '' | tr ' ' '='; }
warn()              { echo " WARNUNG: $*" >&2; }
isNum()             { [[ $1 =~ ^[0-9]+$ ]]; }

# KiB -> MB (KiB / 1024, so wie Hostsharing die Quota angibt) mit drei
# Nachkommastellen; leer wenn kein Wert
mb() {
  [[ -n $1 ]] || return 0
  awk -v k="$1" 'BEGIN { printf "%.3f", k / 1024 }'
}

# a in Prozent von b, zwei Nachkommastellen; leer wenn ein Wert fehlt
# oder b 0 ist (keine Grenze gesetzt)
pct() {
  [[ -n $1 && -n $2 ]] || return 0
  awk -v a="$1" -v b="$2" 'BEGIN { if (b + 0 > 0) printf "%.2f", a * 100 / b }'
}

# 1 wenn a > b und b eine gesetzte Grenze ist, sonst 0; leer ohne Werte
over() {
  [[ -n $1 && -n $2 ]] || return 0
  if [[ $2 -gt 0 && $1 -gt $2 ]]; then printf 1; else printf 0; fi
}

usage() {
  local me=${0##*/}
  cat <<EOF
    Ermittelt fuer das Paket auf NVMe SSD (Home) und HDD Storage
    (/home/storage/<paket>) den Platz je Benutzer (du) und die
    Gruppenquota des Pakets (quota) und gibt beides in einem von drei
    Formaten aus. Muss als Paketbenutzer (z.B. xyz00) laufen, nicht als
    Benutzer xyz00-<name>. Benutzerverzeichnisse werden per sudo als der
    jeweilige Benutzer vermessen.

OPTIONEN
    -v, --visual    Tabellen fuer das Terminal (Standard ohne Option)
    -j, --json      JSON auf stdout
    -t, --table     CSV-Zeilen zum Anhaengen an eine Datei, OHNE Kopfzeile
    --table-header  nur die CSV-Kopfzeile ausgeben (ohne zu messen)
    --no-user       nur Paketwerte, keine Werte je Benutzer;
                    bei -t nur die Zeile typ "paket"
    -h, --help      diese Hilfe

BEISPIEL
    $me [-v|--visual | -j|--json | -t|--table] [--no-user]
    $me --table-header
    $me -h|--help
EOF
}

################################################################
## Ermittlung: Quota
################################################################

# Gruppenquota der Paketgruppe fuer ein Device lesen.
#
# Datenzeile von "quota -gw":
#   <fs> <blocks>[*] <quota> <limit> [<grace>] <files>[*] <quota> <limit> [<grace>]
# Eine Grace-Spalte steht nur da, wenn der Wert davor mit * markiert ist
# (weiche Grenze ueberschritten) - das gilt fuer Bloecke und Inodes getrennt.
readQuota() {
  local device=$1 line
  local -a f

  line=$(quota -gw "$PACGROUP" 2>/dev/null | awk -v dev="$device" '$1 == dev { print; exit }')
  if [[ -z $line ]]; then
    ERR="keine Quota-Angaben fuer $device gefunden"
    return 1
  fi

  read -r -a f <<<"$line"
  local i=1

  BLOCKS=${f[i]};    i=$((i + 1))
  PACQUOTA=${f[i]};  i=$((i + 1))
  HARDQUOTA=${f[i]}; i=$((i + 1))
  BLOCKGRACE=''
  if [[ $BLOCKS == *'*' ]]; then
    BLOCKS=${BLOCKS%'*'}
    BLOCKGRACE=${f[i]}; i=$((i + 1))
  fi

  FILES=${f[i]};     i=$((i + 1))
  FILEQUOTA=${f[i]}; i=$((i + 1))
  FILELIMIT=${f[i]}; i=$((i + 1))
  FILEGRACE=''
  if [[ $FILES == *'*' ]]; then
    FILES=${FILES%'*'}
    FILEGRACE=${f[i]}
  fi

  local v
  for v in "$BLOCKS" "$PACQUOTA" "$HARDQUOTA" "$FILES" "$FILEQUOTA" "$FILELIMIT"; do
    if ! isNum "$v"; then
      ERR="Quota-Zeile nicht lesbar: $line"
      return 1
    fi
  done
}

################################################################
## Ermittlung: Speicherplatz pro Benutzer
################################################################

# Erste Spalte von du (KiB); leer bei Fehler
duKiB() {
  local kib rest
  read -r kib rest < <("$@" 2>/dev/null)
  isNum "$kib" && echo "$kib"
}

# Speicherplatz unter <base> ermitteln:
#   - Paketadmin: alles ausser ./users, plus ./users/_ (Sonderverzeichnis)
#   - jeder Benutzer <base>/users/<name>, gemessen als $PAC-<name>
# <hide_empty>=1 blendet Benutzer mit 0 KiB aus.
collectSpace() {
  local base=$1 hide_empty=$2 kib dir name user

  NAMES=()
  SPACES=()

  kib=$(cd "$base" && duKiB du -s --exclude=./users .)
  if [[ -z $kib ]]; then
    ERR="du fuer $base fehlgeschlagen"
    return 1
  fi
  if [[ -d $base/users/_ ]]; then
    local extra
    extra=$(cd "$base/users" && duKiB du -s -- _)
    kib=$((kib + ${extra:-0}))
  fi
  NAMES+=("$PAC")
  SPACES+=("$kib")

  for dir in "$base"/users/*/; do
    dir=${dir%/}
    name=${dir##*/}
    [[ $name == _ ]] && continue

    user="$PAC-$name"
    id -u "$user" >/dev/null 2>&1 || continue

    kib=$(cd "$base/users" && duKiB sudo -n -u "$user" du -s -- "$name")
    if [[ -z $kib ]]; then
      warn "Speicherplatz von $user nicht ermittelbar (sudo-Recht?)"
      continue
    fi
    [[ $hide_empty == 1 && $kib -eq 0 ]] && continue

    NAMES+=("$user")
    SPACES+=("$kib")
  done
}

# Alles fuer einen Speicher ermitteln, abgeleitete Werte berechnen und
# im Datenmodell ablegen.
# Aufruf: collectStorage <key> <base-verzeichnis> <hide_empty>
collectStorage() {
  local key=$1 base=$2 hide_empty=$3 device idx spec total=0 users=''

  for spec in $PAC_FIELDS benutzer; do
    set_ "$key" "${spec%%:*}" ''
  done

  if [[ ! -d $base ]]; then
    set_ "$key" gebucht 0
    return
  fi
  set_ "$key" gebucht 1

  device=$(findmnt -n -o SOURCE --target "$base")

  ERR=''
  if ! readQuota "$device" || ! collectSpace "$base" "$hide_empty"; then
    set_ "$key" fehler "$ERR"
    warn "$ERR"
    return
  fi

  for idx in "${!SPACES[@]}"; do
    total=$((total + SPACES[idx]))
  done
  for idx in "${!SPACES[@]}"; do
    users+="${NAMES[idx]};$(mb "${SPACES[idx]}");$(pct "${SPACES[idx]}" "$total");$(pct "${SPACES[idx]}" "$PACQUOTA")"$'\n'
  done

  set_ "$key" summe_mb               "$(mb "$total")"
  set_ "$key" summe_anteil_quota_pct "$(pct "$total" "$PACQUOTA")"
  set_ "$key" belegt_mb              "$(mb "$BLOCKS")"
  set_ "$key" quota_mb               "$(mb "$PACQUOTA")"
  set_ "$key" limit_mb               "$(mb "$HARDQUOTA")"
  set_ "$key" belegt_pct             "$(pct "$BLOCKS" "$PACQUOTA")"
  set_ "$key" limit_pct              "$(pct "$BLOCKS" "$HARDQUOTA")"
  set_ "$key" ueber_quota            "$(over "$BLOCKS" "$PACQUOTA")"
  set_ "$key" grace                  "$BLOCKGRACE"
  set_ "$key" inodes                 "$FILES"
  set_ "$key" inodes_quota           "$FILEQUOTA"
  set_ "$key" inodes_limit           "$FILELIMIT"
  set_ "$key" inodes_pct             "$(pct "$FILES" "$FILEQUOTA")"
  set_ "$key" inodes_limit_pct       "$(pct "$FILES" "$FILELIMIT")"
  set_ "$key" inodes_ueber_quota     "$(over "$FILES" "$FILEQUOTA")"
  set_ "$key" inodes_grace           "$FILEGRACE"
  set_ "$key" benutzer               "$users"
}

# Werte eines Benutzers auf einem Speicher: "<mb>;<anteil_summe>;<anteil_quota>"
# leer (";;") wenn der Benutzer dort nicht erfasst ist
userValues() {
  local v
  v=$(get "$1" benutzer | awk -F';' -v n="$2" '$1 == n { print $2 ";" $3 ";" $4; exit }')
  printf '%s' "${v:-;;}"
}

# alle Benutzer beider Speicher, Reihenfolge wie erfasst, jeder einmal
allUsers() {
  local key
  for key in $STORAGE_KEYS; do get "$key" benutzer; done | awk -F';' 'NF && !seen[$1]++ { print $1 }'
}

################################################################
## Ausgabe: visual
################################################################

# Wert oder Ersatztext, wenn leer
orText() { if [[ -n $1 ]]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
yesNo()  { case $1 in 1) printf 'ja' ;; 0) printf 'nein' ;; *) printf -- '-' ;; esac; }

tableRow() {
  printf ' %-31.31s | %16s | %8s %% | %8s %% |\n' "$1" "$(orText "$2" -)" "$(orText "$3" -)" "$(orText "$4" -)"
}

# Zeile der Quotauebersicht: <label> <wert> <einheit> [<pct> <text hinter %>]
overviewRow() {
  if [[ -n $5 ]]; then
    printf ' %-30s %16s %-4s (%6s %% %s)\n' "$1" "$2" "$3" "$(orText "$4" -)" "$5"
  else
    printf ' %-30s %16s %s\n' "$1" "$2" "$3"
  fi
}

visualStorage() {
  local key=$1 title=$2 name size share qpct

  echo " $title"
  dividerLine

  if [[ $(get "$key" gebucht) != 1 ]]; then
    echo " nicht gebucht"
    return
  fi
  if [[ -n $(get "$key" fehler) ]]; then
    echo " FEHLER: $(get "$key" fehler)"
    return
  fi

  if [[ $NO_USER == 1 ]]; then
    # ohne Benutzertabelle nur die Summe
    overviewRow "Summe Benutzer (laut du):" "$(get "$key" summe_mb)" MB \
      "$(get "$key" summe_anteil_quota_pct)" "der Quota"
  else
    printf ' %-31s | %16s | %10s | %10s |\n' "" "Speicherplatz" "Anteil an" "Anteil an"
    printf ' %-31s | %16s | %10s | %10s |\n' "Benutzername" "(MB)" "Summe" "Quota"
    dividerLine
    while IFS=';' read -r name size share qpct; do
      [[ -z $name ]] && continue
      tableRow "$name" "$size" "$share" "$qpct"
    done <<<"$(get "$key" benutzer)"
    dividerLine
    tableRow "Summe" "$(get "$key" summe_mb)" "$(pct "$(get "$key" summe_mb)" "$(get "$key" summe_mb)")" \
      "$(get "$key" summe_anteil_quota_pct)"
  fi

  doubleDividerLine
  echo " Quotauebersicht (laut quota)"
  dividerLine

  overviewRow "Belegter Plattenplatz:" "$(get "$key" belegt_mb)" MB
  overviewRow "Paketquota:"   "$(get "$key" quota_mb)" MB "$(get "$key" belegt_pct)" "belegt"
  overviewRow "Hartes Limit:" "$(get "$key" limit_mb)" MB "$(get "$key" limit_pct)" "belegt"
  overviewRow "Quota ueberschritten:" "$(yesNo "$(get "$key" ueber_quota)")" ""
  overviewRow "GraceTime:" "$(orText "$(get "$key" grace)" keine)" ""
  [[ $(get "$key" ueber_quota) == 1 ]] && echo " WARNUNG: Paketquota ueberschritten!"

  dividerLine

  overviewRow "Belegte 'inodes':" "$(get "$key" inodes)" ""
  overviewRow "'inode' Quota:" "$(get "$key" inodes_quota)" "" "$(get "$key" inodes_pct)" "belegt"
  overviewRow "'inode' Limit:" "$(get "$key" inodes_limit)" "" "$(get "$key" inodes_limit_pct)" "belegt"
  overviewRow "'inode' Quota ueberschritten:" "$(yesNo "$(get "$key" inodes_ueber_quota)")" ""
  overviewRow "GraceTime:" "$(orText "$(get "$key" inodes_grace)" keine)" ""
  [[ $(get "$key" inodes_ueber_quota) == 1 ]] && echo " WARNUNG: 'inode' Quota ueberschritten!"
  return 0
}

outputVisual() {
  doubleDividerLine
  echo " Speicherplatzuebersicht fuer Paket '$PAC'"
  echo " Stand: $NOW_HUMAN"
  dividerLine
  visualStorage ssd "Speicherplatzuebersicht NVMe SSD"
  doubleDividerLine
  echo ""
  visualStorage hdd "Speicherplatzuebersicht HDD Storage"
  doubleDividerLine
}

################################################################
## Ausgabe: json
################################################################

# Wert als JSON je nach Typ (s/n/b); leer wird null
jsonVal() {
  local type=$1 v=$2
  if [[ -z $v ]]; then printf 'null'; return; fi
  case $type in
    n) printf '%s' "$v" ;;
    b) if [[ $v == 1 ]]; then printf 'true'; else printf 'false'; fi ;;
    *) v=${v//\\/\\\\}; v=${v//\"/\\\"}; printf '"%s"' "$v" ;;
  esac
}

jsonStorage() {
  local key=$1 spec sep='' name size share qpct

  printf '    "%s": {' "$key"
  for spec in $PAC_FIELDS; do
    printf '%s\n      "%s": %s' "$sep" "${spec%%:*}" "$(jsonVal "${spec#*:}" "$(get "$key" "${spec%%:*}")")"
    sep=','
  done
  if [[ $NO_USER == 1 ]]; then
    printf '\n    }'
    return
  fi
  sep=''
  printf ',\n      "benutzer": ['
  while IFS=';' read -r name size share qpct; do
    [[ -z $name ]] && continue
    printf '%s\n        { "name": %s, "mb": %s, "anteil_summe_pct": %s, "anteil_quota_pct": %s }' \
      "$sep" "$(jsonVal s "$name")" "$(jsonVal n "$size")" "$(jsonVal n "$share")" "$(jsonVal n "$qpct")"
    sep=','
  done <<<"$(get "$key" benutzer)"
  if [[ -n $sep ]]; then printf '\n      ]\n    }'; else printf ']\n    }'; fi
}

outputJson() {
  local key sep=''
  printf '{\n'
  printf '  "paket": %s,\n' "$(jsonVal s "$PAC")"
  printf '  "datum": %s,\n' "$(jsonVal s "$NOW_ISO")"
  printf '  "einheit": "MB",\n'
  printf '  "speicher": {\n'
  for key in $STORAGE_KEYS; do
    printf '%s' "$sep"
    jsonStorage "$key"
    sep=$',\n'
  done
  printf '\n  }\n}\n'
}

################################################################
## Ausgabe: table (CSV)
################################################################

csvHeader() {
  local key f spec line='datum;paket;typ;benutzer'
  for key in $STORAGE_KEYS; do
    for f in $USER_FIELDS; do line+=";${key}_$f"; done
  done
  for key in $STORAGE_KEYS; do
    for spec in $PAC_FIELDS; do line+=";${key}_${spec%%:*}"; done
  done
  echo "$line"
}

# CSV-Feld: ; und Zeilenumbrueche aus Texten entfernen
csvVal() { local v=${1//;/,}; printf '%s' "${v//$'\n'/ }"; }

outputTable() {
  local name key spec line empty=''

  for key in $STORAGE_KEYS; do
    for spec in $PAC_FIELDS; do empty+=';'; done
  done

  while [[ $NO_USER != 1 ]] && read -r name; do
    [[ -z $name ]] && continue
    line="$NOW_ISO;$PAC;benutzer;$name"
    for key in $STORAGE_KEYS; do line+=";$(userValues "$key" "$name")"; done
    echo "$line$empty"
  done <<<"$(allUsers)"

  line="$NOW_ISO;$PAC;paket;"
  for key in $STORAGE_KEYS; do
    line+=";$(get "$key" summe_mb);$(pct "$(get "$key" summe_mb)" "$(get "$key" summe_mb)");$(get "$key" summe_anteil_quota_pct)"
  done
  for key in $STORAGE_KEYS; do
    for spec in $PAC_FIELDS; do line+=";$(csvVal "$(get "$key" "${spec%%:*}")")"; done
  done
  echo "$line"
}

########################################
##  Hauptprogramm
#######################################

FORMAT=visual
NO_USER=0
while [[ $# -gt 0 ]]; do
  case $1 in
    -v|--visual)    FORMAT=visual ;;
    -j|--json)      FORMAT=json ;;
    -t|--table)     FORMAT=table ;;
    --table-header) csvHeader; exit 0 ;;
    --no-user)      NO_USER=1 ;;
    -h|--help)      usage; exit 0 ;;
    *)              echo "FEHLER: unbekannte Option '$1'" >&2; usage >&2; exit 2 ;;
  esac
  shift
done

if [[ $PAC == *-* ]]; then
  echo "FEHLER: das Skript muss vom Paketbenutzer ${PAC%%-*} ausgefuehrt werden!" >&2
  exit 1
fi

# ein Zeitpunkt fuer alle Ausgaben
IFS='|' read -r NOW_HUMAN NOW_ISO <<<"$(date +'%d.%m.%Y -- %H:%M|%Y-%m-%dT%H:%M:%S%z')"
NOW_ISO="${NOW_ISO%??}:${NOW_ISO#"${NOW_ISO%??}"}"

# 1. Ermittlung
collectStorage ssd ~ 0
collectStorage hdd "/home/storage/$PAC" 1

# 2. Ausgabe
case $FORMAT in
  visual) outputVisual ;;
  json)   outputJson ;;
  table)  outputTable ;;
esac

exit 0
