news-digest: multi-tenant deploy + masthead overlap fix

Two pieces:

1) Multi-tenant onboarding via scripts/add-digest-user.sh

   Shared miniflux + per-user digest stack. Onboarding a teammate
   takes one command (plus a one-time sudo for dir creation):

     scripts/add-digest-user.sh <username>

   What the script does:
     - Reads miniflux admin creds from ana-docker
     - Allocates next free port (scans existing digest-*/.env)
     - Generates a random password (or accepts one as 2nd arg)
     - Creates the miniflux user via the admin API
     - Materializes a per-user .env at /opt/docker/compose/digest-<user>/
       (inherits NEWS_DIGEST_TAG from the canonical stack so all
       tenants run the same image)
     - Brings up `docker compose -p digest-<user> up -d`
     - Seeds default world/local feeds in the new user's miniflux
     - Triggers a first digest run

   compose.yaml now uses ${DIGEST_PROJECT:-news-digest} to namespace
   container_name + homepage labels. Default keeps backward-compat
   for the singleton install — existing stacks unaffected.

2) Masthead overlap on phone widths

   Desktop CSS pinned .masthead-edition to grid-row 1, which collided
   with .masthead-brand once the mobile media query collapsed both
   to grid-column 1. Result: "MORNING EDITION" badge stacked on top
   of the "DAILY DIGEST" hero. Reset grid-row to `auto` for all
   three masthead children in the ≤720 px breakpoint so they
   auto-flow vertically.
This commit is contained in:
2026-04-28 13:53:26 -07:00
parent be7cb298b6
commit aeb5c18ca3
4 changed files with 197 additions and 5 deletions
+173
View File
@@ -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 <username> # generate password
# scripts/add-digest-user.sh <username> <pass> # 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-<user>/
# 6. Brings up the per-user stack (`docker compose -p digest-<user> 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 <username> [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 <<EOF
Run this on your terminal first (this script can't drive sudo without a TTY):
ssh -t $HOST "sudo mkdir -p $HOST_COMPOSE_DIR $HOST_DATA_DIR && sudo chown -R lkraven:lkraven $HOST_COMPOSE_DIR $HOST_DATA_DIR"
Then re-run: scripts/add-digest-user.sh $USER_ARG $USER_PASS
EOF
exit 1
fi
# 6. Sync compose + materialize .env
info "syncing compose.yaml..."
scp -q "$WORKSTATION_STACK/compose.yaml" "$HOST:$HOST_COMPOSE_DIR/compose.yaml"
# Also need the build context (Dockerfile, *.py, templates/) so the
# image can build if it's not already cached. Use the existing tree.
scp -q "$WORKSTATION_STACK/Dockerfile" "$HOST:$HOST_COMPOSE_DIR/Dockerfile"
scp -q "$WORKSTATION_STACK"/{digest.py,seed-headlines.py,web.py,entrypoint.sh,run-digest.sh,crontab} "$HOST:$HOST_COMPOSE_DIR/"
ssh "$HOST" "mkdir -p $HOST_COMPOSE_DIR/templates"
scp -q "$WORKSTATION_STACK"/templates/* "$HOST:$HOST_COMPOSE_DIR/templates/"
info "materializing .env..."
# Inherit the image tag from the canonical news-digest stack so all
# per-user instances use the same built image (otherwise we'd accidentally
# pin to whatever was current when this script was last edited).
canonical_tag=$(ssh "$HOST" 'awk -F= "/^NEWS_DIGEST_TAG=/ {print \$2}" /opt/docker/compose/news-digest/.env' || echo "v1")
info "using image tag: $canonical_tag (inherited from canonical)"
ssh "$HOST" "cat > $HOST_COMPOSE_DIR/.env" <<EOF
# Auto-generated by scripts/add-digest-user.sh on $(date -Iseconds)
# User: $USER_ARG
NEWS_DIGEST_TAG=$canonical_tag
DIGEST_PROJECT=$PROJECT
DIGEST_HOMEPAGE_NAME=$USER_ARG's Digest
DIGEST_HOMEPAGE_DESC=Personal LLM-curated brief for $USER_ARG
NEWS_DIGEST_PORT=$new_port
NEWS_DIGEST_BIND=0.0.0.0
NEWS_DIGEST_TZ=America/Los_Angeles
LLAMA_SWAP_URL=http://10.250.50.54:9292
LLAMA_SWAP_MODEL=granite-4-small
LLAMA_SWAP_TIMEOUT=180
MINIFLUX_URL=http://miniflux:8080
MINIFLUX_USER=$USER_ARG
MINIFLUX_PASSWORD=$USER_PASS
DIGEST_REDDIT_HOURS=12
DIGEST_MIN_SCORE=50
DIGEST_MIN_RATIO=0.85
DIGEST_MAX_PER_SUB=8
DIGEST_MINIFLUX_HOURS=12
DIGEST_MINIFLUX_MAX=8
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
DIGEST_MINIFLUX_WORLD_CATEGORY=World
DIGEST_MINIFLUX_LOCAL_CATEGORY=Local
DIGEST_MINIFLUX_HEADLINES_HOURS=8
DIGEST_MINIFLUX_HEADLINES_MAX=15
NEWS_DIGEST_OUTPUT_DIR=$HOST_DATA_DIR
EOF
# 7. Bring up the per-user stack
info "bringing stack up (compose project: $PROJECT)..."
ssh "$HOST" "cd $HOST_COMPOSE_DIR && docker compose -p $PROJECT up -d 2>&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."
+9
View File
@@ -64,3 +64,12 @@ DIGEST_MINIFLUX_HEADLINES_MAX=15
# Separate from /opt/docker/conf/<stack>/ 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
+8 -4
View File
@@ -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:
+7 -1
View File
@@ -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 {