LibrePortal/containers/stoat/scripts/stoat_install_hooks.sh
librelad 5b6ed924d2 fix(chat tools): wire list_users into the WebUI user-list modal
The Tools tab has an interactive modal: when a list_users task completes it
parses the task log for EZ_USER lines and renders one row per account with
reset / promote / delete buttons. All four new apps failed its contract in every
respect, so running List Users produced log text and nothing else.

- The marker is EZ_USER, tab-separated as email, username, roles. Matrix and
  Stoat emitted LP_USER in a different field order; Mattermost and Rocket.Chat
  emitted no marker at all.
- Matrix and Stoat then consumed their own marker lines in the formatting loop
  and printed only the pretty version, so nothing reached the log to parse.
- The row buttons look up tools by id: reset_password, set_admin, delete_user.
  The deactivate tools were named deactivate_user / disable_user, so no delete
  button rendered.
- Prefill only fills a field named email or username. Rocket.Chat's and Stoat's
  identifier field was called user, so a row action would have opened with an
  empty box.
- '-' placeholders are truthy, so the modal's `email || username` fallback
  picked '-' over the real username for accounts without an email (rocket.cat).
  The EZ_USER line now carries an empty string; '-' stays in the readable line.

Mattermost's listing is rebuilt on `mmctl --json`, which carries roles and
delete_at. The text listing has neither, and there is no --system-admin filter
on user list, so every account was reported as a plain user. Two parsing notes
that cost time: mmctl prints status lines both before and after the JSON, so it
needs raw_decode rather than json.loads; and --per-page above 200 makes it emit
a warning line ahead of the payload.

The modal's delete button also stops asserting "Delete user" over whatever the
tool actually does — it takes its label and icon from the tool, because most of
these deactivate and Matrix cannot delete at all.

Verified by replaying the modal's own parser over real tool output: 2 rows for
Matrix, 4 for Mattermost, 3 for Rocket.Chat, with admin and deactivated states
resolving correctly.

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

357 lines
14 KiB
Bash

#!/bin/bash
# Stoat install hooks.
#
# Upstream configures an instance with an interactive generate_config.sh that
# asks for a domain and writes five files. This is the non-interactive
# equivalent, driven by the domain LibrePortal already knows and writing into
# the app's install directory.
#
# The one rule that matters here: secrets.env is generated ONCE and never
# rewritten. REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded to
# the instance, so regenerating it on a reinstall would permanently orphan the
# entire media store — which is exactly the failure upstream's script warns
# about at length.
stoat_install_pre()
{
local app_name="$1"
if ! appInstallCheckRequirements "$app_name" "$CFG_STOAT_REQUIRES"; then
stoat=n
return 1
fi
}
# The public host every generated file is derived from.
#
# Computed from the port arrays and $domain_full that variables_init_app puts in
# scope, NOT read back from the deployed compose: install_post_compose runs
# before dockerConfigSetupFileWithData, so the compose still holds raw
# placeholders at this point. port_subdomains[0] is CFG_STOAT_PORT_1 (the Caddy
# router); the empty/@/root cases mirror tagsProcessorPortSubdomains so this and
# the Traefik rule generated later cannot drift apart.
_stoatDomain()
{
local sub="${port_subdomains[0]}"
[[ -z "$domain_full" ]] && return 1
if [[ "$sub" == "@" || "$sub" == "root" ]]; then
echo "$domain_full"
elif [[ -n "$sub" ]]; then
echo "${sub}.${domain_full}"
else
echo "stoat.${domain_full}"
fi
}
# Scheme + host the client bundle is built against, with no trailing slash.
#
# https://<host> when Traefik is installed and a domain is configured;
# otherwise http://<lan-ip>:<allocated-port>, which is a perfectly good Stoat
# instance for LAN or WireGuard use — it just cannot do camera or microphone,
# because browsers only grant those to a secure context.
#
# The port is only assigned during compose-up, so a call from
# install_post_compose returns a best guess and install_post_start corrects it.
_stoatBaseUrl()
{
local app_name="$1"
local compose="$containers_dir$app_name/docker-compose.yml"
if [[ -d "${containers_dir}traefik" && -n "$domain_full" ]]; then
local host
host=$(_stoatDomain)
[[ -n "$host" ]] && { echo "https://${host}"; return 0; }
fi
local ports external
ports=$(tagsManagerGetTagContent "$compose" "PORTS_TAG_1")
external="${ports%%:*}"
if [[ -n "$external" && "$external" != PORTS_DATA* ]]; then
echo "http://${public_ip_v4:-localhost}:${external}"
else
echo "http://${public_ip_v4:-localhost}"
fi
}
# Write the three files that carry the public URL. Called once with a guess
# before the stack starts (they are bind-mounted, so they must exist or docker
# would create directories in their place) and again once the port is known.
_stoatWriteUrlFiles()
{
local app_dir="$1" base="$2" video_enabled="$3" rabbit_pass="$4"
# ws:// for http, wss:// for https — a wss:// URL on a plain-HTTP origin
# fails to connect and the client hangs on "connecting".
local ws_scheme="wss"
[[ "$base" == http://* ]] && ws_scheme="ws"
local hostport="${base#*://}"
runFileWrite "$app_dir/.env.web" <<EOF
HOSTNAME=:80
REVOLT_PUBLIC_URL=${base}/api
VITE_API_URL=${base}/api
VITE_WS_URL=${ws_scheme}://${hostport}/ws
VITE_MEDIA_URL=${base}/autumn
VITE_PROXY_URL=${base}/january
VITE_GIFBOX_URL=${base}/gifbox
VITE_CFG_ENABLE_VIDEO=${video_enabled}
EOF
printf '{"api":"%s/api"}' "$base" | runFileWrite "$app_dir/stoat.json"
runFileWrite "$app_dir/Revolt.toml" <<EOF
# Generated by LibrePortal at install time. Secrets live in secrets.env, not
# here. Reinstalling the app rewrites this file — put custom configuration in a
# copy and merge it back if you change anything.
[hosts]
app = "${base}"
api = "${base}/api"
events = "${ws_scheme}://${hostport}/ws"
autumn = "${base}/autumn"
january = "${base}/january"
gifbox = "${base}/gifbox"
[hosts.livekit]
worldwide = "${ws_scheme}://${hostport}/livekit"
[api.livekit.nodes.worldwide]
url = "http://livekit:7880"
lat = 0.0
lon = 0.0
# Upstream's built-in defaults are rabbituser/rabbitpass. The compose gives the
# broker a generated password instead, so the clients have to be told — without
# this the API, crond, pushd and voice-ingress all panic on ACCESS_REFUSED and
# restart forever, while the services that do not touch RabbitMQ come up fine
# and make it look like a partial success.
[rabbit]
host = "rabbit"
port = 5672
username = "stoat"
password = "${rabbit_pass}"
EOF
if [[ -n "$video_enabled" ]]; then
runFileWrite -a "$app_dir/Revolt.toml" <<'EOF'
[features.limits.new_user]
video_resolution = [1920, 1080]
video_aspect_ratio = [0.3, 10]
[features.limits.default]
video_resolution = [1920, 1080]
video_aspect_ratio = [0.3, 10]
EOF
fi
}
# Generate secrets.env if it does not already exist. Returns without touching an
# existing file — see the warning at the top.
_stoatWriteSecrets()
{
local secrets_file="$1"
if [[ -s "$secrets_file" ]]; then
isNotice "Existing secrets.env found — keeping it (regenerating would orphan every uploaded file)."
return 0
fi
# VAPID keypair for web push. The public key is the uncompressed EC point,
# which is the last 65 bytes of the DER encoding, base64url-encoded without
# padding — that is what the browser Push API expects.
local vapid_pem vapid_private vapid_public
vapid_pem=$(mktemp)
openssl ecparam -name prime256v1 -genkey -noout -out "$vapid_pem" 2>/dev/null
vapid_private=$(base64 < "$vapid_pem" | tr -d '\n' | tr -d '=')
vapid_public=$(openssl ec -in "$vapid_pem" -outform DER 2>/dev/null | tail -c 65 | base64 | tr '/+' '_-' | tr -d '\n' | tr -d '=')
rm -f "$vapid_pem"
local files_key livekit_key livekit_secret
files_key=$(openssl rand -base64 32)
livekit_key=$(openssl rand -hex 6)
livekit_secret=$(openssl rand -hex 24)
runFileWrite "$secrets_file" <<EOF
# Generated by LibrePortal at install time. Treat this file as you would a
# private key: REVOLT__FILES__ENCRYPTION_KEY is the only thing that can decrypt
# the media store, and it is never regenerated once written.
REVOLT__PUSHD__VAPID__PRIVATE_KEY='${vapid_private}'
REVOLT__PUSHD__VAPID__PUBLIC_KEY='${vapid_public}'
REVOLT__FILES__ENCRYPTION_KEY='${files_key}'
REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='${livekit_key}'
REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='${livekit_secret}'
EOF
runFileOp chmod 600 "$secrets_file"
isSuccessful "Generated secrets.env"
}
# Own the LibrePortal-written config files (Caddyfile, Revolt.toml, .env.web,
# stoat.json, secrets.env, livekit.yml, the compose + app config) as the docker
# install user, so the containers can read their bind-mount sources.
#
# Top level ONLY. This used to be `chown -R "$app_dir"`, which walked into
# data/db, data/minio and friends — content created by the containers and owned
# by THEIR uids (mongo's, minio's; under rootless those are subuids the docker
# install user has no authority over). Every reinstall therefore printed a screen
# of "Operation not permitted" plus "Permission denied" on the 0700 dirs it
# couldn't even enter, and then failed the step outright — an ✗ Error on a
# healthy install, which is the kind of noise that teaches you to skip error
# lines. Those files must keep their container ownership anyway: chowning mongo's
# data away from mongo is what would actually break stoat.
_stoatOwnConfigFiles() {
local app_dir="$1"
runFileOp find "$app_dir" -maxdepth 1 -type f \
-exec chown "$docker_install_user":"$docker_install_user" {} +
}
stoat_install_post_compose()
{
local app_name="$1"
local app_dir="$containers_dir$app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Generating the Stoat instance configuration"
echo ""
local result
result=$(createFolders "loud" "$docker_install_user" \
"$app_dir/data/db" "$app_dir/data/rabbit" "$app_dir/data/minio" \
"$app_dir/data/caddy-data" "$app_dir/data/caddy-config")
checkSuccess "Creating $app_name data folders"
# Ordering rule for everything below: every file bind-mounted into a
# container must be written before the first step that could fail. A missing
# mount source is not a soft failure — docker either creates a directory in
# its place or refuses to start the container, and both outcomes outlive the
# install and break every later run.
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Caddyfile to $app_dir"
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
# The port is not allocated yet, so this is a guess whenever there is no
# domain; stoat_install_post_start rewrites these once it is known.
local base
base=$(_stoatBaseUrl "$app_name")
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
checkSuccess "Writing .env.web, stoat.json and Revolt.toml for $base"
_stoatWriteSecrets "$app_dir/secrets.env"
# Read the LiveKit credentials back out — either the ones just generated or
# the ones preserved from a previous install — because livekit.yml has to
# carry the same pair the API is configured with.
#
# Read via runFileOp: secrets.env is chmod 600 and owned by the docker
# install user, while these hooks run as the manager, so a plain grep gets
# EACCES and silently yields nothing.
local livekit_key livekit_secret
livekit_key=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
livekit_secret=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then
# Deliberately not fatal. livekit.yml still gets written below so the
# bind mount is a file; voice is broken until the keys are fixed, but
# the other fifteen services come up and text chat works.
isError "Could not read the LiveKit credentials from secrets.env — voice will not work."
isNotice "Fix the keys in $app_dir/secrets.env and livekit.yml, then restart $app_name."
fi
# use_external_ip lets LiveKit discover the address to advertise for WebRTC.
# The port range matches the literal UDP mapping in the compose file; change
# one and you must change the other.
runFileWrite "$app_dir/livekit.yml" <<EOF
rtc:
use_external_ip: true
port_range_start: 50000
port_range_end: 50100
tcp_port: 7881
redis:
address: redis:6379
turn:
enabled: false
keys:
${livekit_key}: ${livekit_secret}
webhook:
api_key: ${livekit_key}
urls:
- "http://voice-ingress:8500/worldwide"
EOF
checkSuccess "Writing livekit.yml"
_stoatOwnConfigFiles "$app_dir"
checkSuccess "Setting ownership on the $app_name config files"
}
stoat_install_post_start()
{
local app_name="$1"
local app_dir="$containers_dir$app_name"
# Ports are assigned during compose-up, so on a domain-less install the URL
# baked in a moment ago was a guess. Correct it now and restart, but only if
# it actually changed — restarting sixteen containers for nothing is not
# free, and a domain-backed install guessed right the first time.
local base current
base=$(_stoatBaseUrl "$app_name")
current=$(runFileOp grep -oP '^VITE_API_URL=\K.*' "$app_dir/.env.web" 2>/dev/null)
current="${current%/api}"
[[ "$base" == "$current" ]] && return 0
((menu_number++))
echo ""
echo "---- $menu_number. Settling the Stoat public URL"
echo ""
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
_stoatOwnConfigFiles "$app_dir"
isSuccessful "Public URL settled as $base (was ${current:-unset})"
# The web client compiles VITE_* at container start, so it has to come back
# up before the corrected URL reaches a browser.
dockerComposeRestart "$app_name"
}
stoat_install_post()
{
local app_name="$1"
local base
base=$(_stoatBaseUrl "$app_name")
echo ""
isNotice "Stoat first run:"
echo ""
echo " Open ${base} and create an account — the first account"
echo " registered on a fresh instance becomes the instance owner."
echo ""
if [[ "$base" == http://* ]]; then
echo " This install serves plain HTTP. Text chat, channels, roles and"
echo " uploads all work, but browsers refuse camera and microphone"
echo " access outside a secure context — so voice and video will not"
echo " work until it is served over HTTPS. A WireGuard tunnel does not"
echo " change that: the check is on the URL scheme, not the transport."
echo ""
fi
echo " Give it a few minutes on first boot: sixteen containers start in"
echo " dependency order, and the API restarts until MongoDB and RabbitMQ"
echo " both report healthy. 'docker compose ps' in the app directory"
echo " shows where it has got to."
echo ""
echo " Voice falls back to TCP 7881, which is already open. For proper"
echo " low-latency WebRTC from outside your LAN, also allow the UDP"
echo " media range — LibrePortal's firewall layer only emits TCP rules,"
echo " so this one is manual:"
echo ""
echo " sudo ufw allow 50000:50100/udp"
echo ""
}