#!/usr/bin/env bash # add-digest-user.sh — provision a per-user news-digest stack. # # Multi-tenant onboarding for the daily-digest applet. One miniflux # instance, multiple miniflux users, one news-digest stack per user # (own port, own output dir, own hide-state). # # Usage: # scripts/add-digest-user.sh # generate password # scripts/add-digest-user.sh # set explicit password # # What it does: # 1. Reads miniflux admin creds from ana-docker:/opt/docker/compose/miniflux/.env # 2. Allocates the next free NEWS_DIGEST_PORT above 8181 # 3. Creates a miniflux user via the admin API # 4. Creates per-user dirs on ana-docker (sudo prompt expected once) # 5. Materializes a per-user .env at /opt/docker/compose/digest-/ # 6. Brings up the per-user stack (`docker compose -p digest- up -d`) # 7. Seeds default world/local/tech feeds in the new user's miniflux account # # Idempotent-ish: re-running for an existing user re-syncs config + feeds # but won't recreate the miniflux user (409 from /v1/users is non-fatal). set -euo pipefail usage() { cat < [password] [--am "MIN HR * * *"] [--pm "MIN HR * * *"] Defaults: AM = "0 8 * * *", PM = "0 20 * * *". Cron syntax is standard 5-field; busybox crond honors the container's \$TZ. Examples: $0 alice $0 bob --am "0 6 * * *" --pm "0 17 * * *" $0 carol s3cret --pm "30 18 * * 1-5" # weekdays only PM run EOF exit 1 } USER_ARG="" USER_PASS="" CRON_AM="0 8 * * *" CRON_PM="0 20 * * *" while [ "$#" -gt 0 ]; do case "$1" in --am) CRON_AM="$2"; shift 2 ;; --pm) CRON_PM="$2"; shift 2 ;; --help|-h) usage ;; --*) echo "unknown flag: $1" >&2; usage ;; *) if [ -z "$USER_ARG" ]; then USER_ARG="$1" elif [ -z "$USER_PASS" ]; then USER_PASS="$1" else echo "unexpected positional arg: $1" >&2; usage fi shift ;; esac done [ -n "$USER_ARG" ] || usage HOST=ana-docker WORKSTATION_STACK="$(dirname "$0")/../stacks/news-digest" PROJECT="digest-$USER_ARG" HOST_COMPOSE_DIR="/opt/docker/compose/$PROJECT" HOST_DATA_DIR="/opt/docker/data/$PROJECT" PORT_BASE=8181 bold() { printf '\033[1m%s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } bold "→ provisioning news-digest for user '$USER_ARG'" # 1. Pull miniflux admin creds from host info "reading miniflux admin creds from $HOST" admin_creds=$(ssh "$HOST" 'grep -E "^MINIFLUX_ADMIN_(USERNAME|PASSWORD)=" /opt/docker/compose/miniflux/.env') admin_user=$(awk -F= '/^MINIFLUX_ADMIN_USERNAME=/ {sub(/^MINIFLUX_ADMIN_USERNAME=/, ""); print}' <<<"$admin_creds") admin_pass=$(awk -F= '/^MINIFLUX_ADMIN_PASSWORD=/ {sub(/^MINIFLUX_ADMIN_PASSWORD=/, ""); print}' <<<"$admin_creds") [ -n "$admin_user" ] && [ -n "$admin_pass" ] || { echo "FATAL: could not read miniflux admin creds" >&2; exit 1; } # 2. Allocate next free port. Use `find` so the glob doesn't blow up # when there are zero per-user digest-*/.env files yet. info "scanning for used digest ports..." used_ports=$(ssh "$HOST" 'find /opt/docker/compose -maxdepth 2 -mindepth 2 -name .env \( -path "*/news-digest/*" -o -path "*/digest-*/*" \) -exec grep -h "^NEWS_DIGEST_PORT=" {} + 2>/dev/null | cut -d= -f2 | sort -un' || true) new_port=$PORT_BASE while echo "$used_ports" | grep -qx "$new_port"; do new_port=$((new_port + 1)) done info "allocated port: $new_port (in use: ${used_ports//$'\n'/, })" # 3. Generate password if not provided if [ -z "$USER_PASS" ]; then USER_PASS=$(openssl rand -base64 18 | tr -d '/+=') fi info "user password: $USER_PASS" # 4. Create miniflux user via admin API (skip silently if 409) info "creating miniflux user '$USER_ARG'..." http_code=$(ssh "$HOST" "curl -s -o /dev/null -w '%{http_code}' \ -u '$admin_user:$admin_pass' \ -H 'Content-Type: application/json' \ -d '{\"username\":\"$USER_ARG\",\"password\":\"$USER_PASS\",\"is_admin\":false}' \ http://10.250.50.70:8080/v1/users") case "$http_code" in 201) info " created" ;; 400|409) info " already exists (HTTP $http_code) — keeping existing user, password reset NOT performed" ;; *) echo "FATAL: miniflux /v1/users returned HTTP $http_code" >&2; exit 1 ;; esac # 5. Provision per-user dirs. Sudo on the host needs a TTY for the # password prompt; if we're being run non-interactively (piped, in # a script), check whether the dirs already exist and bail with a # manual command if they don't. info "checking host dirs..." if ssh "$HOST" "[ -w '$HOST_COMPOSE_DIR' ] && [ -w '$HOST_DATA_DIR' ]" 2>/dev/null; then info " exist + writable, skipping sudo step" elif [ -t 0 ] && [ -t 1 ]; then info " creating (sudo prompt incoming)..." ssh -t "$HOST" "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && \ sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR" else cat >&2 < $HOST_COMPOSE_DIR/.env" <&1 | sed 's/^/ /'" # 8. Wait for worker to be alive then seed feeds info "waiting for worker to be ready..." for i in $(seq 1 30); do if ssh "$HOST" "docker exec $PROJECT-worker test -f /app/seed-headlines.py" 2>/dev/null; then break fi sleep 2 done info "seeding default feeds in miniflux for $USER_ARG..." ssh "$HOST" "docker exec $PROJECT-worker python3 /app/seed-headlines.py 2>&1 | sed 's/^/ /'" # 9. Trigger first digest run so the page isn't blank info "triggering first digest run (this can take ~90s)..." ssh "$HOST" "docker exec $PROJECT-worker /usr/local/bin/run-digest.sh 2>&1 | tail -3 | sed 's/^/ /'" || true bold "" bold "✓ provisioned digest for $USER_ARG" echo echo " digest URL : http://10.250.50.70:$new_port/" echo " miniflux UI : http://10.250.50.70:8080/ (login: $USER_ARG / $USER_PASS)" echo " schedule : AM '$CRON_AM' / PM '$CRON_PM' (TZ from \$NEWS_DIGEST_TZ)" echo " compose dir : $HOST:$HOST_COMPOSE_DIR/" echo " output dir : $HOST:$HOST_DATA_DIR/" echo echo " hand the URL + miniflux creds to the user; they can manage their" echo " feed subscriptions via the miniflux UI."