diff --git a/lib/common.sh b/lib/common.sh index 9315e86..45d1f40 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -261,9 +261,20 @@ write_readme() { } # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── -# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"] +# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] +# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), +# or plain port number for localhost fallback (e.g. "8085"). configure_caddy_for_service() { - local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + + # Derive the proxy upstream and a port number for display messages. + # Plain number → localhost:PORT (host-network or legacy services) + # name:port → used as-is (preferred: service on shared caddy_net) + local _UPSTREAM _DISPLAY_PORT + case "$SERVICE_UPSTREAM" in + *:*) _UPSTREAM="$SERVICE_UPSTREAM"; _DISPLAY_PORT="${SERVICE_UPSTREAM##*:}" ;; + *) _UPSTREAM="localhost:$SERVICE_UPSTREAM"; _DISPLAY_PORT="$SERVICE_UPSTREAM" ;; + esac # Caddy not installed → nothing to do [ -d "$DOCKER_DIR/caddy" ] || return 0 @@ -280,7 +291,7 @@ configure_caddy_for_service() { prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then echo " Skipping Caddy configuration." - echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT" + echo " Access $SERVICE_NAME at: http://localhost:$_DISPLAY_PORT" return 0 fi @@ -320,7 +331,7 @@ configure_caddy_for_service() { # $SERVICE_NAME $SERVICE_DOMAIN { - reverse_proxy host.docker.internal:$SERVICE_PORT + reverse_proxy $_UPSTREAM # Security headers header { diff --git a/services/actualbudget.sh b/services/actualbudget.sh index a6eacee..3cc01a1 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -57,7 +57,7 @@ AB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" log_success "Actual Budget configured at $AB_DIR" - configure_caddy_for_service "ActualBudget" "5006" "budget" + configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget" write_readme "$AB_DIR" << MD # Actual Budget diff --git a/services/arm.sh b/services/arm.sh index 858309b..6614c4e 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -93,7 +93,7 @@ ARM_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_OUTPUT" log_success "A.R.M. configured at $ARM_DIR" - configure_caddy_for_service "A.R.M." "8080" "arm" + configure_caddy_for_service "A.R.M." "arm:8080" "arm" write_readme "$ARM_DIR" << MD # A.R.M. (Automatic Ripping Machine) diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index 4754beb..f8e2f2d 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -69,7 +69,7 @@ ABS_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" log_success "Audiobookshelf configured at $ABS_DIR" - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" + configure_caddy_for_service "AudioBookshelf" "audiobookshelf:80" "audiobooks" write_readme "$ABS_DIR" << MD # Audiobookshelf diff --git a/services/caddy.sh b/services/caddy.sh index 8233122..f4ee359 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -82,8 +82,6 @@ services: - ACME_AGREE=true labels: - "io.podman.annotations.label/crowdsec.enable=true" - extra_hosts: - - "host.docker.internal:host-gateway" networks: - caddy_net @@ -123,7 +121,7 @@ CADDY_COMPOSE # Example: # myservice.yourdomain.com { # import authelia -# reverse_proxy host.docker.internal:PORT +# reverse_proxy container_name:port # } # ActualBudget @@ -133,7 +131,7 @@ CADDY_COMPOSE # format json # level INFO # } -# reverse_proxy host.docker.internal:5006 +# reverse_proxy actualbudget:5006 # header { # Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # X-Frame-Options "SAMEORIGIN" @@ -174,7 +172,7 @@ a web service). You can also edit it by hand: ``` myservice.example.com { - reverse_proxy host.docker.internal:1234 + reverse_proxy container_name:1234 log { output file /var/log/caddy/myservice.example.com.log format json diff --git a/services/emby.sh b/services/emby.sh index ea1bb4e..96095da 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -77,7 +77,7 @@ EMBY_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" log_success "Emby configured at $EMBY_DIR" - configure_caddy_for_service "Emby" "8096" "emby" + configure_caddy_for_service "Emby" "emby:8096" "emby" write_readme "$EMBY_DIR" << MD # Emby diff --git a/services/filebrowser.sh b/services/filebrowser.sh index afaa04f..5299171 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -67,10 +67,20 @@ FB_SETTINGS chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" + # Deploy user-management helper script + local _TOOLS_DIR + _TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../tools" 2>/dev/null && pwd)" || true + if [ -f "$_TOOLS_DIR/manage_users.sh" ]; then + cp "$_TOOLS_DIR/manage_users.sh" "$FB_DIR/manage_users.sh" + chmod 750 "$FB_DIR/manage_users.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR/manage_users.sh" + log_success "manage_users.sh installed at $FB_DIR/manage_users.sh" + fi + echo "" log_success "Filebrowser configured at $FB_DIR" - configure_caddy_for_service "FileBrowser" "8085" "files" + configure_caddy_for_service "FileBrowser" "filebrowser:80" "files" write_readme "$FB_DIR" << MD # FileBrowser diff --git a/services/fmd.sh b/services/fmd.sh index 9d52ff9..db11e5f 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -62,7 +62,7 @@ FMD_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" log_success "FindMyDevice configured at $FMD_DIR" - configure_caddy_for_service "FindMyDevice" "8084" "fmd" + configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd" write_readme "$FMD_DIR" << MD # FindMyDevice (FMD) diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index e9b050f..3bca1fb 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -514,7 +514,7 @@ FNEOF # ── Caddy snippet ────────────────────────────────────────────────────────── if [ -n "$FRIGATE_PUBLIC_URL" ] && [ "$FRIGATE_PUBLIC_URL" != "https://cam.yourdomain.com" ]; then local _DOM="${FRIGATE_PUBLIC_URL#https://}" - configure_caddy_for_service "Frigate" "8971" "frigate-audio" || true + configure_caddy_for_service "Frigate" "frigate-audio:8971" "frigate-audio" || true fi ensure_docker_dir_ownership "$DIR" diff --git a/services/frigate.sh b/services/frigate.sh index 6f4bcd1..ef6321a 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -128,7 +128,7 @@ FRIGATE_CONFIG chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true log_success "Frigate configured at $FRIGATE_DIR" - configure_caddy_for_service "Frigate" "5000" "frigate" + configure_caddy_for_service "Frigate" "frigate:5000" "frigate" write_readme "$FRIGATE_DIR" << MD # Frigate NVR diff --git a/services/homeassistant.sh b/services/homeassistant.sh index bd7488b..360c958 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -91,7 +91,11 @@ HA_CONFIG echo "" log_success "Home Assistant configured at $HOMEASSISTANT_DIR" - configure_caddy_for_service "Home Assistant" "8123" "home" + if [ "$HA_NETMODE" = "2" ]; then + configure_caddy_for_service "Home Assistant" "8123" "home" + else + configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" + fi local START_HA="" prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA diff --git a/services/immich.sh b/services/immich.sh index 09c9561..49d5049 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -572,7 +572,7 @@ IMPORT_BODY log_success "Immich configured at $IMMICH_DIR" - configure_caddy_for_service "Immich" "2283" "immich" + configure_caddy_for_service "Immich" "immich-server:2283" "immich" write_readme "$IMMICH_DIR" << MD # Immich diff --git a/services/jellyfin.sh b/services/jellyfin.sh index 273a3dc..96a4bc8 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -87,7 +87,7 @@ JELLYFIN_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" log_success "Jellyfin configured at $JELLYFIN_DIR" - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" + configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin" write_readme "$JELLYFIN_DIR" << MD # Jellyfin diff --git a/services/js99er.sh b/services/js99er.sh index c62645c..7cd1e9f 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -275,7 +275,7 @@ COMPOSE log_success "js99er configured at $JS99ER_DIR" # ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ─────────── - configure_caddy_for_service "js99er" "$JS99ER_PORT" "js99er" + configure_caddy_for_service "js99er" "js99er:80" "js99er" # ── 6. Build & start ───────────────────────────────────────────────────── local START_JS99ER="" diff --git a/services/magicmirror.sh b/services/magicmirror.sh index aa4e256..afdff96 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -132,7 +132,7 @@ MM_COMPOSE log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" # Offer Caddy only for first instance - [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "$MM_PORT" "mirror" + [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" local START_MM="" prompt_yn "Start instance $i now? (y/n):" "y" START_MM diff --git a/services/mealie.sh b/services/mealie.sh index e8d6fa9..9dfdcca 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -63,7 +63,7 @@ MEALIE_COMPOSE chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" log_success "Mealie configured at $MEALIE_DIR" - configure_caddy_for_service "Mealie" "9925" "recipes" + configure_caddy_for_service "Mealie" "mealie:9000" "recipes" write_readme "$MEALIE_DIR" << MD # Mealie diff --git a/services/meshcentral.sh b/services/meshcentral.sh index e249ccb..c9f6a50 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -74,7 +74,7 @@ MC_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" log_success "MeshCentral configured at $MC_DIR" - configure_caddy_for_service "MeshCentral" "4430" "mesh" + configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh" write_readme "$MC_DIR" << MD # MeshCentral diff --git a/services/ntfy.sh b/services/ntfy.sh index 70cd5bc..060f433 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -55,7 +55,7 @@ NTFY_ENV echo "" log_success "ntfy configured at $NTFY_DIR" - configure_caddy_for_service "ntfy" "8090" "ntfy" + configure_caddy_for_service "ntfy" "ntfy:80" "ntfy" write_readme "$NTFY_DIR" << MD # ntfy diff --git a/services/portainer.sh b/services/portainer.sh index f8a3338..3ee55e2 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -48,7 +48,7 @@ PORTAINER_COMPOSE echo "" log_success "Portainer configured at $PORTAINER_DIR" - configure_caddy_for_service "Portainer" "9000" "portainer" + configure_caddy_for_service "Portainer" "portainer:9000" "portainer" write_readme "$PORTAINER_DIR" << MD # Portainer diff --git a/services/traccar.sh b/services/traccar.sh index 7e91cbe..2c03ec8 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -70,7 +70,7 @@ TRACCAR_XML chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" log_success "Traccar configured at $TRACCAR_DIR" - configure_caddy_for_service "Traccar" "8082" "traccar" + configure_caddy_for_service "Traccar" "traccar:8082" "traccar" write_readme "$TRACCAR_DIR" << MD # Traccar diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index 907b864..e326e8c 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -75,7 +75,7 @@ docker compose logs -f # logs MD # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" + configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" local START_UPTIME="" prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME diff --git a/services/wg-easy.sh b/services/wg-easy.sh index f2dea62..d3ca608 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -79,7 +79,7 @@ WGEASY_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" log_success "wg-easy configured at $WGEASY_DIR" - configure_caddy_for_service "wg-easy" "51821" "vpn" + configure_caddy_for_service "wg-easy" "wg-easy:51821" "vpn" write_readme "$WGEASY_DIR" << MD # wg-easy diff --git a/tools/manage_users.sh b/tools/manage_users.sh new file mode 100644 index 0000000..d8e3cfa --- /dev/null +++ b/tools/manage_users.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash +# manage_users.sh — FileBrowser user management via the REST API. +# +# Placed in ~/docker/filebrowser/ by the filebrowser installer. +# Requires: curl, jq (apt install curl jq) +# +# Usage: +# ./manage_users.sh list +# ./manage_users.sh add [--admin] +# ./manage_users.sh delete +# ./manage_users.sh passwd +# ./manage_users.sh scope +# ./manage_users.sh info +# +# ── Username rules ──────────────────────────────────────────────────────────── +# Letters, numbers, hyphens, underscores only. No spaces or dots. +# Examples: alice bob-smith data_user2 +# +# ── Password rules ──────────────────────────────────────────────────────────── +# Minimum 8 characters. No maximum. +# Must contain at least one letter and one number. +# Special characters are allowed. +# +# ── Scope rules ─────────────────────────────────────────────────────────────── +# Scope is a path INSIDE the container, relative to the FileBrowser root (/srv). +# The volume in docker-compose.yml mounts your host path (FB_PATH) as /srv. +# +# If FB_PATH is ~/drives/data1 then: +# / → full access to ~/drives/data1 +# /music → ~/drives/data1/music only +# /docs/bob → ~/drives/data1/docs/bob only +# +# Admin account created on first login gets scope / by default. +# +# ── Examples ───────────────────────────────────────────────────────────────── +# Add admin with full access: +# ./manage_users.sh add admin / +# +# Add alice with access to just the music directory: +# ./manage_users.sh add alice /music +# +# Add bob as an admin with full access: +# ./manage_users.sh add bob / --admin +# +# Change alice's password: +# ./manage_users.sh passwd alice +# +# Restrict alice to a subdirectory: +# ./manage_users.sh scope alice /music/alice +# +# List all users: +# ./manage_users.sh list +# +# Delete bob: +# ./manage_users.sh delete bob +# +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FB_URL="${FB_URL:-http://localhost:8085}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo " $*"; } + +require_cmd() { + for cmd in "$@"; do + command -v "$cmd" &>/dev/null || die "'$cmd' not found. Install it: sudo apt install $cmd" + done +} + +validate_username() { + local u="$1" + [[ -n "$u" ]] || die "Username cannot be empty." + [[ "$u" =~ ^[a-zA-Z0-9_-]+$ ]] || die "Invalid username '$u'. Only letters, numbers, hyphens, underscores allowed." +} + +validate_password() { + local p="$1" + [[ ${#p} -ge 8 ]] || die "Password too short (minimum 8 characters)." + [[ "$p" =~ [a-zA-Z] ]] || die "Password must contain at least one letter." + [[ "$p" =~ [0-9] ]] || die "Password must contain at least one number." +} + +validate_scope() { + local s="$1" + [[ "$s" == /* ]] || die "Scope must be an absolute path starting with / (e.g. /music or /)" +} + +prompt_password() { + local varname="$1" prompt="${2:-Password}" + local p1 p2 + while true; do + read -r -s -p "$prompt: " p1; echo + read -r -s -p "Confirm: " p2; echo + [[ "$p1" == "$p2" ]] || { echo " Passwords do not match. Try again."; continue; } + validate_password "$p1" + printf -v "$varname" "%s" "$p1" + break + done +} + +# ── Authentication ───────────────────────────────────────────────────────────── +get_token() { + local admin_user admin_pass + read -r -p "FileBrowser admin username [admin]: " admin_user + admin_user="${admin_user:-admin}" + read -r -s -p "FileBrowser admin password: " admin_pass; echo + + local resp + resp=$(curl -s -o /dev/null -w "%{http_code}:%{stderr}" \ + -X POST "$FB_URL/api/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}" 2>/dev/null || true) + + local token + token=$(curl -s -X POST "$FB_URL/api/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}") + + [[ "$token" == *"."*"."* ]] || die "Login failed. Check credentials and that FileBrowser is running." + echo "$token" +} + +# ── API helpers ──────────────────────────────────────────────────────────────── +api_get() { + local token="$1" path="$2" + curl -sf -X GET "$FB_URL$path" -H "X-Auth: $token" +} + +api_post() { + local token="$1" path="$2" body="$3" + curl -sf -X POST "$FB_URL$path" \ + -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" +} + +api_put() { + local token="$1" path="$2" body="$3" + curl -sf -X PUT "$FB_URL$path" \ + -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" +} + +api_delete() { + local token="$1" path="$2" + curl -sf -X DELETE "$FB_URL$path" -H "X-Auth: $token" +} + +# Returns user JSON object for the given username, or empty string if not found. +find_user() { + local token="$1" username="$2" + api_get "$token" "/api/users" | jq -r --arg u "$username" '.[] | select(.username==$u)' +} + +get_user_id() { + local token="$1" username="$2" + local user + user=$(find_user "$token" "$username") + [[ -n "$user" ]] || die "User '$username' not found." + echo "$user" | jq -r '.id' +} + +# ── Default permissions for new non-admin users ────────────────────────────── +default_perms() { + cat <<'JSON' +{ + "admin": false, + "execute": false, + "create": true, + "rename": true, + "modify": true, + "delete": true, + "share": false, + "download": true +} +JSON +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +cmd_list() { + local token + token=$(get_token) + echo + printf "%-20s %-5s %-30s\n" "USERNAME" "ADMIN" "SCOPE" + printf "%-20s %-5s %-30s\n" "--------" "-----" "-----" + api_get "$token" "/api/users" | \ + jq -r '.[] | [.username, (if .perm.admin then "yes" else "no" end), .scope] | @tsv' | \ + while IFS=$'\t' read -r uname is_admin scope; do + printf "%-20s %-5s %s\n" "$uname" "$is_admin" "$scope" + done +} + +cmd_add() { + local username="$1" scope="$2" is_admin="${3:-false}" + validate_username "$username" + validate_scope "$scope" + + local password + echo + echo "Setting password for new user '$username'." + echo " Min 8 chars, at least one letter and one number." + echo + prompt_password password "New password for $username" + + local token + token=$(get_token) + + # Check if user already exists + local existing + existing=$(find_user "$token" "$username") + [[ -z "$existing" ]] || die "User '$username' already exists. Use 'passwd' or 'scope' to modify." + + local perms + perms=$(default_perms) + if [[ "$is_admin" == "true" ]]; then + perms=$(echo "$perms" | jq '.admin = true') + fi + + local body + body=$(jq -n \ + --arg u "$username" \ + --arg p "$password" \ + --arg s "$scope" \ + --argjson perms "$perms" \ + '{username: $u, password: $p, scope: $s, locale: "en", + viewMode: "list", singleClick: false, sorting: {by: "name", asc: true}, + perm: $perms, commands: [], lockPassword: false, + hideDotfiles: false, dateFormat: false}') + + api_post "$token" "/api/users" "$body" >/dev/null + echo + info "User '$username' created." + info " Scope: $scope" + info " Admin: $is_admin" +} + +cmd_delete() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local uid + uid=$(get_user_id "$token" "$username") + + local confirm + read -r -p "Delete user '$username' (id=$uid)? [y/N]: " confirm + [[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } + + api_delete "$token" "/api/users/$uid" >/dev/null + echo + info "User '$username' deleted." +} + +cmd_passwd() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local uid user + uid=$(get_user_id "$token" "$username") + user=$(find_user "$token" "$username") + + echo + echo "Changing password for '$username'." + echo " Min 8 chars, at least one letter and one number." + echo + + local password + prompt_password password "New password for $username" + + local body + body=$(echo "$user" | jq --arg p "$password" '. + {password: $p}') + api_put "$token" "/api/users/$uid" "$body" >/dev/null + echo + info "Password updated for '$username'." +} + +cmd_scope() { + local username="$1" new_scope="$2" + validate_username "$username" + validate_scope "$new_scope" + + local token + token=$(get_token) + + local uid user old_scope + uid=$(get_user_id "$token" "$username") + user=$(find_user "$token" "$username") + old_scope=$(echo "$user" | jq -r '.scope') + + local body + body=$(echo "$user" | jq --arg s "$new_scope" '. + {scope: $s}') + api_put "$token" "/api/users/$uid" "$body" >/dev/null + echo + info "Scope updated for '$username': $old_scope → $new_scope" +} + +cmd_info() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local user + user=$(find_user "$token" "$username") + [[ -n "$user" ]] || die "User '$username' not found." + + echo + echo "$user" | jq '{ + username, + scope, + admin: .perm.admin, + create: .perm.create, + modify: .perm.modify, + delete: .perm.delete, + download: .perm.download, + execute: .perm.execute + }' +} + +usage() { + cat <<'USAGE' +FileBrowser user management + +Usage: + manage_users.sh list + manage_users.sh add [--admin] + manage_users.sh delete + manage_users.sh passwd + manage_users.sh scope + manage_users.sh info + +Scope path is relative to /srv inside the container (= FB_PATH on the host). + / full access to everything under FB_PATH + /music only ~/drives/data1/music (if FB_PATH=~/drives/data1) + /docs/bob only ~/drives/data1/docs/bob + +Username: letters, numbers, hyphens, underscores only (no spaces or dots). +Password: min 8 chars, at least one letter and one number. + +Examples: + ./manage_users.sh add admin / + ./manage_users.sh add alice /music + ./manage_users.sh add bob / --admin + ./manage_users.sh passwd alice + ./manage_users.sh scope alice /music/alice + ./manage_users.sh list + ./manage_users.sh delete bob +USAGE +} + +# ── Dispatch ────────────────────────────────────────────────────────────────── +require_cmd curl jq + +cmd="${1:-help}" +shift || true + +case "$cmd" in + list) cmd_list ;; + add) + [[ $# -ge 2 ]] || die "Usage: manage_users.sh add [--admin]" + is_admin="false" + [[ "${3:-}" == "--admin" ]] && is_admin="true" + cmd_add "$1" "$2" "$is_admin" + ;; + delete) [[ $# -ge 1 ]] || die "Usage: manage_users.sh delete "; cmd_delete "$1" ;; + passwd) [[ $# -ge 1 ]] || die "Usage: manage_users.sh passwd "; cmd_passwd "$1" ;; + scope) + [[ $# -ge 2 ]] || die "Usage: manage_users.sh scope " + cmd_scope "$1" "$2" + ;; + info) [[ $# -ge 1 ]] || die "Usage: manage_users.sh info "; cmd_info "$1" ;; + help|--help|-h) usage ;; + *) echo "Unknown command: $cmd"; echo; usage; exit 2 ;; +esac