#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>

# SecuBox-Deb :: mastodonctl — seule surface privilegiee du module.
#
# Rend du JSON, comme les autres ctl du projet : ce qui est lisible par un
# humain doit rester consommable par un panneau.
#
# CE QU'IL NE FAIT PAS, ET POURQUOI :
#
#   Il ne genere aucun mot de passe et n'en affiche aucun qu'il aurait invente.
#   Un secret produit par un outil finit dans un journal, un historique de shell
#   ou une capture d'ecran. Les secrets de l'instance sont generes DANS le
#   conteneur et restent dans /etc/secubox/secrets, chmod 600.
#
#   Il n'ouvre aucun port. Le cantonnement vit dans nftables et HAProxy ; une
#   garde posee dans un outil d'administration serait contournee par tout autre
#   appelant.

set -uo pipefail

readonly VERSION="0.1.0"
readonly CONF="${SECUBOX_MASTODON_CONF:-/etc/secubox/mastodon.toml}"
readonly LXC_PATH="${SECUBOX_LXC_PATH:-/data/lxc}"
readonly INSTALL="${SECUBOX_MASTODON_INSTALL:-/usr/share/secubox/lib/mastodon/install-lxc.sh}"

_toml() { # _toml section cle defaut
    python3 - "$CONF" "$1" "$2" "$3" <<'PY'
import sys, re
chemin, section, cle, defaut = sys.argv[1:5]
cur, val = "", None
try:
    for l in open(chemin):
        l = l.strip()
        if l.startswith("#") or not l: continue
        m = re.match(r"^\[(.+)\]$", l)
        if m: cur = m.group(1); continue
        if cur == section and "=" in l:
            k, v = (x.strip() for x in l.split("=", 1))
            if k == cle: val = v.strip('"\'').split("#")[0].strip()
except OSError:
    pass
print(val if val else defaut)
PY
}

_nom()  { _toml lxc nom mastodon; }
_ip()   { _toml lxc ip 10.100.0.200; }
_dom()  { _toml instance domain ""; }

_lxc_etat() {
    lxc-info -n "$(_nom)" -P "$LXC_PATH" -s 2>/dev/null | awk '{print tolower($2)}' | head -1
}

# ── status ──────────────────────────────────────────────────────────────────
#
# QUATRE CHOSES DISTINCTES, et les confondre egare :
#   le paquet est-il installe, le CONTENEUR existe-t-il, TOURNE-t-il, et
#   l'instance REPOND-elle ? Un conteneur allume dont Puma est mort n'est pas
#   « en marche » ; le dire evite de chercher la panne dans le reseau.
cmd_status() {
    local nom ip dom etat joignable=false vers=""
    nom=$(_nom); ip=$(_ip); dom=$(_dom)
    etat=$(_lxc_etat); [ -n "$etat" ] || etat="absent"
    if [ "$etat" = "running" ]; then
        # L'EN-TETE Host EST INDISPENSABLE : Rails refuse un hote inattendu
        # (403), et Mastodon redirige vers HTTPS (301) sans X-Forwarded-Proto.
        # Sans ces deux en-tetes la sonde conclut « injoignable » sur une
        # instance parfaitement vivante — et le panneau ment.
        curl -fsS --max-time 6 \
            -H "Host: ${dom:-localhost}" -H "X-Forwarded-Proto: https" \
            "http://$ip:3000/api/v1/instance" -o /tmp/.mstd.$$ 2>/dev/null && joignable=true
        [ -s /tmp/.mstd.$$ ] && vers=$(python3 -c "
import json;print(json.load(open('/tmp/.mstd.$$')).get('version',''))" 2>/dev/null)
        rm -f /tmp/.mstd.$$
    fi
    python3 - "$nom" "$ip" "$dom" "$etat" "$joignable" "$vers" "$VERSION" "$CONF" <<'PY'
import json, os, sys
nom, ip, dom, etat, joignable, vers, v, conf = sys.argv[1:9]
print(json.dumps({
    "ok": True, "version": v,
    "conteneur": {"nom": nom, "ip": ip, "etat": etat},
    "instance": {"domaine": dom, "joignable": joignable == "true", "version": vers or None},
    "configure": os.path.exists(conf),
    # `pret` ne dit PAS « tout va bien » : il dit que les quatre conditions
    # sont reunies. Chacune manquante appelle un geste different, et le panneau
    # les montre separement.
    "pret": etat == "running" and joignable == "true" and bool(dom),
}, ensure_ascii=False))
PY
}

# ── install ─────────────────────────────────────────────────────────────────
cmd_install() {
    [ -f "$CONF" ] || { printf '{"ok":false,"error":"%s absent — copiez mastodon.toml.example et renseignez le domaine"}\n' "$CONF"; return 2; }
    local dom; dom=$(_dom)
    # LE DOMAINE EST IRREVERSIBLE : il est grave dans chaque adresse
    # d'utilisateur et dans les signatures echangees avec les autres instances.
    # Refuser ici coute une minute ; s'en apercevoir apres la premiere
    # federation coute les comptes distants qui vous suivent.
    [ -n "$dom" ] || { printf '{"ok":false,"error":"aucun domaine configure — il ne pourra plus etre change apres la premiere federation"}\n'; return 2; }
    [ -x "$INSTALL" ] || { printf '{"ok":false,"error":"script d installation absent: %s"}\n' "$INSTALL"; return 1; }
    printf '{"ok":true,"message":"provisionnement lance — suivez: journalctl -t mastodon-install -f"}\n'
    SECUBOX_LXC_NAME="$(_nom)" SECUBOX_LXC_IP="$(_ip)" SECUBOX_MASTODON_DOMAIN="$dom" \
        setsid "$INSTALL" >/dev/null 2>&1 &
}

cmd_start()   { lxc-start -n "$(_nom)" -P "$LXC_PATH" 2>/dev/null; sleep 2; cmd_status; }
cmd_stop()    { lxc-stop  -n "$(_nom)" -P "$LXC_PATH" -t 30 2>/dev/null; cmd_status; }

# ── invite ──────────────────────────────────────────────────────────────────
#
# Les inscriptions sont fermees par defaut : une instance ouverte sur internet
# se remplit de comptes automatises en quelques heures, et la moderation retombe
# sur quelqu'un.
cmd_invite() {
    [ "$(_lxc_etat)" = "running" ] || { printf '{"ok":false,"error":"conteneur arrete"}\n'; return 1; }
    # `tootctl invites` N'EXISTE PAS en Mastodon 4.7 — les invitations se creent
    # depuis l'interface ou par le modele. La version precedente appelait cette
    # sous-commande absente, recuperait « Could not find command "invites" » et
    # la rendait TELLE QUELLE comme lien d'invitation, avec ok:true : le
    # message d'erreur devenait la valeur, et l'appelant n'avait aucun moyen de
    # s'en apercevoir avant qu'un utilisateur ne clique dessus.
    local out
    out=$(lxc-attach -n "$(_nom)" -P "$LXC_PATH" -- su - mastodon -c \
        'cd /home/mastodon/live && RAILS_ENV=production bin/rails runner "\
u = User.where(role_id: UserRole.where(name: %w[Owner Admin]).select(:id)).first || User.first; \
abort(%(aucun compte proprietaire)) if u.nil?; \
i = Invite.create!(user: u, expires_at: 7.days.from_now, max_uses: nil); \
puts %(https://#{ENV[%(LOCAL_DOMAIN)]}/invite/#{i.code})"' 2>&1 | tail -1)
    # LE RESULTAT EST VERIFIE, pas suppose : seule une adresse d'invitation bien
    # formee vaut succes. N'importe quelle autre sortie — trace Ruby, conteneur
    # muet, migration en cours — est une erreur, et doit le dire.
    case "$out" in
        https://*/invite/?*)
            python3 -c "
import json,sys; print(json.dumps({'ok': True, 'invitation': sys.argv[1].strip(),
 'note': 'valable 7 jours ; le lien sert plusieurs fois'}, ensure_ascii=False))" "$out" ;;
        *)
            python3 -c "
import json,sys; print(json.dumps({'ok': False,
 'error': 'invitation non emise : ' + sys.argv[1].strip()}, ensure_ascii=False))" "$out"
            return 1 ;;
    esac
}


# ── lecture de la base ───────────────────────────────────────────────────────
#
# TOUTES LES REQUETES SONT BORNEES. Ce panneau interroge une instance qui sert
# de vrais utilisateurs : une requete sans `LIMIT` sur `accounts` ou `statuses`
# ferait payer l'affichage d'un tableau de bord a ceux qui lisent leur fil.
#
# LECTURE SEULE, SANS EXCEPTION. Ces sous-commandes n'ecrivent jamais ; les
# actions (invitation, demarrage) restent celles qui existaient deja.
_psql() { # _psql <sql>
    lxc-attach -n "$(_nom)" -P "$LXC_PATH" -- \
        su - postgres -c "psql -Atq -F '|' -c \"$1\" mastodon_production" 2>/dev/null
}

# Rend un JSON d'erreur homogene quand le conteneur est arrete. Une page qui
# affiche « 0 compte » sur une instance eteinte MENT ; elle doit dire qu'elle
# n'a pas pu regarder.
_exige_running() {
    local e; e=$(_lxc_etat)
    [ "$e" = "running" ] && return 0
    # NE JAMAIS TRADUIRE UN AVEUGLEMENT EN ABSENCE. `lxc-info` echoue pour un
    # non-root et rend une chaine VIDE : l'ancienne version en concluait
    # « conteneur arrete » et l'affichait sur une instance parfaitement vivante.
    # Un etat vide n'est pas un etat connu, et le dire coute une ligne.
    if [ -z "$e" ]; then
        printf '{"ok":false,"error":"etat du conteneur illisible — privileges insuffisants ?"}\n'
    else
        printf '{"ok":false,"error":"conteneur %s — aucune lecture possible"}\n' "$e"
    fi
    return 1
}

# ── comptes ─────────────────────────────────────────────────────────────────
#
# LA DERNIERE CONNEXION EST LA COLONNE QUI COMPTE. Un compte confirme qui ne
# s'est JAMAIS connecte est le motif classique de l'inscription automatisee :
# l'adresse est validee, le compte ne sert jamais. L'interface amont ne met pas
# cette information en avant ; ici, elle est en clair.
cmd_accounts() {
    _exige_running || return 1
    _psql "select a.username, coalesce(u.email,''), coalesce(r.name,'Utilisateur'), \
(u.confirmed_at is not null), coalesce(u.disabled,false), u.created_at::date, \
coalesce(u.current_sign_in_at::date::text,''), coalesce(st.statuses_count,0) \
from accounts a left join users u on u.account_id = a.id \
left join user_roles r on r.id = u.role_id \
left join account_stats st on st.account_id = a.id \
where a.domain is null order by u.created_at nulls last limit 200" \
    | python3 -c "
import sys, json
out = []
for l in sys.stdin.read().splitlines():
    c = l.split('|')
    if len(c) < 8: continue
    out.append({'compte': c[0], 'courriel': c[1], 'role': c[2],
                'confirme': c[3] == 't', 'desactive': c[4] == 't',
                'cree': c[5], 'derniere_connexion': c[6] or None,
                'messages': int(c[7] or 0),
                'systeme': c[1] == ''})
print(json.dumps({'ok': True, 'total': len(out), 'comptes': out}, ensure_ascii=False))
"
}

# ── invitations ─────────────────────────────────────────────────────────────
#
# LA FILIATION EST TOUT L'INTERET. Mastodon affiche le COMPTEUR d'usages d'une
# invitation, jamais QUI s'en est servi — l'information existe pourtant en base
# (`users.invite_id`). Sans elle, « 1 utilisation » ne dit pas si c'est la
# personne qu'on attendait ou un inconnu.
cmd_invites() {
    _exige_running || return 1
    _psql "select i.id, e.username, i.code, coalesce(i.expires_at::text,''), \
coalesce(i.max_uses::text,''), i.uses, i.created_at::date, \
coalesce((select string_agg(a2.username, ',') from users u2 \
  join accounts a2 on a2.id = u2.account_id where u2.invite_id = i.id),''), \
(i.expires_at is not null and i.expires_at < now()) \
from invites i join users ue on ue.id = i.user_id \
join accounts e on e.id = ue.account_id order by i.id desc limit 100" \
    | python3 -c "
import sys, json
out = []
for l in sys.stdin.read().splitlines():
    c = l.split('|')
    if len(c) < 9: continue
    out.append({'id': int(c[0]), 'emetteur': c[1], 'code': c[2],
                'expire_le': c[3] or None, 'max_usages': int(c[4]) if c[4] else None,
                'usages': int(c[5] or 0), 'creee': c[6],
                'comptes_crees': [x for x in c[7].split(',') if x],
                'expiree': c[8] == 't'})
actives = [i for i in out if not i['expiree']]
print(json.dumps({'ok': True, 'total': len(out), 'actives': len(actives),
                  'invitations': out}, ensure_ascii=False))
"
}


# ── moderation ──────────────────────────────────────────────────────────────
#
# CE QUI ATTEND QUELQU'UN. Un signalement non traite ne fait pas de bruit : il
# reste simplement la. C'est la seule page de ce panneau qui appelle une action
# humaine, donc elle doit dire l'ANCIENNETE du plus vieux dossier, pas seulement
# leur nombre — dix signalements du jour et un signalement d'il y a trois
# semaines ne racontent pas la meme histoire.
cmd_moderation() {
    _exige_running || return 1
    local sig sus dom
    sig=$(_psql "select count(*), coalesce(max(extract(day from now()-created_at))::int,0) \
from reports where action_taken_at is null")
    sus=$(_psql "select count(*) filter (where suspended_at is not null), \
count(*) filter (where silenced_at is not null) from accounts where domain is null")
    dom=$(_psql "select count(*) from domain_blocks")
    python3 -c "
import sys, json
sig = (sys.argv[1] or '0|0').split('|')
sus = (sys.argv[2] or '0|0').split('|')
dom = (sys.argv[3] or '0').split('|')
def n(x, i=0):
    try: return int(x[i])
    except (IndexError, ValueError): return 0
print(json.dumps({'ok': True,
  'signalements_en_attente': n(sig),
  'plus_ancien_jours': n(sig, 1),
  'comptes_suspendus': n(sus),
  'comptes_limites': n(sus, 1),
  'domaines_bloques': n(dom)}, ensure_ascii=False))
" "$sig" "$sus" "$dom"
}

# ── sante ───────────────────────────────────────────────────────────────────
#
# TROIS SERVICES, PAS UN. `web` sert les pages, `sidekiq` traite les files,
# `streaming` alimente le temps reel. Une instance dont seul `web` tourne
# REPOND — et ne publie plus rien : les messages partent en file et y restent.
# C'est la panne qu'on ne voit pas depuis un navigateur.
#
# La place occupee par les MEDIAS est l'autre chiffre qui compte : sur une
# appliance, c'est elle qui remplit le disque, pas la base.
cmd_health() {
    _exige_running || return 1
    local n; n=$(_nom)
    local etats=""
    for u in mastodon-web mastodon-sidekiq mastodon-streaming; do
        local e
        e=$(lxc-attach -n "$n" -P "$LXC_PATH" -- systemctl is-active "$u" 2>/dev/null | head -1)
        etats="$etats$u=${e:-inconnu};"
    done
    local base media fed
    base=$(_psql "select pg_size_pretty(pg_database_size('mastodon_production'))")
    media=$(lxc-attach -n "$n" -P "$LXC_PATH" -- du -sh /home/mastodon/live/public/system 2>/dev/null | awk '{print $1}')
    fed=$(_psql "select count(*) filter (where domain is null), count(*) filter (where domain is not null), \
(select count(distinct domain) from accounts where domain is not null) from accounts")
    python3 -c "
import sys, json
etats = dict(x.split('=', 1) for x in sys.argv[1].split(';') if '=' in x)
fed = (sys.argv[4] or '0|0|0').split('|')
def n(i):
    try: return int(fed[i])
    except (IndexError, ValueError): return 0
services = {k.replace('mastodon-', ''): v for k, v in etats.items()}
# L'ETAT GLOBAL EST LE PIRE DES TROIS, jamais une moyenne : un sidekiq mort
# rend l'instance muette meme si le web repond parfaitement.
ok = all(v == 'active' for v in services.values())
print(json.dumps({'ok': True, 'services': services, 'tout_actif': ok,
  'base': sys.argv[2] or None, 'medias': sys.argv[3] or None,
  'comptes_locaux': n(0), 'comptes_distants': n(1), 'instances_connues': n(2)},
  ensure_ascii=False))
" "$etats" "$base" "$media" "$fed"
}

cmd_help() {
    cat <<'EOF'
mastodonctl — instance Mastodon federee (LXC natif)

  status              Paquet, conteneur, marche, instance joignable — separement
  install             Provisionne le conteneur (long : plusieurs Go)
  start | stop        Allume / eteint le conteneur
  invite              Emet une invitation (7 jours, affichee une fois)
  version

Le DOMAINE de /etc/secubox/mastodon.toml ne peut plus etre change apres la
premiere federation : il est grave dans chaque adresse d'utilisateur et dans
les signatures echangees avec les autres instances.
EOF
}

case "${1:-help}" in
    status)  cmd_status ;;
    install) cmd_install ;;
    start)   cmd_start ;;
    stop)    cmd_stop ;;
    invite)  cmd_invite ;;
    accounts|comptes)   cmd_accounts ;;
    invites|invitations) cmd_invites ;;
    moderation)          cmd_moderation ;;
    health|sante)        cmd_health ;;
    version) printf '{"ok":true,"version":"%s"}\n' "$VERSION" ;;
    *)       cmd_help ;;
esac
