#!/bin/bash # Stoat user management. # # Deliberately the thinnest set of the four, because Stoat gives the least to # work with. Its service containers are distroless (no shell), it has no admin # CLI, and its admin HTTP surface is not a stable documented API — so the only # durable handles are the public auth endpoint and the database itself. # # What that rules out, and why: # # Password reset — Stoat hashes with argon2 through its own authifier layer. # Reimplementing that in bash means matching its parameters exactly, and # getting them subtly wrong writes a hash nothing can verify, silently locking # the account out with no error at the time. Users reset their own password # through the app; that path is not worth counterfeiting from a button. # # Role/permission editing — Stoat's permissions are per-server bitfields held # on server_members, not a global admin flag. There is no single "make admin" # to toggle; it is a server-by-server concept the app models properly and a # button would misrepresent. # # What is left is genuinely useful and genuinely safe: see who exists, and turn # an account off or back on. _stoatMongo() { runFileOp docker exec -i stoat-database mongosh revolt --quiet --eval "$1" 2>&1 } _stoatMongoFailed() { local out="$1" what="$2" # mongosh reports failures as a thrown error rather than a non-zero exit in # --eval mode, so match on the text. if [[ "$out" == *MongoServerError* || "$out" == *ReferenceError* || "$out" == *TypeError* ]]; then isError "$what failed: $(printf '%s' "$out" | head -2 | tr '\n' ' ')" return 0 fi return 1 } # Accounts and profiles are separate collections sharing an _id: `accounts` holds # the login (email, disabled flag), `users` the profile (username, discriminator). # Neither alone is a useful view, so join them. authAdapter_stoat_listUsers() { local out out=$(_stoatMongo ' const users = db.users.find({}, {username:1, discriminator:1, display_name:1}).toArray(); const byId = {}; db.accounts.find({}, {email:1, disabled:1}).toArray().forEach(a => byId[a._id] = a); users.forEach(u => { const a = byId[u._id] || {}; const handle = u.username + (u.discriminator ? "#" + u.discriminator : ""); const state = a.disabled ? " (disabled)" : ""; print("LP_USER\t" + handle + "\t" + (a.email || "-") + "\t" + (u.display_name || "-") + state); }); print("LP_TOTAL:" + users.length); ') _stoatMongoFailed "$out" "Listing users" && return 1 local line total=0 while IFS= read -r line; do case "$line" in LP_USER*) IFS=$'\t' read -r _ handle email display <<< "$line" printf ' %-24s %-30s %s\n' "$handle" "$email" "$display" ;; LP_TOTAL:*) total="${line#LP_TOTAL:}" ;; esac done <<< "$out" if [[ "$total" == "0" ]]; then isNotice "No Stoat accounts yet — the first person to register becomes the instance owner." return 0 fi isSuccessful "$total Stoat account(s)." } # Disabling is Stoat's own reversible state, not a hand-rolled hack: the account # stays intact and its messages stay readable, the holder just cannot log in. _stoatSetDisabled() { local who="$1" disabled="$2" verb="$3" [[ -z "$who" ]] && { isError "A username or email is required."; return 1; } local out out=$(_stoatMongoWho "$who" "$disabled") _stoatMongoFailed "$out" "$verb $who" && return 1 [[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; } [[ "$out" != *LP_OK* ]] && { isError "$verb $who failed: $out"; return 1; } return 0 } # Kept separate so the account lookup (by email on `accounts`, or by username on # `users`) lives in one place. _stoatMongoWho() { local who="$1" disabled="$2" _stoatMongo " const who = $(printf '%s' "$who" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'); let acct = null; if (who.includes('@')) { acct = db.accounts.findOne({email: who}); } else { const u = db.users.findOne({username: who}); if (u) acct = db.accounts.findOne({_id: u._id}); } if (!acct) { print('LP_MISSING'); } else { db.accounts.updateOne({_id: acct._id}, {\$set: {disabled: $disabled}}); print('LP_OK'); } " } authAdapter_stoat_deleteUser() { local who="$1" _stoatSetDisabled "$who" "true" "Disabling" || return 1 isSuccessful "Stoat account '$who' disabled — they can no longer sign in. Re-enable it with the Enable tool." isNotice "Their existing sessions are not revoked by this; restart the app to force everyone to re-authenticate." } authAdapter_stoat_enableUser() { local who="$1" _stoatSetDisabled "$who" "false" "Enabling" || return 1 isSuccessful "Stoat account '$who' re-enabled." }