diff --git a/scripts/add-digest-user.sh b/scripts/add-digest-user.sh new file mode 100755 index 0000000..fbf906d --- /dev/null +++ b/scripts/add-digest-user.sh @@ -0,0 +1,173 @@ +#!/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 + +USER_ARG="${1:?usage: $0 [password]}" +USER_PASS="${2:-}" +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 " 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." diff --git a/stacks/news-digest/.env.example b/stacks/news-digest/.env.example index 824ed89..d5f46b0 100644 --- a/stacks/news-digest/.env.example +++ b/stacks/news-digest/.env.example @@ -64,3 +64,12 @@ DIGEST_MINIFLUX_HEADLINES_MAX=15 # Separate from /opt/docker/conf// to keep generated content # distinct from config. Owned by container UID; writes are atomic. NEWS_DIGEST_OUTPUT_DIR=/opt/docker/data/news-digest + +# ── multi-tenant (optional) ────────────────────────────────────────── +# Per-user instances are deployed via scripts/add-digest-user.sh, which +# materializes a per-user .env and sets DIGEST_PROJECT to namespace +# container names + homepage labels. Leave unset for the singleton +# install — defaults preserve the original "news-digest" naming. +# DIGEST_PROJECT=digest-alice +# DIGEST_HOMEPAGE_NAME=Alice's Digest +# DIGEST_HOMEPAGE_DESC=Personal news brief for Alice diff --git a/stacks/news-digest/compose.yaml b/stacks/news-digest/compose.yaml index 293da26..ed7f61d 100644 --- a/stacks/news-digest/compose.yaml +++ b/stacks/news-digest/compose.yaml @@ -21,7 +21,11 @@ services: build: context: . dockerfile: Dockerfile - container_name: news-digest-worker + # ${DIGEST_PROJECT} prefixes container names so multiple instances + # (one per teammate, scoped to their miniflux user) can coexist on + # the same host. Default keeps backward-compat with the original + # singleton deploy. + container_name: ${DIGEST_PROJECT:-news-digest}-worker restart: unless-stopped environment: - TZ=${NEWS_DIGEST_TZ:-America/Los_Angeles} @@ -57,7 +61,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: news-digest-web + container_name: ${DIGEST_PROJECT:-news-digest}-web restart: unless-stopped depends_on: - news-digest-worker @@ -90,9 +94,9 @@ services: - tnet labels: - homepage.group=News - - homepage.name=Daily Digest + - homepage.name=${DIGEST_HOMEPAGE_NAME:-Daily Digest} - homepage.icon=mdi-newspaper-variant-outline - - homepage.description=LLM-curated briefing across feeds, twice daily + - homepage.description=${DIGEST_HOMEPAGE_DESC:-LLM-curated briefing across feeds, twice daily} - homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT} networks: diff --git a/stacks/news-digest/templates/style.css b/stacks/news-digest/templates/style.css index d9a63ba..ddb60ef 100644 --- a/stacks/news-digest/templates/style.css +++ b/stacks/news-digest/templates/style.css @@ -899,9 +899,15 @@ a:hover { color: var(--accent); } padding: 24px var(--pad-x) 16px; row-gap: 12px; } + /* Desktop pins brand+edition both to grid-row 1, which collapses + them on top of each other once we go single-column. Reset row + placement so they auto-flow vertically. */ .masthead-brand, .masthead-edition, - .masthead-meta { grid-column: 1 / 2; } + .masthead-meta { + grid-column: 1 / 2; + grid-row: auto; + } .masthead-edition { justify-content: flex-start; flex-wrap: wrap; gap: 12px; } .edition-stamp { font-size: 16px; padding: 3px 8px; } .masthead-meta {