#!/bin/bash
# LibrePortal storage-location helper — the ONLY writer of the root-owned
# storage registry, and the only thing that may hand a new directory to the
# container user.
#
# Why this exists: with storage locations, the set of paths root will chown is no
# longer fixed at install. If that set came from a manager-writable config, the
# manager could aim a root `chown -R dockerinstall` at /etc and own the box. So
# the registry lives root:root here, this script is its only writer, and every
# candidate must clear the admission rules below before it is accepted.
#
# The rule that makes it safe:
#
#     root only ever chowns a directory that is EMPTY.
#
# An empty directory contains nothing to give away, so acceptance cannot transfer
# anything that already existed. Everything created underneath afterwards is ours
# by construction. The one relaxation — a directory already carrying OUR marker,
# so a drive full of app data can be adopted — costs nothing: writing that marker
# requires write access you would have had to already possess.
#
# Self-contained ON PURPOSE: it must NOT source any manager-owned code (incl.
# paths.sh), or it would re-open the very escalation it exists to close. init.sh
# bakes the roots and the manager name into the installed copy.
#
# Actions:
#   add <path> [name]   validate, accept, mark, chown, append to the registry
#   remove <id|path>    drop a location (refuses while app dirs remain)
#   list                print the registry (id<TAB>path<TAB>dev<TAB>uuid)
#   verify [id]         re-check marker + device of one/all locations
#   path <id>           print one location's path

set -u

[[ $EUID -eq 0 ]] || { echo "libreportal-storage: must run as root" >&2; exit 1; }

# Baked by init.sh at install (placeholders replaced). An unbaked copy still
# contains the "__" sentinel, which no real absolute path does.
MANAGER="__MANAGER__"
SYSTEM_DIR="__SYSTEM_DIR__"
CONTAINERS_DIR="__CONTAINERS_DIR__"
BACKUPS_DIR="__BACKUPS_DIR__"
[[ "$MANAGER"        == *"__"* || -z "$MANAGER"        ]] && MANAGER="libreportal"
[[ "$SYSTEM_DIR"     == *"__"* || -z "$SYSTEM_DIR"     ]] && SYSTEM_DIR="/libreportal-system"
[[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers"
[[ "$BACKUPS_DIR"    == *"__"* || -z "$BACKUPS_DIR"    ]] && BACKUPS_DIR="/libreportal-backups"

LIB_DIR="/usr/local/lib/libreportal"
REGISTRY="$LIB_DIR/storage.roots"
MARKER=".libreportal-storage"
DB_CFG="$SYSTEM_DIR/configs/general/general_docker_install"

# Paths that must never become a storage location, whatever the caller says.
# /home is excluded here and only reachable with --allow-home (see _protected).
PROTECTED=(/ /etc /usr /bin /sbin /lib /lib32 /lib64 /libx32 /boot /proc /sys
           /dev /run /var /tmp /root /home /srv/../ /media/../)

_err() { echo "libreportal-storage: $*" >&2; }

_mode() {
    local m
    m=$(grep -h '^CFG_DOCKER_INSTALL_TYPE=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
    echo "${m:-rootless}"
}

_container_owner() {
    local appusr=""
    if [[ "$(_mode)" == "rootless" ]]; then
        appusr=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
        if [[ -n "$appusr" ]] && id -u "$appusr" >/dev/null 2>&1; then echo "$appusr"; return; fi
        echo "dockerinstall"; return
    fi
    echo "$MANAGER"
}

_install_id() {
    # Stable per-install identity, so an adopted drive can say which install
    # wrote it. Derived from the machine id; never a secret.
    local mid=""
    [[ -r /etc/machine-id ]] && mid=$(cat /etc/machine-id 2>/dev/null)
    [[ -z "$mid" && -r /var/lib/dbus/machine-id ]] && mid=$(cat /var/lib/dbus/machine-id 2>/dev/null)
    echo "${mid:-unknown}" | cut -c1-16
}

_protected() {
    local d="$1" allow_home="$2" p
    for p in "${PROTECTED[@]}"; do
        [[ "$p" == */../ ]] && continue
        if [[ "$d" == "$p" ]]; then
            [[ "$allow_home" == "1" && "$p" == "/home" ]] && continue
            return 0
        fi
        # Inside a protected tree. /home is special: allowed with --allow-home,
        # matching init.sh's existing --allow-home for the install-time roots.
        if [[ "$d" == "$p"/* ]]; then
            [[ "$allow_home" == "1" && "$p" == "/home" ]] && continue
            return 0
        fi
    done
    return 1
}

# Refuse a candidate that nests with any root we already know about, in EITHER
# direction. A storage location containing a backup repo (or the reverse) is a
# recursive-inclusion trap: the backup engine would walk a tree holding its own
# repository.
_nests() {
    local d="$1" other
    local -a known=("$SYSTEM_DIR" "$CONTAINERS_DIR" "$BACKUPS_DIR")
    local _id _path _rest
    if [[ -r "$REGISTRY" ]]; then
        while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
            [[ -z "$_path" || "$_id" == \#* ]] && continue
            known+=("${_path%/}")
        done < "$REGISTRY"
    fi
    for other in "${known[@]}"; do
        other="${other%/}"
        [[ -z "$other" ]] && continue
        if [[ "$d" == "$other" || "$d" == "$other"/* || "$other" == "$d"/* ]]; then
            echo "$other"; return 0
        fi
    done
    return 1
}

# Empty means: nothing but lost+found (a filesystem's own artefact) and our own
# marker. Anything else and we refuse — see the header.
_is_empty_enough() {
    local d="$1" e
    shopt -s nullglob dotglob
    for e in "$d"/*; do
        e="${e##*/}"
        [[ "$e" == "lost+found" || "$e" == "$MARKER" ]] && continue
        shopt -u nullglob dotglob
        return 1
    done
    shopt -u nullglob dotglob
    return 0
}

_has_marker() { [[ -f "$1/$MARKER" ]]; }

_next_id() {
    local max=0 _id _rest
    if [[ -r "$REGISTRY" ]]; then
        while IFS=$'\t' read -r _id _rest || [[ -n "$_id" ]]; do
            [[ "$_id" =~ ^[0-9]+$ ]] || continue
            (( _id > max )) && max=$_id
        done < "$REGISTRY"
    fi
    echo $(( max + 1 ))
}

_dev_of()  { stat -c '%d' -- "$1" 2>/dev/null || echo 0; }
_uuid_of() { findmnt -no UUID --target "$1" 2>/dev/null | tail -1; }

# Probe whether a filesystem can actually hold app data: POSIX ownership, the
# high sub-UIDs rootless containers map into, and a write that reads back.
#
# Runs as root because it must: for a CANDIDATE the directory is not ours yet
# (a fresh /mnt/disk is root-owned 0755), so an unprivileged probe can only ever
# report "cannot create a directory here" — which says nothing about the
# filesystem. The probe creates one uniquely-named directory, tests it, and
# removes it; the path is validated by the same protected-path rules as `add`
# before anything is created.
#
# Prints one <severity>\t<check>\t<message> record per finding, matching the
# manager-side checks. Exit non-zero if anything refused.
probe() {
    local raw="${1:-}" allow_home=0
    [[ "${2:-}" == "--allow-home" ]] && allow_home=1
    [[ -n "$raw" ]] || { _err "probe requires a path"; return 2; }
    [[ "$raw" == /* ]] || { _err "path must be absolute"; return 2; }

    local d
    d=$(realpath -e -- "$raw" 2>/dev/null) || { echo -e "refuse\tpath\tNo such directory: $raw"; return 1; }
    d="${d%/}"
    if _protected "$d" "$allow_home"; then
        echo -e "refuse\tprotected\tInside a protected system path."
        return 1
    fi

    local t="$d/.lp-storage-probe.$$"
    local rc=0
    if ! mkdir -p "$t" 2>/dev/null; then
        echo -e "refuse\twritable\tCannot create a directory here."
        return 1
    fi

    local cowner; cowner=$(_container_owner)
    if ! chown "$cowner:$cowner" "$t" 2>/dev/null; then
        echo -e "refuse\townership\tCannot set file ownership here (an NFS export with root_squash, or a filesystem without POSIX ownership)."
        rc=1
    fi

    if (( rc == 0 )) && ! chown 165536:165536 "$t" 2>/dev/null; then
        echo -e "refuse\tsubuid\tCannot store the high UIDs rootless containers use (tried 165536)."
        rc=1
    fi

    if (( rc == 0 )); then
        if ! echo libreportal > "$t/probe" 2>/dev/null; then
            echo -e "refuse\twrite\tWrite failed."
            rc=1
        else
            sync -f "$t/probe" 2>/dev/null || true
            if [[ "$(cat "$t/probe" 2>/dev/null)" != "libreportal" ]]; then
                echo -e "refuse\treadback\tWrote a file but read back different content — the device may be failing."
                rc=1
            fi
        fi
    fi

    rm -rf -- "$t"
    return $rc
}

# Make a mount permanent by adding one line to /etc/fstab.
#
# fstab is the most dangerous file LibrePortal touches: a bad entry does not
# break an app, it can leave the machine unbootable and needing rescue media.
# Everything below exists to make that impossible:
#
#   * nofail + x-systemd.device-timeout=10s — a missing device can then never
#     block boot. This single pair is what makes writing fstab defensible at
#     all; without it an unplugged drive strands the box at a systemd timeout
#     or drops it to emergency mode.
#   * UUID=, never /dev/sdX — device names reorder between boots.
#   * append only, inside a marked block. Existing lines are never rewritten,
#     so anything the user or another tool manages is untouched.
#   * refuse when a line for that target or device already exists — we do not
#     get to be the second opinion on a mount someone else configured.
#   * a timestamped backup, and `findmnt --verify` before the new file is put
#     in place. A file that does not verify is discarded, not installed.
#
# Opt-in only: nothing calls this unless a person asked for it.
fstab_add() {
    local target="${1:-}"
    [[ -n "$target" && "$target" == /* ]] \
        || { _err "fstab-add requires an absolute mount point"; return 2; }
    target="${target%/}"
    [[ "$target" == *..* ]] && { _err "invalid mount point"; return 2; }

    command -v findmnt >/dev/null 2>&1 || { _err "findmnt unavailable"; return 1; }

    # Refuse the root filesystem before anything else, so the reason given is the
    # real one rather than a confusing "not a mount point".
    [[ "$target" == "/" || -z "$target" ]] \
        && { _err "refusing to touch the root filesystem's fstab entry"; return 1; }

    # Must currently be a real mount — we describe reality, we do not invent it.
    local now_target
    now_target=$(findmnt -no TARGET --target "$target" 2>/dev/null | tail -1)
    if [[ "$now_target" != "$target" ]]; then
        _err "'$target' is not a mount point right now. Mount it first, then add it."
        return 1
    fi

    local uuid fstype
    uuid=$(findmnt -no UUID   --target "$target" 2>/dev/null | tail -1)
    fstype=$(findmnt -no FSTYPE --target "$target" 2>/dev/null | tail -1)
    if [[ -z "$uuid" ]]; then
        _err "'$target' has no filesystem UUID, so no stable fstab entry can be written for it."
        return 1
    fi
    [[ -z "$fstype" ]] && fstype="auto"

    # Already described? Leave it alone — do not add a second opinion.
    local existing
    existing=$(grep -vE '^[[:space:]]*#' /etc/fstab 2>/dev/null \
        | awk -v t="$target" -v u="UUID=$uuid" '$2==t || $1==u {print; exit}')
    if [[ -n "$existing" ]]; then
        _err "/etc/fstab already has an entry for this mount: $existing"
        return 1
    fi

    local line="UUID=$uuid $target $fstype defaults,nofail,x-systemd.device-timeout=10s 0 2"

    local stamp backup tmp
    stamp=$(date +%Y%m%d-%H%M%S)
    backup="/etc/fstab.libreportal-$stamp.bak"
    cp -a /etc/fstab "$backup" || { _err "could not back up /etc/fstab"; return 1; }

    tmp=$(mktemp) || return 1
    cat /etc/fstab > "$tmp"
    # Guarantee the file ends with a newline before appending, or the new entry
    # would be glued onto whatever the last line was.
    [[ -s "$tmp" && -n "$(tail -c1 "$tmp")" ]] && printf '\n' >> "$tmp"
    {
        printf '\n# Added by LibrePortal (%s) — storage location %s\n' "$stamp" "$target"
        printf '# nofail: a missing device must never block boot.\n'
        printf '%s\n' "$line"
    } >> "$tmp"

    # Verify BEFORE installing. findmnt --verify parses fstab and reports
    # structural problems; a file that does not pass is thrown away.
    if ! findmnt --verify --tab-file "$tmp" >/dev/null 2>&1; then
        local why; why=$(findmnt --verify --tab-file "$tmp" 2>&1 | head -5)
        rm -f "$tmp"
        _err "the resulting /etc/fstab did not verify, so it was NOT installed: $why"
        return 1
    fi

    install -m 0644 -o root -g root "$tmp" /etc/fstab || { rm -f "$tmp"; return 1; }
    rm -f "$tmp"

    # Let systemd pick up the new unit now, so the entry is live rather than
    # only true after the next boot.
    systemctl daemon-reload >/dev/null 2>&1 || true

    echo "$line"
    return 0
}

add() {
    local raw="" name="" allow_home=0 a
    for a in "$@"; do
        case "$a" in
            --allow-home) allow_home=1 ;;
            --name=*)     name="${a#--name=}" ;;
            -*)           _err "unknown option $a"; return 2 ;;
            *)            [[ -z "$raw" ]] && raw="$a" || name="$a" ;;
        esac
    done
    [[ -n "$raw" ]] || { _err "add requires a path"; return 2; }

    # --- absolute, and free of symlinks/.. ---------------------------------
    [[ "$raw" == /* ]] || { _err "path must be absolute (got '$raw')"; return 1; }
    local d
    d=$(realpath -e -- "$raw" 2>/dev/null) || { _err "no such directory: $raw"; return 1; }
    d="${d%/}"
    if [[ "$d" != "${raw%/}" ]]; then
        _err "refusing '$raw' — it resolves to '$d' (symlinked or non-canonical). Register the real path."
        return 1
    fi
    [[ -d "$d" ]] || { _err "not a directory: $d"; return 1; }

    # --- protected system paths --------------------------------------------
    if _protected "$d" "$allow_home"; then
        _err "refusing '$d' — inside a protected system path."
        return 1
    fi

    # --- no nesting with any root we know ----------------------------------
    local clash
    if clash=$(_nests "$d"); then
        # Re-adding the same path is idempotent when it already carries our marker.
        if [[ "$clash" == "$d" ]] && _has_marker "$d"; then
            local existing
            existing=$(awk -F'\t' -v p="$d" '$2==p{print $1}' "$REGISTRY" 2>/dev/null | head -1)
            [[ -n "$existing" ]] && { echo "$existing"; return 0; }
        fi
        _err "refusing '$d' — it nests with '$clash'. Use a sibling directory (e.g. '$d/apps') instead."
        return 1
    fi

    # --- the parent must not be manager-writable ---------------------------
    # Closes the validate-then-chown race: if the manager can rename or replace
    # the directory between the checks below and the chown, the checks prove
    # nothing. /mnt, /srv, /media are root-owned, which is the intended home.
    local parent="${d%/*}"; [[ -z "$parent" ]] && parent="/"
    if [[ -w "$parent" ]] && sudo -u "$MANAGER" test -w "$parent" 2>/dev/null; then
        _err "refusing '$d' — its parent '$parent' is writable by $MANAGER, which would make the safety checks racy. Use a location under a root-owned parent such as /mnt or /srv."
        return 1
    fi

    # --- empty, or already ours --------------------------------------------
    if ! _is_empty_enough "$d"; then
        if _has_marker "$d"; then
            : # adopt: it is already a LibrePortal storage location
        else
            _err "refusing '$d' — it is not empty. Root only ever takes ownership of an empty directory. Create an empty subdirectory (e.g. '$d/apps') and register that."
            return 1
        fi
    fi

    # --- accept -------------------------------------------------------------
    local id
    id=$(_next_id)
    local cowner; cowner=$(_container_owner)

    umask 022
    mkdir -p "$LIB_DIR"
    {
        echo "# LibrePortal storage location. Managed by libreportal-storage; do not edit."
        echo "location_id=$id"
        echo "install_id=$(_install_id)"
        echo "created=$(date -Iseconds)"
        echo "name=${name:-location-$id}"
    } > "$d/$MARKER"
    chown root:root "$d/$MARKER"
    chmod 0644 "$d/$MARKER"

    chown "$cowner:$cowner" "$d"
    chmod 0751 "$d"

    printf '%s\t%s\t%s\t%s\n' "$id" "$d" "$(_dev_of "$d")" "$(_uuid_of "$d")" >> "$REGISTRY"
    chown root:root "$REGISTRY"
    chmod 0644 "$REGISTRY"

    echo "$id"
}

remove() {
    local want="${1:-}"
    [[ -n "$want" ]] || { _err "remove requires an id or path"; return 2; }
    [[ -r "$REGISTRY" ]] || { _err "no storage registry"; return 1; }

    local _id _path _dev _uuid found_path="" found_id=""
    while IFS=$'\t' read -r _id _path _dev _uuid || [[ -n "$_id" ]]; do
        [[ -z "$_path" || "$_id" == \#* ]] && continue
        if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then
            found_path="${_path%/}"; found_id="$_id"; break
        fi
    done < "$REGISTRY"
    [[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }

    # Refuse while app data remains. A location whose drive is absent cannot be
    # proven empty, so refuse that too rather than dropping the only record of
    # where those apps live.
    if [[ ! -f "$found_path/$MARKER" ]]; then
        _err "refusing to remove location $found_id — '$found_path' has no marker, so its drive is probably not mounted. Mount it first, or the apps on it would be orphaned."
        return 1
    fi
    local e leftovers=0
    shopt -s nullglob
    for e in "$found_path"/*/; do
        [[ -d "$e" ]] && leftovers=$((leftovers + 1))
    done
    shopt -u nullglob
    if (( leftovers > 0 )); then
        _err "refusing to remove location $found_id — '$found_path' still holds $leftovers app director$( ((leftovers==1)) && echo y || echo ies). Move or uninstall them first."
        return 1
    fi

    local tmp; tmp=$(mktemp)
    awk -F'\t' -v id="$found_id" '$1!=id' "$REGISTRY" > "$tmp"
    cat "$tmp" > "$REGISTRY"
    rm -f "$tmp"
    chown root:root "$REGISTRY"; chmod 0644 "$REGISTRY"
    rm -f "$found_path/$MARKER"
    echo "$found_id"
}

list() {
    [[ -r "$REGISTRY" ]] || return 0
    cat "$REGISTRY"
}

path() {
    local want="${1:-}"
    [[ -r "$REGISTRY" ]] || return 1
    awk -F'\t' -v id="$want" '$1==id{print $2; found=1} END{exit !found}' "$REGISTRY"
}

# Re-check a location: marker present (i.e. drive mounted) and still the same
# filesystem it was registered on. Prints "<id> <state> <path>" per location.
verify() {
    local only="${1:-}"
    [[ -r "$REGISTRY" ]] || return 0
    local _id _path _dev _uuid state now_uuid
    while IFS=$'\t' read -r _id _path _dev _uuid || [[ -n "$_id" ]]; do
        [[ -z "$_path" || "$_id" == \#* ]] && continue
        [[ -n "$only" && "$only" != "$_id" ]] && continue
        if [[ ! -f "${_path%/}/$MARKER" ]]; then
            state="unmounted"
        else
            now_uuid=$(_uuid_of "${_path%/}")
            if [[ -n "$_uuid" && -n "$now_uuid" && "$_uuid" != "$now_uuid" ]]; then
                state="different-device"
            else
                state="ok"
            fi
        fi
        printf '%s\t%s\t%s\n' "$_id" "$state" "${_path%/}"
    done < "$REGISTRY"
}

action="${1:-}"; shift 2>/dev/null || true
case "$action" in
    add)     add "$@" ;;
    probe)   probe "${1:-}" "${2:-}" ;;
    fstab-add) fstab_add "${1:-}" ;;
    remove)  remove "${1:-}" ;;
    list)    list ;;
    path)    path "${1:-}" ;;
    verify)  verify "${1:-}" ;;
    *) echo "usage: libreportal-storage {add <path> [--name=NAME] [--allow-home]|probe <path> [--allow-home]|fstab-add <mountpoint>|remove <id|path>|list|path <id>|verify [id]}" >&2; exit 2 ;;
esac
