librelad 7aed9102c4 fix(tools): keep the user on the Tools tab and show the result there
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.

Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.

Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.

And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:25:19 +01:00

315 lines
13 KiB
Bash

#!/bin/bash
# Matrix user management, via Synapse's admin API.
#
# Everything goes through `docker exec matrix-synapse python`: the image is
# debian-slim with no curl or wget, but python is what Synapse itself runs on,
# so it is always present. Talking to localhost:8008 inside the container also
# means these tools behave identically whether the install is LAN-only or behind
# Traefik, and never depend on the published port.
#
# Two Matrix facts shape what can be offered here:
# - A user ID is permanent. There is no rename; "changing a username" means
# creating a new account.
# - There is no true delete. Deactivation is the terminal state — it revokes
# access, devices and profile, but the ID stays burned so it can never be
# re-registered and old messages still resolve.
# Localpart -> full ID. Accepts either form, so a caller can pass "alice" or
# "@alice:example.com" and get the same result.
_matrixUserId() {
local user="$1" server
[[ "$user" == @* ]] && { echo "$user"; return 0; }
server=$(_matrixServerName)
[[ -z "$server" ]] && return 1
echo "@${user}:${server}"
}
# Where the cached admin access token lives. Beside homeserver.yaml, which
# already holds the registration shared secret and the database password, so
# this adds no new class of secret to the install.
_matrix_token_cache="data/.lp-admin-token"
# Run a python snippet inside the Synapse container with an admin access token
# already in scope and a call(method, path, body) helper available.
#
# Args: <python snippet> [extra docker -e flags...]
#
# Values are handed over as environment variables rather than interpolated into
# the snippet, so a password containing quotes or backslashes cannot break out
# into the python source.
#
# The token is CACHED between invocations. Logging in each time seemed tidier,
# but Synapse rate-limits /login (rc_login defaults to a burst of 5), so running
# a few tools in succession failed with "Too Many Requests" — the tools were
# throttling themselves. One login, reused until it stops working, and a single
# re-login on 401 if the token was revoked or the password changed.
_matrixApi() {
local script="$1"; shift
local admin_user="${CFG_MATRIX_ADMIN_USERNAME:-admin}"
local admin_pass="${CFG_MATRIX_ADMIN_PASSWORD_1}"
local cache="${containers_dir}matrix/${_matrix_token_cache}"
if [[ -z "$admin_pass" || "$admin_pass" == RANDOMIZEDPASSWORD* ]]; then
isError "No Matrix admin password in matrix.config — cannot authenticate to the admin API."
return 1
fi
local cached=""
[[ -s "$cache" ]] && cached=$(runFileOp cat "$cache" 2>/dev/null)
local raw
raw=$(runFileOp docker exec -i "$@" \
-e LP_ADMIN_USER="$admin_user" \
-e LP_ADMIN_PASS="$admin_pass" \
-e LP_TOKEN="$cached" \
matrix-synapse python - <<PY
import json, os, sys, time, urllib.request, urllib.error
BASE = "http://localhost:8008"
TOKEN = os.environ.get("LP_TOKEN") or ""
class Unauthorised(Exception):
pass
def _raw(method, path, body=None, token=None, quiet404=False):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
r.add_header("Content-Type", "application/json")
if token:
r.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(r, timeout=30) as resp:
return json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
if quiet404 and e.code == 404:
return None
body_txt = e.read().decode()
try:
parsed = json.loads(body_txt)
except Exception:
parsed = {}
if e.code in (401, 403) and parsed.get("errcode") in ("M_UNKNOWN_TOKEN", "M_MISSING_TOKEN"):
raise Unauthorised()
# Synapse tells us exactly how long to wait; honour it rather than
# failing the whole tool on a transient throttle.
if e.code == 429:
wait = parsed.get("retry_after_ms", 2000) / 1000.0
time.sleep(min(wait + 0.25, 10))
return _raw(method, path, body, token, quiet404)
print("LP_ERR:" + str(parsed.get("error", body_txt)))
sys.exit(1)
except urllib.error.URLError as e:
print("LP_ERR:" + str(e))
sys.exit(1)
def _login():
global TOKEN
res = _raw("POST", "/_matrix/client/v3/login", {
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": os.environ["LP_ADMIN_USER"]},
"password": os.environ["LP_ADMIN_PASS"],
})
TOKEN = res["access_token"]
# Picked up by the caller and written to the cache file.
print("LP_NEWTOKEN:" + TOKEN)
if not TOKEN:
_login()
def call(method, path, body=None, quiet404=False):
try:
return _raw(method, path, body, TOKEN, quiet404)
except Unauthorised:
# Cached token was revoked, or the admin password changed under us.
_login()
return _raw(method, path, body, TOKEN, quiet404)
$script
PY
)
# Persist a freshly-minted token, then strip the marker so callers only ever
# see the snippet's own output.
local new_token
new_token=$(printf '%s\n' "$raw" | sed -n 's/^LP_NEWTOKEN://p' | head -1)
if [[ -n "$new_token" ]]; then
printf '%s' "$new_token" | runFileWrite "$cache"
runFileOp chmod 600 "$cache"
fi
printf '%s\n' "$raw" | grep -v '^LP_NEWTOKEN:'
return 0
}
# Shared post-processing: surface the API's own error text rather than a generic
# failure, since Synapse's messages are usually the actionable part.
_matrixApiFailed() {
local out="$1" what="$2"
if [[ "$out" == *LP_ERR:* ]]; then
isError "$what failed: ${out#*LP_ERR:}"
return 0
fi
return 1
}
authAdapter_matrix_createUser() {
local user="$1" password="$2" displayname="$3" isAdmin="$4"
[[ -z "$user" ]] && { isError "A username is required."; return 1; }
[[ -z "$password" ]] && password=$(generateRandomPassword)
local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; }
[[ -z "$displayname" ]] && { displayname="${user#@}"; displayname="${displayname%%:*}"; }
local admin_flag="False"; [[ "$isAdmin" == "true" ]] && admin_flag="True"
local out
out=$(_matrixApi "
uid = os.environ['LP_UID']
if call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True):
print('LP_EXISTS')
else:
call('PUT', '/_synapse/admin/v2/users/' + uid, {
'password': os.environ['LP_NEWPASS'],
'displayname': os.environ['LP_DISPLAY'],
'admin': ${admin_flag},
})
print('LP_OK')
" -e LP_UID="$uid" -e LP_NEWPASS="$password" -e LP_DISPLAY="$displayname" 2>&1)
_matrixApiFailed "$out" "Creating $uid" && return 1
[[ "$out" == *LP_EXISTS* ]] && { isError "$uid already exists."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Creating $uid failed: $out"; return 1; }
isSuccessful "Matrix user created — ID: $uid — Password: $password"
}
authAdapter_matrix_setPassword() {
local user="$1" password="$2"
[[ -z "$user" ]] && { isError "A username is required."; return 1; }
[[ -z "$password" ]] && password=$(generateRandomPassword)
local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; }
local out
out=$(_matrixApi "
uid = os.environ['LP_UID']
if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True):
print('LP_MISSING')
else:
# logout_devices invalidates every existing session, which is the whole
# point of a reset — otherwise a stolen token keeps working afterwards.
call('PUT', '/_synapse/admin/v2/users/' + uid, {
'password': os.environ['LP_NEWPASS'],
'logout_devices': True,
})
print('LP_OK')
" -e LP_UID="$uid" -e LP_NEWPASS="$password" 2>&1)
_matrixApiFailed "$out" "Resetting $uid" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Resetting $uid failed: $out"; return 1; }
# Keep the config in step when the admin's own password changes, or the
# WebUI card and these very tools would carry on offering the old one.
local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}")
[[ "$uid" == "$admin_uid" ]] && authPersistCfg matrix ADMIN_PASSWORD "$password"
isSuccessful "Matrix password set for $uid — New password: $password — all their sessions were signed out."
}
authAdapter_matrix_listUsers() {
local out
out=$(_matrixApi "
res = call('GET', '/_synapse/admin/v2/users?from=0&limit=500&deactivated=true')
for u in res.get('users', []):
flags = []
if u.get('admin'): flags.append('admin')
if u.get('deactivated'): flags.append('deactivated')
# EZ_USER<TAB>identifier<TAB>display<TAB>roles — the exact shape the WebUI's
# user-list modal parses. The first column is what a row action gets
# prefilled with, so it must be the Matrix ID, not the display name.
name = u.get('displayname') or ''
print('EZ_USER\t' + u['name'] + '\t' + name + '\t' + (','.join(flags) or 'user'))
print(' %-34s %-20s %s' % (u['name'], name or '-', ','.join(flags) or 'user'))
print('LP_TOTAL:' + str(res.get('total', 0)))
" 2>&1)
_matrixApiFailed "$out" "Listing users" && return 1
local line total=0
while IFS= read -r line; do
case "$line" in
# Both the marker line (for the WebUI modal, which reads the task
# log) and the aligned line (for a human) are printed by the python
# above — pass them straight through. Re-splitting the marker here
# used to shift the columns whenever a field was empty, because TAB
# is IFS whitespace and bash collapses a run of it into one
# delimiter.
EZ_USER*|' '*) printf '%s\n' "$line" ;;
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
esac
done <<< "$out"
isSuccessful "$total Matrix account(s)."
}
# Matrix has no delete — deactivation is as far as it goes, and it is
# irreversible. Named deleteUser to match the adapter contract the other apps
# use, but the message is explicit about what actually happens.
authAdapter_matrix_deleteUser() {
local user="$1"
[[ -z "$user" ]] && { isError "A username is required."; return 1; }
local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; }
local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}")
if [[ "$uid" == "$admin_uid" ]]; then
isError "Refusing to deactivate $uid — it is the admin these tools authenticate as."
isNotice "Promote another account to admin and point CFG_MATRIX_ADMIN_USERNAME at it first."
return 1
fi
local out
out=$(_matrixApi "
uid = os.environ['LP_UID']
if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True):
print('LP_MISSING')
else:
call('POST', '/_synapse/admin/v1/deactivate/' + uid, {'erase': True})
print('LP_OK')
" -e LP_UID="$uid" 2>&1)
_matrixApiFailed "$out" "Deactivating $uid" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Deactivating $uid failed: $out"; return 1; }
isSuccessful "Matrix user $uid deactivated and erased. The ID is permanently taken and cannot be re-registered."
}
authAdapter_matrix_setAdmin() {
local user="$1" isAdmin="$2"
[[ -z "$user" ]] && { isError "A username is required."; return 1; }
local target="false"; [[ "$isAdmin" == "true" ]] && target="true"
local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; }
local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}")
if [[ "$uid" == "$admin_uid" && "$target" == "false" ]]; then
isError "Refusing to demote $uid — it is the admin these tools authenticate as, and demoting it would lock them out."
return 1
fi
local py_bool="False"; [[ "$target" == "true" ]] && py_bool="True"
local out
out=$(_matrixApi "
uid = os.environ['LP_UID']
if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True):
print('LP_MISSING')
else:
call('PUT', '/_synapse/admin/v2/users/' + uid, {'admin': ${py_bool}})
print('LP_OK')
" -e LP_UID="$uid" 2>&1)
_matrixApiFailed "$out" "Changing admin status for $uid" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Changing admin status for $uid failed: $out"; return 1; }
isSuccessful "Matrix user $uid admin → $target."
}