librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

313 lines
14 KiB
Bash

#!/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="$(appDir "$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_USER<TAB>identifier<TAB>display<TAB>roles, 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' </dev/urandom | head -c 40)"
local out
out=$(_stoatMongo "
const who = $(_stoatJson "$who");
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: {password_reset: {token: $(_stoatJson "$token"), expiry: new Date(Date.now() + 600000).toISOString()}}});
print('LP_OK');
}
")
_stoatMongoFailed "$out" "Password reset for $who" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Password reset for $who failed: $out"; return 1; }
out=$(runFileOp curl -sS --max-time 20 -X PATCH "${api}/auth/account/reset_password" \
-H 'Content-Type: application/json' \
-d "{\"token\":$(_stoatJson "$token"),\"password\":$(_stoatJson "$pass")}" 2>&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."
}