#!/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: # # 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. Confirmed against a running instance: the user # document holds only _id/username/discriminator, and GET /users/@me adds only # relationship and online. There is no privileged bit to set. # # Password reset WAS excluded for the same reason — Stoat hashes with argon2 # through its own authifier layer, and a hand-rolled hash that is subtly wrong # writes an account nothing can verify, with no error at the time. That objection # is answered not by reimplementing the hash but by refusing to: authifier's own # password_reset field takes a token, and its reset endpoint does the hashing. # See authAdapter_stoat_setPassword. # Host-local API base for this app, e.g. http://127.0.0.1:8860/api. # # 127.0.0.1 rather than the published LAN address: this only ever runs on the # box itself, and the loopback route does not depend on which interface the # advertised URL happens to name. _stoatApiLocal() { local app_name="${1:-stoat}" local compose="$containers_dir$app_name/docker-compose.yml" local ports external ports=$(tagsManagerGetTagContent "$compose" "PORTS_TAG_1" 2>/dev/null) external="${ports%%:*}" [[ -z "$external" || "$external" == PORTS_DATA* ]] && return 1 printf 'http://127.0.0.1:%s/api' "$external" } # JSON-encode one value, so an apostrophe in a password cannot end the string. _stoatJson() { printf '%s' "$1" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'; } # True when the instance already holds at least one account. _stoatAccountExists() { local n n=$(_stoatMongo 'print(db.accounts.countDocuments({}))' | tr -dc '0-9') [[ -n "$n" && "$n" != "0" ]] } # Register an account and finish onboarding, which is what actually makes it a # usable identity: `accounts` holds the login, `users` the handle, and Stoat # creates the second only when onboarding completes. An account left un-onboarded # can sign in and then sits on a "pick a username" screen forever, and — worse for # a first account — has not yet taken instance ownership. # # Done over HTTP rather than by writing Mongo directly because passwords go # through Stoat's own argon2 layer. Reimplementing that in bash means matching # its parameters exactly, and a subtly wrong hash writes an account nothing can # verify: no error at the time, just a login that never works. _stoatCreateAccount() { local api="$1" email="$2" pass="$3" user="$4" local body out token body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass")}" out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \ -H 'Content-Type: application/json' -d "$body" 2>&1) # On a closed instance the API answers MissingInvite. Rather than refuse, mint # one: an admin creating an account here IS the authorisation, and requiring # them to go and generate an invite by hand first would make CFG_STOAT_INVITE_ONLY # a switch that breaks LibrePortal's own tooling. # # Reactive rather than reading the config, so this follows the instance's ACTUAL # state — someone who edits Revolt.toml by hand, or flips it after install, gets # the same behaviour without LibrePortal having to be told. # # The invite is single-use and consumed by this create: Stoat stamps it # used/claimed_by, so it cannot become a spare key left under the mat. if [[ "$out" == *MissingInvite* ]]; then local code code=$(_stoatMongo 'const c = "LP" + Math.random().toString(36).slice(2, 10).toUpperCase(); db.account_invites.insertOne({_id: c, used: false}); print(c);' | tr -dc 'A-Z0-9') if [[ -z "$code" ]]; then isError "Stoat is invite-only and an invite could not be created." return 1 fi body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass"),\"invite\":$(_stoatJson "$code")}" out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \ -H 'Content-Type: application/json' -d "$body" 2>&1) # Do not leave an unused invite behind if the create still failed. [[ -n "$out" ]] && _stoatMongo "db.account_invites.deleteOne({_id: $(_stoatJson "$code"), used: false})" >/dev/null 2>&1 fi # A successful create returns 204 with no body; anything printed is an error. if [[ -n "$out" && "$out" != *'"result"'* ]]; then isError "Stoat account create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)" return 1 fi out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/session/login" \ -H 'Content-Type: application/json' -d "$body" 2>&1) token=$(printf '%s' "$out" | python3 -c 'import sys,json try: print(json.load(sys.stdin).get("token","")) except Exception: print("")' 2>/dev/null) if [[ -z "$token" ]]; then isError "Stoat login after create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)" return 1 fi out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/onboard/complete" \ -H 'Content-Type: application/json' -H "X-Session-Token: ${token}" \ -d "{\"username\":$(_stoatJson "$user")}" 2>&1) if [[ "$out" == *'"type"'*'"error"'* || "$out" == *UsernameTaken* || "$out" == *InvalidUsername* ]]; then isError "Stoat onboarding failed for '${user}': $(printf '%s' "$out" | tr -d '\n' | head -c 200)" return 1 fi return 0 } _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)" : ""; // EZ_USERidentifierdisplayroles, as the WebUI modal expects. // Email first where there is one: it is the field a row action prefills. print("EZ_USER\t" + (a.email || handle) + "\t" + handle + "\t" + (a.disabled ? "disabled" : "user")); }); print("LP_TOTAL:" + users.length); ') _stoatMongoFailed "$out" "Listing users" && return 1 local line total=0 while IFS= read -r line; do case "$line" in EZ_USER*) IFS=$'\t' read -r _ ident handle state <<< "$line" # The marker line has to reach the task log for the WebUI # user-list modal to build its rows from. printf '%s\n' "$line" printf ' %-30s %-24s %s\n' "$ident" "$handle" "$state" ;; 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." } authAdapter_stoat_createUser() { local email="$1" pass="$2" user="$3" [[ -z "$email" ]] && { isError "An email is required."; return 1; } [[ -z "$pass" ]] && pass=$(generateRandomPassword) # Stoat needs a handle as well as a login. Derive one from the email's local # part when the caller did not supply it, so the generic "create user" form # (which only asks for email + password) still produces a usable account # rather than one stuck on the pick-a-username screen. if [[ -z "$user" ]]; then user="${email%%@*}" user="${user//[^a-zA-Z0-9_.]/}" fi local api api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || { isError "Could not work out Stoat's local API address."; return 1; } _stoatCreateAccount "$api" "$email" "$pass" "$user" || return 1 # Keep the config truthful when this IS the configured owner: an install that # could not reach the API leaves those fields describing an account that does # not exist, and creating it by hand afterwards should reconcile the two. if [[ "$email" == "${CFG_STOAT_ADMIN_EMAIL:-}" ]]; then authPersistCfg stoat ADMIN_PASSWORD "$pass" authPersistCfg stoat ADMIN_USERNAME "$user" fi isSuccessful "Stoat account created — Handle: $user — Email: $email — Password: $pass" } # Reset a password WITHOUT touching the hash ourselves. # # authifier already owns a reset flow: an account carries a password_reset token, # and PATCH /auth/account/reset_password trades that token for a new password — # hashing it with exactly the parameters the verifier expects, because it is the # same code that verifies. So the only thing written directly is the token, which # is inert on its own; Stoat does the part that has to be right. # # The alternative, writing an argon2 string into the account document, would mean # reproducing $argon2i$v=19$m=4096,t=3,p=1 by hand — and a near miss there is # silent, locking the holder out with no error at the time. authAdapter_stoat_setPassword() { local who="$1" pass="$2" [[ -z "$who" ]] && { isError "A username or email is required."; return 1; } [[ -z "$pass" ]] && pass=$(generateRandomPassword) local api api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || { isError "Could not work out Stoat's local API address."; return 1; } # Short-lived and single-use: the reset endpoint consumes it, and the expiry # bounds the window if the endpoint is never reached. local token token="lp$(tr -dc 'a-z0-9' &1) if [[ -n "$out" ]]; then # Clear the token so a failed attempt does not leave a live reset behind. _stoatMongo "db.accounts.updateOne({\$or:[{email:$(_stoatJson "$who")}]}, {\$unset:{password_reset:''}})" >/dev/null 2>&1 isError "Stoat rejected the new password: $(printf '%s' "$out" | tr -d '\n' | head -c 200)" return 1 fi if [[ "$who" == "${CFG_STOAT_ADMIN_EMAIL:-}" || "$who" == "${CFG_STOAT_ADMIN_USERNAME:-}" ]]; then authPersistCfg stoat ADMIN_PASSWORD "$pass" fi isSuccessful "Stoat password set for $who — New password: $pass" isNotice "Existing sessions stay valid; restart the app to force a re-login." }