Files
esh-pfi-infrastructure/stacks/news-digest/web.py
T
vh f692b7ec7a news-digest: per-item × button + cross-device hidden tray
Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.

Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.

Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).

Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.

Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
2026-04-26 15:05:25 -07:00

89 lines
2.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""news-digest-web — FastAPI app that serves the digest + a tiny hidden-items API.
Replaces the old nginx web container. Two responsibilities:
1. Serve every file in /output as static content (index.html,
edition-*.html, archive.html, style.css, favicon.svg, app.js).
2. Expose /api/{hidden,hide,restore} so the per-item × button can
persist hidden state server-side, shared across every device the
user opens the digest from.
Storage is a single /output/hidden.json — array of item IDs the user
has hidden. Atomic writes via tempfile + rename; a threading lock
serializes the read-modify-write inside this single uvicorn worker.
Single-user setup, no auth (the digest itself is unauthenticated on
LAN; same trust boundary applies).
Item IDs are stable 12-char sha1 prefixes computed by digest.py at
render time and embedded in the page as `data-id` on each `.item`.
The frontend (templates/app.js) reads /api/hidden once on page load,
hides matching items pre-paint, and hits /api/hide and /api/restore
on user interactions.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
HIDDEN_FILE = OUTPUT_DIR / "hidden.json"
app = FastAPI(title="news-digest-web")
_lock = threading.Lock()
def _load_hidden() -> set[str]:
if not HIDDEN_FILE.exists():
return set()
try:
data = json.loads(HIDDEN_FILE.read_text())
return set(data) if isinstance(data, list) else set()
except (json.JSONDecodeError, OSError):
return set()
def _save_hidden(ids: set[str]) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
tmp = HIDDEN_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(sorted(ids)))
tmp.replace(HIDDEN_FILE)
class IdBody(BaseModel):
id: str
@app.get("/api/hidden")
def get_hidden() -> list[str]:
return sorted(_load_hidden())
@app.post("/api/hide")
def post_hide(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.add(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
@app.post("/api/restore")
def post_restore(body: IdBody) -> dict[str, object]:
with _lock:
ids = _load_hidden()
ids.discard(body.id)
_save_hidden(ids)
return {"ok": True, "count": len(ids)}
# Mounted last so /api/* routes win precedence over a (nonexistent)
# /api/* file. html=True makes index.html the directory default,
# matching nginx's `try_files` behavior we used to rely on.
app.mount("/", StaticFiles(directory=str(OUTPUT_DIR), html=True), name="static")