Files
upbeatBytes/goodnews/api.py
T
thejayman77 ddcfab3a11 Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
  read-only). queries.source_articles + source_articles_summary — per article:
  title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
  (domain rule), has_image, duplicate. Summary = counts + source-level paywall
  rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
  · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
  image/Duplicates), compact rows with title→link + badges + reason, Load more.

So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:37:51 -04:00

1870 lines
79 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.
"""FastAPI service for goodNews.
A read-only JSON API over the ingestion database, plus a small static site that
consumes it. The same endpoints back both the website and any future companion
app; the auto-generated OpenAPI docs at /docs are that shared contract.
Run with the bundled CLI: goodnews serve
Or directly: uvicorn goodnews.api:app --host 0.0.0.0 --port 8000
The database path comes from GOODNEWS_DB (falling back to the repo's data dir),
so the API and CLI always read the same file.
"""
from __future__ import annotations
import csv
import hashlib
import hmac
import io
import json
import os
import re
import secrets
import sqlite3
from collections import Counter
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, email_send, feeds, games, oauth_google, queries, share, sources, summarize
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead
from .llm import LocalModelClient
from .moods import MOODS, mood_filter
from .lanes import build_lane_pool
from .paywall import is_paywalled
from .taxonomy import FAMILIES, FLAVORS, TOPICS
# Edge-cache directives for GLOBAL endpoints — responses that depend only on the
# URL, never the session/user, so Cloudflare may safely serve one visitor's copy
# to another (this is what makes "Gathering the good news…" resolve from the edge
# instead of a round-trip to the residential origin). The personalization
# boundary is hard: anything session- or filter-specific stays private/no-store.
_EDGE_CONFIG = "public, max-age=0, s-maxage=900, stale-while-revalidate=600" # static config (moods/categories)
_EDGE_DERIVED = "public, max-age=0, s-maxage=120, stale-while-revalidate=120" # global, data-derived (lanes/families)
_EDGE_FEED = "public, max-age=0, s-maxage=45, stale-while-revalidate=30" # global feed (URL-keyed, shareable only)
_PRIVATE = "private, no-store" # never share across users
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
FRONTEND_DIR = ROOT / "frontend" / "build"
LEGACY_STATIC = Path(__file__).resolve().parent / "static"
STATIC_DIR = FRONTEND_DIR if FRONTEND_DIR.is_dir() else LEGACY_STATIC
def db_path() -> Path:
return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB)))
# --- Auth helpers -----------------------------------------------------------
PUBLIC_BASE_URL = os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/")
SESSION_COOKIE = "ub_session"
OAUTH_COOKIE = "ub_oauth"
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
SESSION_SECRET = os.environ.get("GOODNEWS_SESSION_SECRET", "dev-insecure-secret")
# Emails that are always admins (normalized), in addition to users.is_admin.
ADMIN_EMAILS = {e.strip().lower() for e in os.environ.get("GOODNEWS_ADMIN_EMAILS", "").split(",") if e.strip()}
# Secure cookies in production (https); off for http (local/test) so they round-trip.
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _sign(value: str) -> str:
sig = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest()
return f"{value}.{sig}"
def _unsign(signed: str | None) -> str | None:
if not signed or "." not in signed:
return None
value, _, sig = signed.rpartition(".")
expected = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest()
return value if hmac.compare_digest(sig, expected) else None
def _google_redirect_uri() -> str:
return f"{PUBLIC_BASE_URL}/api/auth/google/callback"
def _session_token_from_request(request: Request) -> str | None:
"""Web sends the session as an httpOnly cookie; the app sends a bearer token."""
cookie = request.cookies.get(SESSION_COOKIE)
if cookie:
return cookie
authz = request.headers.get("Authorization", "")
return authz[7:].strip() if authz.startswith("Bearer ") else None
def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | None:
user = auth.resolve_session(conn, _session_token_from_request(request))
if user:
conn.commit() # persist the last_seen touch
return user
def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
user = _current_user(conn, request)
if not user:
raise HTTPException(status_code=401, detail="Sign in to do that.")
return user
def _is_admin(user: sqlite3.Row) -> bool:
return bool(user["is_admin"]) or auth.normalize_email(user["email"]) in ADMIN_EMAILS
def _require_admin(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
user = _require_user(conn, request)
if not _is_admin(user):
raise HTTPException(status_code=403, detail="Admins only.")
return user
def _user_out(user: sqlite3.Row) -> dict:
return {
"id": user["id"],
"email": user["email"],
"display_name": user["display_name"],
"avatar_url": user["avatar_url"],
"is_admin": _is_admin(user),
"digest_enabled": bool(user["digest_enabled"]),
}
# Articles whose summary is being generated right now — so concurrent pollers /
# scrapers don't each kick off a duplicate LLM call.
_summarizing: set[int] = set()
def _run_summary(article_id: int) -> None:
try:
with get_conn() as conn:
summarize.generate_summary(conn, article_id)
except Exception:
pass
finally:
_summarizing.discard(article_id)
def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
if article_id in _summarizing:
return
_summarizing.add(article_id)
background_tasks.add_task(_run_summary, article_id)
def _feedback_email_safe(addr: str, category: str, message: str, contact: str | None, who: str) -> None:
try:
email_send.send_feedback(addr, category, message, contact, who)
except Exception:
pass
def _send_link_safe(email: str, link: str) -> None:
"""Send the magic link, swallowing failures (runs off the request path)."""
try:
email_send.send_magic_link(email, link)
except Exception:
pass # don't crash the worker; never surfaced to the caller anyway
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
SESSION_COOKIE, token, max_age=SESSION_MAX_AGE,
httponly=True, secure=_COOKIE_SECURE, samesite="lax", path="/",
)
@contextmanager
def get_conn():
conn = connect(db_path())
try:
yield conn
finally:
conn.close()
def _prefs_sql_kw(fp, now) -> dict:
"""Categorical prefs → queries.feed keyword filters (avoid-terms stay Python)."""
return dict(
include_topics=fp.include_topics or None,
include_flavors=fp.include_flavors or None,
mute_topics=list(fp.muted_topics(now)) or None,
mute_flavors=list(fp.muted_flavors(now)) or None,
max_cortisol=fp.max_cortisol,
max_ragebait=fp.max_ragebait,
)
def _pick_lead(items: list[dict]) -> list[dict]:
"""Lead with a gentle, readable, ideally illustrated story.
Preference order: gentle + readable + has an image, then gentle + readable,
then gentle, then leave the order alone. Charged/paywalled/imageless stories
still appear in the set — they just don't lead.
"""
def gentle(a: dict) -> bool:
return safe_to_lead(a) and not is_paywalled(a.get("canonical_url"))
for ok in (
lambda a: gentle(a) and bool(a.get("image_url")),
gentle,
safe_to_lead,
):
for i, a in enumerate(items):
if ok(a):
return items if i == 0 else [a, *items[:i], *items[i + 1:]]
return items
# --- Response models (the companion-app contract) ---------------------------
class Category(BaseModel):
key: str
description: str
class CategoriesResponse(BaseModel):
topics: list[Category]
flavors: list[Category]
class CategoryCount(BaseModel):
topic: str | None
flavor: str | None
count: int
class Article(BaseModel):
id: int
title: str
description: str | None = None
url: str
image_url: str | None = None
published_at: str | None = None
source: str
source_id: int | None = None
topic: str | None = None
flavor: str | None = None
accepted: bool
rank_score: int | None = None
reason_code: str | None = None
reason_text: str | None = None
model_name: str | None = None
rank: int | None = None # position within a brief, when applicable
paywalled: bool = False
tags: list[str] = []
summary: str | None = None # our own cached summary (present on the brief)
@classmethod
def from_row(cls, row: dict) -> "Article":
raw_tags = row.get("tags")
return cls(
summary=row.get("summary"),
id=row["id"],
title=row["title"],
description=row.get("description"),
url=row["canonical_url"],
image_url=row.get("image_url"),
published_at=row.get("published_at"),
source=row["source_name"],
source_id=row.get("source_id"),
topic=row.get("topic"),
flavor=row.get("flavor"),
accepted=bool(row.get("accepted")),
rank_score=row.get("rank_score"),
reason_code=row.get("reason_code"),
reason_text=row.get("reason_text"),
model_name=row.get("model_name"),
rank=row.get("rank"),
paywalled=is_paywalled(row.get("canonical_url")),
tags=[t for t in (raw_tags.split(",") if raw_tags else []) if t],
)
class FeedResponse(BaseModel):
topic: str | None
flavor: str | None
count: int
items: list[Article]
class BriefResponse(BaseModel):
brief_date: str | None
title: str | None
generated_at: str | None = None # freshness stamp: changes only when content changes
items: list[Article]
class RejectedExample(BaseModel):
title: str
reason: str
class Candidate(BaseModel):
id: int
feed_url: str
homepage_url: str | None = None
name: str | None = None
status: str
preview: dict | None = None
notes: str | None = None
last_previewed_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class SourcePreview(BaseModel):
url: str
sampled: int
classified: bool
accepted: int
acceptance_rate: float
avg_cortisol: float
avg_ragebait: float
avg_pr_risk: float
newest_published: str | None
recent_7d: int
topic_mix: dict[str, int]
flavor_mix: dict[str, int]
examples_accepted: list[str]
examples_rejected: list[RejectedExample]
class WordGuessRequest(BaseModel):
variant: str = "5"
guess: str
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
class GameStateBody(BaseModel):
game: str
variant: str
date: str
state: dict = {}
class WordPoolBody(BaseModel):
word: str
class WordPoolImportBody(BaseModel):
text: str = ""
words: list[str] = []
class ClientErrorBody(BaseModel):
reason: str = ""
path: str = ""
version: str = ""
class WordsearchThemeBody(BaseModel):
theme: str
words: list[str] = []
id: int | None = None
class WordsearchSuggestBody(BaseModel):
theme: str
existing: list[str] = []
class EmailStartRequest(BaseModel):
email: str
class TokenVerifyRequest(BaseModel):
token: str
class UserOut(BaseModel):
id: int
email: str
display_name: str | None = None
avatar_url: str | None = None
is_admin: bool = False
digest_enabled: bool = False
class SessionOut(BaseModel):
user: UserOut
token: str # for non-browser (app) clients; the web SPA uses the cookie
class IdsBody(BaseModel):
ids: list[int] = []
class ImportBody(BaseModel):
seen: list[int] = []
saved: list[int] = []
class PrefsBody(BaseModel):
prefs: dict = {}
class EventBody(BaseModel):
kind: str
article_id: int | None = None
visitor: str | None = None
class FeedbackBody(BaseModel):
category: str = "other"
message: str = ""
email: str | None = None
visitor: str | None = None
hp: str | None = None # honeypot — bots fill it, humans don't
class FeedbackReadBody(BaseModel):
read: bool = True
class FeedbackReplyBody(BaseModel):
html: str = "" # raw editor HTML — sanitized server-side before use
class SourceStatusBody(BaseModel):
status: str = "active" # active | paused | retired
class SourceVisibilityBody(BaseModel):
visible: bool = True
class CandidateSuggestBody(BaseModel):
feed_url: str = ""
name: str | None = None
class CandidatePromoteBody(BaseModel):
default_category: str | None = None
active: bool = False # promote-as-paused by default; opt in to activate
trust_score: int = 5
pr_risk_score: int = 3
poll_interval_minutes: int = 180
class CandidateRenameBody(BaseModel):
name: str = ""
class DigestBody(BaseModel):
enabled: bool = True
class FollowBody(BaseModel):
kind: str # 'source' | 'tag'
value: str # source id (as text) or tag key
class SourceReviewBody(BaseModel):
flag: bool = False
reason: str | None = None
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
# The only event kinds we record. All aggregate, non-personal.
_EVENT_KINDS = {
"visit", "open", "summary_viewed", "full_story", "source_click",
"share_ub", "copy_source", "native_share",
"not_today", "less_like_this", "hide_topic",
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
}
def _visitor_hash(token: str | None) -> str:
token = (token or "").strip()[:200]
if not token:
return ""
return hashlib.sha256(f"{SESSION_SECRET}:{token}".encode()).hexdigest()
# --- App --------------------------------------------------------------------
def create_app() -> FastAPI:
app = FastAPI(
title="goodNews API",
version="0.1.0",
description="Constructive, uplifting news — metadata and links only.",
)
# The website and companion app may live on other origins; allow them.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@app.get("/healthz")
def healthz() -> dict:
# Read-only: the schema is owned by the ingestion CLI, so the API never
# writes (it can run as a read-only replica against a shared DB).
try:
with get_conn() as conn:
scored = conn.execute("SELECT COUNT(*) FROM article_scores").fetchone()[0]
except sqlite3.Error:
scored = 0
return {"status": "ok", "scored_articles": scored}
# --- Auth: passwordless magic link (Google added in Phase 2) ----------
@app.post("/api/auth/email/start")
def auth_email_start(body: EmailStartRequest, background_tasks: BackgroundTasks) -> dict:
email = auth.normalize_email(body.email)
if not _EMAIL_RE.match(email):
raise HTTPException(status_code=422, detail="Please enter a valid email address.")
link = None
with get_conn() as conn:
# Light abuse guard: cap recent tokens per address (still reply OK).
recent = conn.execute(
"SELECT COUNT(*) FROM login_tokens WHERE email = ? "
"AND created_at > datetime('now', '-10 minutes')",
(email,),
).fetchone()[0]
if recent < 5:
raw = auth.create_login_token(conn, email)
conn.commit()
link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}"
# Hand the (slow) SMTP send to a background task so the request returns
# immediately. Reply is always identical (no account enumeration).
if link:
background_tasks.add_task(_send_link_safe, email, link)
return {"ok": True}
@app.post("/api/auth/email/verify", response_model=SessionOut)
def auth_email_verify(body: TokenVerifyRequest, request: Request, response: Response) -> SessionOut:
with get_conn() as conn:
email = auth.consume_login_token(conn, body.token)
if not email:
conn.commit()
raise HTTPException(status_code=400, detail="This sign-in link is invalid or has expired.")
user_id = auth.find_or_create_user(conn, email, "email", email)
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
conn.commit()
user = auth.get_user(conn, user_id)
_set_session_cookie(response, token)
return SessionOut(user=UserOut(**_user_out(user)), token=token)
@app.get("/api/auth/me", response_model=UserOut | None)
def auth_me(request: Request) -> UserOut | None:
with get_conn() as conn:
user = _current_user(conn, request)
return UserOut(**_user_out(user)) if user else None
@app.post("/api/account/digest")
def account_digest(body: DigestBody, request: Request) -> dict:
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
raise HTTPException(status_code=401, detail="sign in required")
token = user["digest_unsub_token"]
if body.enabled and not token:
token = secrets.token_urlsafe(18)
conn.execute(
"UPDATE users SET digest_enabled = ?, digest_unsub_token = ? WHERE id = ?",
(1 if body.enabled else 0, token, user["id"]),
)
conn.commit()
return {"ok": True, "digest_enabled": body.enabled}
def _do_unsubscribe(u: int, t: str) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT digest_unsub_token FROM users WHERE id = ?", (u,)).fetchone()
if row and row["digest_unsub_token"] and hmac.compare_digest(row["digest_unsub_token"], t):
conn.execute("UPDATE users SET digest_enabled = 0 WHERE id = ?", (u,))
conn.commit()
return True
return False
@app.post("/api/digest/unsubscribe")
def digest_unsubscribe_oneclick(u: int = Query(...), t: str = Query(...)) -> dict:
# RFC 8058 one-click: the mailbox provider POSTs here; just do it + 200.
_do_unsubscribe(u, t)
return {"ok": True}
@app.get("/api/digest/unsubscribe", response_class=HTMLResponse)
def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse:
# One-click, no login: match the per-user token, then turn the digest off.
ok = _do_unsubscribe(u, t)
msg = (
"Youre unsubscribed from the daily digest. No hard feelings — "
"Upbeat Bytes is always here when you want it."
if ok else
"That unsubscribe link looks invalid or expired. You can manage the "
"digest from your account settings."
)
html = (
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
)
return HTMLResponse(html)
@app.post("/api/auth/logout")
def auth_logout(request: Request, response: Response) -> dict:
with get_conn() as conn:
auth.revoke_session(conn, _session_token_from_request(request))
conn.commit()
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
# --- Auth: Google (OAuth 2.0 / OIDC) ----------------------------------
@app.get("/api/auth/google/start")
def google_start() -> RedirectResponse:
if not oauth_google.configured():
raise HTTPException(status_code=503, detail="Google sign-in isn't configured.")
state = secrets.token_urlsafe(24)
verifier, challenge = oauth_google.new_pkce()
url = oauth_google.auth_url(_google_redirect_uri(), state, challenge)
resp = RedirectResponse(url, status_code=302)
# Bind the flow to this browser; read back (and CSRF-checked) on callback.
resp.set_cookie(
OAUTH_COOKIE, _sign(f"{state}:{verifier}"), max_age=600,
httponly=True, secure=_COOKIE_SECURE, samesite="lax", path="/",
)
return resp
@app.get("/api/auth/google/callback")
def google_callback(
request: Request,
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
fail = RedirectResponse(f"{PUBLIC_BASE_URL}/auth/verify?error=google", status_code=302)
if error or not code or not state:
return fail
saved = _unsign(request.cookies.get(OAUTH_COOKIE))
if not saved:
return fail
saved_state, _, verifier = saved.partition(":")
if not hmac.compare_digest(saved_state, state):
return fail
try:
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
info = oauth_google.verify_id_token(tokens["id_token"])
if not info.get("picture") and tokens.get("access_token"):
info["picture"] = oauth_google.fetch_userinfo(tokens["access_token"]).get("picture")
except Exception:
return fail
with get_conn() as conn:
user_id = auth.find_or_create_user(
conn, info["email"], "google", info["sub"],
display_name=info.get("name"), avatar_url=info.get("picture"),
)
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
conn.commit()
ok = RedirectResponse(f"{PUBLIC_BASE_URL}/", status_code=302)
_set_session_cookie(ok, token)
ok.delete_cookie(OAUTH_COOKIE, path="/")
return ok
# --- Saved articles, history, and one-time import (all require sign-in) ---
@app.get("/api/saved", response_model=FeedResponse)
def saved_list(request: Request) -> FeedResponse:
with get_conn() as conn:
user = _require_user(conn, request)
rows = queries.saved(conn, user["id"])
items = [Article.from_row(r) for r in rows]
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
@app.get("/api/saved/ids")
def saved_id_list(request: Request) -> list[int]:
with get_conn() as conn:
user = _require_user(conn, request)
return queries.saved_ids(conn, user["id"])
# --- Follows: durable source / tag interests (require sign-in) ---
def _follows_for(conn, user_id: int) -> list[dict]:
rows = conn.execute(
"SELECT kind, value FROM user_follows WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
).fetchall()
out = []
for r in rows:
d = {"kind": r["kind"], "value": r["value"], "name": r["value"]}
if r["kind"] == "source" and r["value"].isdigit():
src = conn.execute("SELECT name FROM sources WHERE id = ?", (int(r["value"]),)).fetchone()
if src:
d["name"] = src["name"]
out.append(d)
return out
@app.get("/api/follows")
def follows_list(request: Request) -> list[dict]:
with get_conn() as conn:
user = _require_user(conn, request)
return _follows_for(conn, user["id"])
@app.post("/api/follows")
def follow_add(body: FollowBody, request: Request) -> dict:
if body.kind not in ("source", "tag"):
raise HTTPException(status_code=422, detail="kind must be 'source' or 'tag'")
value = (body.value or "").strip()
if body.kind == "tag":
value = value.lower()
if not value:
raise HTTPException(status_code=422, detail="value is required")
with get_conn() as conn:
user = _require_user(conn, request)
if body.kind == "source":
if not value.isdigit() or not conn.execute(
"SELECT 1 FROM sources WHERE id = ?", (int(value),)
).fetchone():
raise HTTPException(status_code=404, detail="source not found")
conn.execute(
"INSERT OR IGNORE INTO user_follows (user_id, kind, value) VALUES (?, ?, ?)",
(user["id"], body.kind, value),
)
conn.commit()
return {"ok": True, "kind": body.kind, "value": value}
@app.delete("/api/follows")
def follow_remove(request: Request, kind: str = Query(...), value: str = Query(...)) -> dict:
v = value.strip().lower() if kind == "tag" else value.strip()
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"DELETE FROM user_follows WHERE user_id = ? AND kind = ? AND value = ?", (user["id"], kind, v)
)
conn.commit()
return {"ok": True}
@app.post("/api/saved/{article_id}")
def save_article(article_id: int, request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
if not conn.execute("SELECT 1 FROM articles WHERE id = ?", (article_id,)).fetchone():
raise HTTPException(status_code=404, detail="No such article.")
conn.execute(
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
(user["id"], article_id),
)
conn.commit()
return {"saved": True}
@app.delete("/api/saved/{article_id}")
def unsave_article(article_id: int, request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"DELETE FROM saved_articles WHERE user_id = ? AND article_id = ?",
(user["id"], article_id),
)
conn.commit()
return {"saved": False}
@app.get("/api/history", response_model=FeedResponse)
def history_list(request: Request) -> FeedResponse:
with get_conn() as conn:
user = _require_user(conn, request)
rows = queries.history(conn, user["id"])
items = [Article.from_row(r) for r in rows]
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
@app.post("/api/history")
def record_history(body: IdsBody, request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
for aid in queries.existing_article_ids(conn, body.ids):
conn.execute(
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
"VALUES (?, ?, 'seen')",
(user["id"], aid),
)
conn.commit()
return {"ok": True}
@app.delete("/api/history")
def clear_history(request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute("DELETE FROM user_history WHERE user_id = ?", (user["id"],))
conn.commit()
return {"ok": True}
@app.delete("/api/history/{article_id}")
def remove_history(article_id: int, request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"DELETE FROM user_history WHERE user_id = ? AND article_id = ?",
(user["id"], article_id),
)
conn.commit()
return {"ok": True}
# --- Prefs sync (Calm Filters / Boundaries follow the account) --------
@app.get("/api/prefs")
def get_prefs(request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
row = conn.execute(
"SELECT prefs_json FROM user_prefs WHERE user_id = ?", (user["id"],)
).fetchone()
if not row:
return {"prefs": None} # no row yet → caller seeds from the device
try:
return {"prefs": json.loads(row["prefs_json"])}
except (ValueError, TypeError):
return {"prefs": None}
@app.put("/api/prefs")
def put_prefs(body: PrefsBody, request: Request) -> dict:
blob = json.dumps(body.prefs)[:20000]
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"INSERT INTO user_prefs (user_id, prefs_json, updated_at) "
"VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(user_id) DO UPDATE SET prefs_json = excluded.prefs_json, "
"updated_at = CURRENT_TIMESTAMP",
(user["id"], blob),
)
conn.commit()
return {"ok": True}
# --- Account: profile, sessions, export, delete -----------------------
@app.get("/api/account")
def account_info(request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
providers = [r["provider"] for r in conn.execute(
"SELECT provider FROM identities WHERE user_id = ?", (user["id"],)
)]
sessions = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?", (user["id"],)
).fetchone()[0]
saved = conn.execute(
"SELECT COUNT(*) FROM saved_articles WHERE user_id = ?", (user["id"],)
).fetchone()[0]
return {
"user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]},
"providers": providers,
"sessions": sessions,
"saved_count": saved,
}
@app.post("/api/account/logout-all")
def logout_all(request: Request, response: Response) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user["id"],))
conn.commit()
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
@app.get("/api/account/export")
def export_account(request: Request) -> Response:
with get_conn() as conn:
user = _require_user(conn, request)
uid = user["id"]
providers = [r["provider"] for r in conn.execute(
"SELECT provider FROM identities WHERE user_id = ?", (uid,)
)]
saved = queries.saved(conn, uid, limit=10000)
hist = queries.history(conn, uid, limit=10000)
prow = conn.execute(
"SELECT prefs_json FROM user_prefs WHERE user_id = ?", (uid,)
).fetchone()
slim = lambda a: {"id": a["id"], "title": a["title"], "url": a["canonical_url"]}
data = {
"account": {"id": uid, "email": user["email"],
"display_name": user["display_name"], "created_at": user["created_at"]},
"sign_in_methods": providers,
"saved": [slim(a) for a in saved],
"history": [slim(a) for a in hist],
"preferences": json.loads(prow["prefs_json"]) if prow else None,
}
return Response(
content=json.dumps(data, indent=2),
media_type="application/json",
headers={"Content-Disposition": "attachment; filename=upbeatbytes-data.json"},
)
@app.delete("/api/account")
def delete_account(request: Request, response: Response) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute("DELETE FROM users WHERE id = ?", (user["id"],)) # cascades to all account data
conn.commit()
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
# --- Public share/landing page for an article -------------------------
@app.get("/a/{article_id}", response_class=HTMLResponse)
def share_page(article_id: str, background_tasks: BackgroundTasks) -> HTMLResponse:
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
try:
aid = int(article_id)
except (TypeError, ValueError):
return not_found # malformed id → calm 404, no stack trace
with get_conn() as conn:
row = conn.execute(
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
"a.duplicate_of, a.source_id, src.name AS source_name, s.reason_text, s.accepted, "
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
"FROM articles a JOIN sources src ON src.id = a.source_id "
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
(aid,),
).fetchone()
# Only render real, accepted, non-duplicate stories.
if not row or row["duplicate_of"] is not None or not row["accepted"]:
return not_found
summary = summarize.get_summary(conn, aid)
explanation = summarize.get_explanation(conn, aid)
if not summary or not explanation:
_kick_summary(aid, background_tasks) # generate/top-up for next time; page polls
return HTMLResponse(
share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation)
)
# --- Privacy-respecting first-party analytics -------------------------
@app.post("/api/events")
def record_event(body: EventBody) -> dict:
if body.kind in _EVENT_KINDS:
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
"VALUES (?, ?, ?, date('now'))",
(body.kind, body.article_id or 0, _visitor_hash(body.visitor)),
)
conn.commit()
return {"ok": True} # always identical; dedup'd by the unique key
@app.post("/api/client-error")
def record_client_error(body: ClientErrorBody, request: Request) -> dict:
# Boot-failure seatbelt telemetry — what blank-risk looks like in the wild.
ua = (request.headers.get("user-agent") or "")[:300]
with get_conn() as conn:
conn.execute(
"INSERT INTO client_errors (reason, path, user_agent, app_version) VALUES (?, ?, ?, ?)",
((body.reason or "")[:500], (body.path or "")[:200], ua, (body.version or "")[:60]),
)
conn.execute("DELETE FROM client_errors WHERE created_at < datetime('now','-14 days')")
conn.commit()
return {"ok": True}
@app.get("/api/admin/client-errors")
def admin_client_errors(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
rows = conn.execute(
"SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
).fetchall()
# Bots stay visible in the list (tagged) but are excluded from the
# headline counts — see queries.admin_stats.
return [{**dict(r), "bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
@app.post("/api/feedback")
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
if body.hp: # honeypot tripped → accept silently, store nothing
return {"ok": True}
message = (body.message or "").strip()[:4000]
if not message:
raise HTTPException(status_code=422, detail="Please add a short message.")
category = body.category if body.category in _FEEDBACK_CATEGORIES else "other"
email = ((body.email or "").strip()[:200]) or None
vh = _visitor_hash(body.visitor)
with get_conn() as conn:
if vh: # light flood cap per anonymous token per day
recent = conn.execute(
"SELECT COUNT(*) FROM feedback WHERE visitor_hash = ? AND day = date('now')", (vh,)
).fetchone()[0]
if recent >= 8:
return {"ok": True}
user = _current_user(conn, request)
conn.execute(
"INSERT INTO feedback (category, message, contact_email, user_id, visitor_hash, day) "
"VALUES (?, ?, ?, ?, ?, date('now'))",
(category, message, email, user["id"] if user else None, vh),
)
conn.commit()
who = user["email"] if user else "anonymous visitor"
for addr in ADMIN_EMAILS:
background_tasks.add_task(_feedback_email_safe, addr, category, message, email, who)
return {"ok": True}
@app.get("/api/admin/feedback")
def admin_feedback(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
rows = conn.execute(
"SELECT f.id, f.category, f.message, f.contact_email, f.created_at, f.read_at, "
"u.email AS user_email FROM feedback f LEFT JOIN users u ON u.id = f.user_id "
"ORDER BY f.created_at DESC LIMIT 200"
).fetchall()
items = [dict(r) for r in rows]
if items:
ids = [it["id"] for it in items]
ph = ",".join("?" * len(ids))
reps = conn.execute(
f"SELECT id, feedback_id, message, message_html, sent_to, sent_at FROM feedback_replies "
f"WHERE feedback_id IN ({ph}) ORDER BY sent_at",
ids,
).fetchall()
by_fid: dict = {}
for r in reps:
by_fid.setdefault(r["feedback_id"], []).append(dict(r))
for it in items:
it["replies"] = by_fid.get(it["id"], [])
return items
@app.post("/api/admin/feedback/{fid}/read")
def admin_feedback_read(fid: int, body: FeedbackReadBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
ts = "CURRENT_TIMESTAMP" if body.read else "NULL"
cur = conn.execute(f"UPDATE feedback SET read_at = {ts} WHERE id = ?", (fid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="feedback not found")
conn.commit()
return {"ok": True, "read": body.read}
@app.delete("/api/admin/feedback/{fid}")
def admin_feedback_delete(fid: int, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute("DELETE FROM feedback WHERE id = ?", (fid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="feedback not found")
conn.commit()
return {"ok": True}
@app.post("/api/admin/feedback/{fid}/reply")
def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict:
# Cap the RAW editor HTML first (slicing sanitized output could sever a
# tag), then sanitize the whole thing.
reply_html = sanitize_reply_html((body.html or "")[:20000])
reply_text = reply_html_to_text(reply_html)
if not reply_text:
raise HTTPException(status_code=422, detail="Reply message is required.")
# 1. Validate + gather, then release the DB connection — SMTP can take
# ~20s and shouldn't keep a connection open.
with get_conn() as conn:
admin = _require_admin(conn, request)
fb = conn.execute("SELECT contact_email, message FROM feedback WHERE id = ?", (fid,)).fetchone()
if not fb:
raise HTTPException(status_code=404, detail="feedback not found")
if not fb["contact_email"]:
raise HTTPException(status_code=400, detail="No reply address for this feedback.")
admin_id, to, original = admin["id"], fb["contact_email"], fb["message"]
# 2. Send with no DB connection held; only record a reply that actually
# went out, so the UI can keep the draft on failure.
try:
email_send.send_feedback_reply(to, reply_text, reply_html, original)
except Exception as exc: # noqa: BLE001 — surface any SMTP failure to the admin
raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}")
# 3. Record the sent reply + mark the item read.
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) "
"VALUES (?, ?, ?, ?, ?)",
(fid, admin_id, reply_text, reply_html, to),
)
conn.execute(
"UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,)
)
conn.commit()
reply = conn.execute(
"SELECT id, feedback_id, message, message_html, sent_to, sent_at FROM feedback_replies WHERE id = ?",
(cur.lastrowid,),
).fetchone()
return {"ok": True, "reply": dict(reply)}
@app.post("/api/admin/sources/{sid}/status")
def admin_source_status(sid: int, body: SourceStatusBody, request: Request) -> dict:
# Lifecycle: active | paused | retired. Stops/resumes polling only —
# existing articles stay visible (use /visibility to hide). `active` is
# kept mirrored so the scheduler/CLI keep working off the legacy flag.
if body.status not in ("active", "paused", "retired"):
raise HTTPException(status_code=422, detail="invalid status")
active = 1 if body.status == "active" else 0
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute(
"UPDATE sources SET status = ?, active = ? WHERE id = ?", (body.status, active, sid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "status": body.status, "active": active}
@app.post("/api/admin/sources/{sid}/visibility")
def admin_source_visibility(sid: int, body: SourceVisibilityBody, request: Request) -> dict:
# Pull a source's existing articles out of (or back into) the public feed
# and brief. Reversible; never deletes anything.
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute(
"UPDATE sources SET content_visible = ? WHERE id = ?", (1 if body.visible else 0, sid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "visible": body.visible}
@app.post("/api/admin/sources/{sid}/preview")
def admin_source_preview(sid: int, request: Request) -> dict:
# Read-only spot-check of a LIVE source: safe-fetch + heuristic preview.
# Mutates nothing — no DB write, no poll attempt, no health/state change.
with get_conn() as conn:
_require_admin(conn, request)
src = conn.execute("SELECT feed_url FROM sources WHERE id = ?", (sid,)).fetchone()
if not src:
raise HTTPException(status_code=404, detail="source not found")
url = src["feed_url"]
return _preview_or_502(url) # safe fetch, no DB connection held
@app.get("/api/admin/sources/{sid}/articles")
def admin_source_articles(sid: int, request: Request, filter: str = "all",
limit: int = 25, offset: int = 0) -> dict:
# Read-only inspector: the REAL ingested articles behind a source's metrics,
# so paywall/image/acceptance/duplicate signals can be verified against evidence.
limit = max(1, min(int(limit), 100))
offset = max(0, int(offset))
with get_conn() as conn:
_require_admin(conn, request)
if not conn.execute("SELECT 1 FROM sources WHERE id = ?", (sid,)).fetchone():
raise HTTPException(status_code=404, detail="source not found")
arts = queries.source_articles(conn, sid, filter, limit, offset)
return {
"articles": arts,
"summary": queries.source_articles_summary(conn, sid) if offset == 0 else None,
"has_more": len(arts) == limit,
}
# --- Source candidates (supervised add-a-source pipeline) ----------------
def _candidate_dict(row) -> dict:
d = dict(row)
raw = d.pop("preview_json", None)
try:
d["preview"] = json.loads(raw) if raw else None
except (ValueError, TypeError):
d["preview"] = None
return d
@app.get("/api/admin/candidates")
def admin_candidates(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
rows = sources.list_candidates(conn)
return [_candidate_dict(r) for r in rows]
def _preview_or_502(url: str, deep: bool = False) -> dict:
# SSRF-safe fetch (admin-pasted URL is untrusted). Default is the fast
# heuristic; deep=True also runs the real LLM classifier on a small sample
# (slower, ~5-7s/item — the true acceptance view, not an estimate).
client = None
if deep:
try:
client = LocalModelClient.from_env()
except Exception: # noqa: BLE001 — fall back to heuristic if the model is down
client = None
try:
return feeds.preview_feed(
url, sample=(8 if deep else 20), fetcher=feeds.safe_fetch_feed, client=client
)
except Exception as exc: # noqa: BLE001 — surface a readable reason
raise HTTPException(status_code=502, detail=f"Couldn't preview that feed: {exc}")
@app.post("/api/admin/candidates")
def admin_candidate_suggest(body: CandidateSuggestBody, request: Request) -> dict:
url = (body.feed_url or "").strip()
if not url:
raise HTTPException(status_code=422, detail="feed_url is required")
with get_conn() as conn: # gate BEFORE the outbound fetch
_require_admin(conn, request)
# Don't re-add a feed that's already live or already queued (catches
# http/https · www · trailing-slash variants, not just exact dups).
existing = sources.find_existing_feed(conn, url)
if existing:
where = "already a source" if existing["kind"] == "source" else "already in the candidate queue"
raise HTTPException(
status_code=409,
detail=f"{existing['name']}” is {where} ({existing['status']}). "
"Find it below — Re-preview it there if you want a fresh read.",
)
preview = _preview_or_502(url) # no DB connection held during network I/O
with get_conn() as conn:
_require_admin(conn, request)
row = sources.save_candidate(conn, url, preview=preview, name=(body.name or None), status="suggested")
return _candidate_dict(row)
@app.post("/api/admin/candidates/{cid}/preview")
def admin_candidate_repreview(cid: int, request: Request, deep: bool = False) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
cand = conn.execute("SELECT feed_url FROM source_candidates WHERE id = ?", (cid,)).fetchone()
if not cand:
raise HTTPException(status_code=404, detail="candidate not found")
url = cand["feed_url"]
preview = _preview_or_502(url, deep=deep) # conn released during the (slow) model pass
with get_conn() as conn:
_require_admin(conn, request)
row = sources.save_candidate(conn, url, preview=preview)
return _candidate_dict(row)
@app.post("/api/admin/candidates/{cid}/rename")
def admin_candidate_rename(cid: int, body: CandidateRenameBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
cand = conn.execute("SELECT status FROM source_candidates WHERE id = ?", (cid,)).fetchone()
if not cand:
raise HTTPException(status_code=404, detail="candidate not found")
# Match the UI policy server-side: a promoted/rejected candidate is
# settled history — rename only while it's pending review.
if cand["status"] in ("promoted", "rejected"):
raise HTTPException(status_code=409, detail=f"Can't rename a {cand['status']} candidate.")
name = (body.name or "").strip()[:160] or None # cap so a pasted paragraph can't wreck the queue
row = sources.rename_candidate(conn, cid, name)
return _candidate_dict(row)
@app.post("/api/admin/candidates/{cid}/promote")
def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
try:
source_id = sources.promote_candidate(
conn, cid, active=body.active, default_category=body.default_category,
trust_score=body.trust_score, pr_risk_score=body.pr_risk_score,
poll_interval_minutes=body.poll_interval_minutes,
)
except sources.DuplicateFeedError as exc:
ex = exc.existing
raise HTTPException(
status_code=409,
detail=f"{ex['name']}” is already a source ({ex['status']}) — "
"promote skipped so its settings aren't overwritten.",
)
except ValueError:
raise HTTPException(status_code=404, detail="candidate not found")
src = conn.execute(
"SELECT id, name, status, active, content_visible FROM sources WHERE id = ?", (source_id,)
).fetchone()
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
return {"ok": True, "source_id": source_id, "source": dict(src), "candidate": _candidate_dict(cand)}
@app.post("/api/admin/candidates/{cid}/reject")
def admin_candidate_reject(cid: int, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
if not sources.reject_candidate(conn, cid):
raise HTTPException(status_code=404, detail="candidate not found")
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
return _candidate_dict(cand)
# --- CSV exports (admin-gated, for inspection / archiving) ---------------
def _csv_cell(v):
# Defuse CSV formula injection: a cell a spreadsheet might evaluate (=,+,-,@)
# gets a leading apostrophe. Numbers pass through untouched (no risk, and
# this avoids mangling any legitimate negative value).
if v is None:
return ""
if isinstance(v, (int, float)):
return v
s = str(v)
return "'" + s if s[:1] in ("=", "+", "-", "@") else s
def _csv_response(filename: str, write) -> Response:
buf = io.StringIO()
writer = csv.writer(buf)
write(lambda values: writer.writerow([_csv_cell(v) for v in values]))
return Response(
content=buf.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.get("/api/admin/export/sources.csv")
def admin_export_sources(request: Request) -> Response:
# Current-state snapshot of every source (active + paused + retired).
with get_conn() as conn:
_require_admin(conn, request)
rows = queries.source_health(conn)
def write(row):
row([
"name", "feed_url", "homepage", "status", "visible", "served", "accepted_total",
"total_articles", "acceptance_pct", "duplicate_pct", "accepted_dup_pct",
"image_coverage_pct", "paywalled", "last_success", "last_error", "retry_after", "review_flag", "review_reason",
])
for s in rows:
row([
s["name"], s["feed_url"], s.get("homepage_url") or "",
s.get("status") or "", "yes" if s.get("content_visible") else "no",
s["served"], s["accepted_total"], s["total_articles"],
s["acceptance_rate"], s["duplicate_rate"], s["accepted_dup_rate"], s["image_coverage"],
"yes" if s.get("paywalled") else "no",
s.get("last_success_at") or "", s.get("last_error") or "", s.get("retry_after_at") or "",
"yes" if s.get("review_flag") else "no", s.get("review_reason") or "",
])
return _csv_response("sources.csv", write)
@app.get("/api/admin/export/audience.csv")
def admin_export_audience(request: Request, days: int = Query(30)) -> Response:
days = days if days in (7, 30, 90) else 30
with get_conn() as conn:
_require_admin(conn, request)
st = queries.admin_stats(conn, days=days)
def write(row):
v, a = st["visitors"], st["accounts"]
row(["metric", "value"])
for label, value in [
("window_days", st["days"]),
("visitors_today", v["today"]), ("visitors_7d", v["d7"]), ("visitors_30d", v["d30"]),
("returning_30d", st.get("returning", 0)), ("once_30d", st.get("once", 0)),
("accounts_total", a["total"]), ("accounts_new_7d", a["new_7d"]), ("accounts_active_7d", a["active_7d"]),
("feedback_7d", st.get("feedback_7d", 0)), ("feedback_unread", st.get("feedback_unread", 0)),
]:
row([label, value])
for kind, n in (st.get("shares") or {}).items():
row([f"share_{kind}", n])
row([]) # blank line, then the daily time series
row(["date", "visitors", "opens"])
for d in st.get("daily", []):
row([d["day"], d["visits"], d["opens"]])
return _csv_response("audience.csv", write)
@app.post("/api/admin/sources/{sid}/review")
def admin_source_review(sid: int, body: SourceReviewBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute(
"UPDATE sources SET review_flag = ?, review_reason = ? WHERE id = ?",
(1 if body.flag else 0, (body.reason or None) if body.flag else None, sid),
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "flag": body.flag}
@app.get("/api/admin/stats")
def admin_stats(request: Request, days: int = Query(30)) -> dict:
days = days if days in (7, 30, 90) else 30 # clamp to the offered windows
with get_conn() as conn:
_require_admin(conn, request)
return queries.admin_stats(conn, days=days)
@app.get("/api/summary/{article_id}")
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
with get_conn() as conn:
summary = summarize.get_summary(conn, article_id)
explanation = summarize.get_explanation(conn, article_id)
if summary:
return {"status": "ready", "summary": summary, "explanation": explanation}
_kick_summary(article_id, background_tasks)
return {"status": "pending", "summary": None, "explanation": None}
@app.get("/today", response_class=HTMLResponse)
def today_digest() -> HTMLResponse:
with get_conn() as conn:
b = queries.brief(conn)
items = b.get("items") or []
if not items:
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
@app.get("/sitemap.xml")
def sitemap() -> Response:
with get_conn() as conn:
rows = conn.execute(
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL "
"ORDER BY lm DESC LIMIT 5000"
).fetchall()
base = PUBLIC_BASE_URL
urls = [
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
]
for r in rows:
lm = (r["lm"] or "")[:10]
lastmod = f"<lastmod>{lm}</lastmod>" if lm else ""
urls.append(f"<url><loc>{base}/a/{r['id']}</loc>{lastmod}</url>")
xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
+ "".join(urls) + "</urlset>"
)
return Response(content=xml, media_type="application/xml")
@app.post("/api/import")
def import_local(body: ImportBody, request: Request) -> dict:
"""Fold this device's anonymous history/saved into the account (one-time)."""
with get_conn() as conn:
user = _require_user(conn, request)
for aid in queries.existing_article_ids(conn, body.seen):
conn.execute(
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
"VALUES (?, ?, 'seen')",
(user["id"], aid),
)
for aid in queries.existing_article_ids(conn, body.saved):
conn.execute(
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
(user["id"], aid),
)
conn.commit()
return {"ok": True}
@app.get("/api/categories", response_model=CategoriesResponse)
def categories(response: Response) -> CategoriesResponse:
response.headers["Cache-Control"] = _EDGE_CONFIG # static taxonomy, identical for everyone
return CategoriesResponse(
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
)
@app.get("/api/moods")
def moods(response: Response) -> list[dict]:
# The humane front door: each mood resolves to a filter preset the
# client merges with the user's own Calm Filters.
response.headers["Cache-Control"] = _EDGE_CONFIG # static presets, identical for everyone
return MOODS
@app.get("/api/lanes")
def lanes(response: Response) -> dict:
# The customizable quick-access rail: 'today' is always pinned, and the
# reader pins any subset of these moods / topics / Discovery tags. Live
# counts let the client gate empty lanes and show volume.
response.headers["Cache-Control"] = _EDGE_DERIVED # global counts, no per-user data
with get_conn() as conn:
tagc = queries.tag_counts(conn)
topicc: dict[str, int] = {}
for row in queries.category_counts(conn):
topicc[row["topic"]] = topicc.get(row["topic"], 0) + int(row["count"])
return build_lane_pool(topicc, tagc)
@app.get("/api/families")
def families(response: Response) -> list[dict]:
# Grouping vocabulary organised into calm families for the Explore UI.
response.headers["Cache-Control"] = _EDGE_DERIVED # global vocab + counts, no per-user data
with get_conn() as conn:
counts = queries.tag_counts(conn)
return [
{
"name": name,
"description": d["description"],
"tags": [{"key": t, "count": counts.get(t, 0)} for t in d["tags"]],
}
for name, d in FAMILIES.items()
]
@app.get("/api/category-counts", response_model=list[CategoryCount])
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
fp = prefs_from_json(prefs)
with get_conn() as conn:
if fp.is_empty():
rows = queries.category_counts(conn, accepted_only=accepted_only)
else:
# Count over the SAME filtered set the feed would return, so the
# browse numbers always match what the user actually sees.
allrows = queries.feed(conn, accepted_only=accepted_only, limit=100000, offset=0)
kept = filter_articles(allrows, fp, datetime.now(timezone.utc))
counts = Counter((r["topic"], r["flavor"]) for r in kept)
rows = [
{"topic": t, "flavor": f, "count": n}
for (t, f), n in sorted(counts.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1])))
]
return [CategoryCount(**row) for row in rows]
@app.get("/api/feed", response_model=FeedResponse)
def feed(
response: Response,
topic: str | None = Query(None),
flavor: str | None = Query(None),
accepted_only: bool = True,
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
prefs: str | None = Query(None),
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
tag: str | None = Query(None, description="grouping tag to browse"),
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
request: Request = None,
) -> FeedResponse:
# Edge-cacheable ONLY when the response depends purely on the URL: not the
# following feed (reads the session's follows) and not personal filters
# (prefs/dismissals are per-reader). The shareable cases — the default
# home feed, topic/flavor/tag/source browse — are identical for everyone,
# so the edge can serve one copy to all. Everything else stays private.
shareable = not following and not prefs and not exclude.strip()
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}")
if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}")
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
# Categorical filters (include/mute topics+flavors incl. active pauses,
# cortisol ceiling) go to SQL so nothing is truncated by ranking. Only
# word-boundary avoid-terms and dismissals need a Python pass.
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn:
if following:
user = _current_user(conn, request)
if not user:
return FeedResponse(topic=topic, flavor=flavor, count=0, items=[])
frows = conn.execute(
"SELECT kind, value FROM user_follows WHERE user_id = ?", (user["id"],)
).fetchall()
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
if fp.avoid_terms or excl:
# Over-fetch enough to cover what the Python pass might remove.
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=fetch_n, offset=0, tag=tag, source_id=source_id, sort=sort, **kw,
)
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
rows = kept[offset : offset + limit]
else:
rows = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=limit, offset=offset, tag=tag, source_id=source_id, sort=sort, **kw,
)
# Keep the top of a browse view readable: stable-sort paywalled items
# below readable ones (composite order preserved within each group).
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
return FeedResponse(
topic=topic,
flavor=flavor,
count=len(rows),
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/puzzle/{game}")
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
with get_conn() as conn:
if game == "word" and variant in games.WORD_VARIANTS:
return games.word_puzzle_response(conn, local_today(), variant)
if game == "wordsearch":
return games.wordsearch_response(conn, local_today(), variant)
raise HTTPException(status_code=404, detail="no such puzzle")
@app.post("/api/puzzle/word/guess")
def word_guess(body: WordGuessRequest) -> dict:
if body.variant not in games.WORD_VARIANTS:
raise HTTPException(status_code=404, detail="no such puzzle")
with get_conn() as conn:
res = games.adjudicate_word_guess(conn, local_today(), body.variant, body.guess, body.n)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return res
# --- Cross-device game state sync (signed-in only; merged server-side) ---
def _game_ok(game: str, variant: str) -> bool:
return (game == "word" and variant in games.WORD_VARIANTS) or \
(game == "wordsearch" and variant in games.WS_TIERS)
def _valid_pdate(d: str) -> bool:
return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", d or "")) # plain YYYY-MM-DD, no junk rows
@app.get("/api/games/state")
def game_state_get(game: str, variant: str, date: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
if not _valid_pdate(date):
raise HTTPException(status_code=400, detail="bad date")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": None}
return {"state": games.load_game_state(conn, user["id"], game, variant, date)}
@app.put("/api/games/state")
def game_state_put(body: GameStateBody, request: Request) -> dict:
if not _game_ok(body.game, body.variant):
raise HTTPException(status_code=404, detail="no such game")
if not _valid_pdate(body.date):
raise HTTPException(status_code=400, detail="bad date")
if len(json.dumps(body.state)) > 20000: # a real game state is tiny — reject junk
raise HTTPException(status_code=413, detail="state too large")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": body.state} # signed out → no sync, just echo
merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date, body.state or {})
return {"state": merged}
@app.get("/api/games/stats")
def game_stats_get(game: str, variant: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
with get_conn() as conn:
user = _current_user(conn, request)
return {"stats": games.game_stats(conn, user["id"], game, variant) if user else None}
# --- Admin: Daily Word pool curation ---
@app.get("/api/admin/word/lookup")
def admin_word_lookup(word: str, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.lookup_word(word)
res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"])
res["removed"] = bool(res["variant"]) and res["word"] in games._db_removed(conn, res["variant"])
return res
@app.get("/api/admin/word/pool")
def admin_word_pool(request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
return games.pool_summary(conn)
@app.post("/api/admin/word/pool")
def admin_word_pool_add(body: WordPoolBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.add_pool_word(conn, body.word)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return {**res, "pool": games.pool_summary(conn)}
@app.delete("/api/admin/word/pool/{word}")
def admin_word_pool_remove(word: str, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
games.remove_pool_word(conn, word)
return games.pool_summary(conn)
@app.post("/api/admin/word/pool/restore")
def admin_word_pool_restore(body: WordPoolBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
games.restore_pool_word(conn, body.word)
return games.pool_summary(conn)
@app.post("/api/admin/word/pool/import")
def admin_word_pool_import(body: WordPoolImportBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
words = list(body.words)
if body.text:
words += re.findall(r"[A-Za-z]+", body.text)
res = games.import_pool_words(conn, words)
return {**res, "pool": games.pool_summary(conn)}
# --- Admin: Word Search theme authoring ---
@app.get("/api/admin/wordsearch/themes")
def admin_ws_themes(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
return games.list_wordsearch_themes(conn)
@app.post("/api/admin/wordsearch/themes")
def admin_ws_theme_save(body: WordsearchThemeBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.save_wordsearch_theme(conn, body.theme, body.words, body.id)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return {**res, "themes": games.list_wordsearch_themes(conn)}
@app.delete("/api/admin/wordsearch/themes/{tid}")
def admin_ws_theme_remove(tid: int, request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
games.remove_wordsearch_theme(conn, tid)
return games.list_wordsearch_themes(conn)
@app.post("/api/admin/wordsearch/suggest")
def admin_ws_suggest(body: WordsearchSuggestBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
from .llm import LocalModelClient
try:
client = LocalModelClient.from_env()
except Exception: # noqa: BLE001
client = None
res = games.suggest_wordsearch_word(client, body.theme, body.existing)
if "error" in res:
raise HTTPException(status_code=503, detail=res["error"])
return res
@app.get("/api/since", response_model=FeedResponse)
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
# since the reader's last visit (boundary-respecting). count = how many;
# items = a few to show inline. No nagging, no unread state stored.
try:
norm = ts.replace("Z", "+00:00")
dt = datetime.fromisoformat(norm)
since = (dt.astimezone(timezone.utc) if dt.tzinfo else dt).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return FeedResponse(topic=None, flavor=None, count=0, items=[])
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn:
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
if fp.avoid_terms:
rows = filter_articles(rows, fp, now)
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
return FeedResponse(topic=None, flavor=None, count=len(rows), items=[Article.from_row(r) for r in rows[:8]])
@app.get("/api/brief", response_model=BriefResponse)
def brief(
response: Response,
date: str | None = Query(None),
limit: int = Query(10, ge=1, le=50),
prefs: str | None = Query(None),
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
) -> BriefResponse:
# The default highlights are global (date-keyed, no session) → edge-cacheable
# so a new visitor's "Gathering the good news…" resolves from their POP, not
# a pull to the residential origin. Personal filters stay private.
shareable = not prefs and not exclude.strip()
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
with get_conn() as conn:
data = queries.brief(conn, brief_date=date, limit=limit)
# Drop dismissed (replaced-away) items and anything the reader's
# boundaries hide; avoid-terms take precedence over curation.
items = [a for a in data["items"] if a["id"] not in excl]
if not fp.is_empty():
items = filter_articles(items, fp, now)
# Keep the highlights full: if a boundary or a dismissal removed a
# story, top up with other readable, boundary-respecting good news
# rather than show fewer.
if len(items) < limit:
have = {a["id"] for a in items} | excl
pool = queries.feed(
conn, accepted_only=True, limit=limit * 5 + 40, offset=0, **_prefs_sql_kw(fp, now)
)
for a in filter_articles(pool, fp, now):
if len(items) >= limit:
break
if a["id"] not in have:
items.append(a)
have.add(a["id"])
# Lead with a gentle, readable story (charged or paywalled stories stay
# in the set, just not as the first thing seen).
items = _pick_lead(items)
return BriefResponse(
brief_date=data["brief_date"],
title=data["title"],
generated_at=data.get("created_at"),
items=[Article.from_row(r) for r in items],
)
@app.get("/api/brief-dates", response_model=list[str])
def brief_dates(limit: int = Query(30, ge=1, le=365)) -> list[str]:
with get_conn() as conn:
return queries.available_dates(conn, limit=limit)
@app.get("/api/replacement", response_model=Article | None)
def replacement(
exclude: str = Query("", description="comma-separated article ids already shown"),
prefs: str | None = Query(None),
avoid_paywall: bool = True,
gentle: bool = Query(False, description="also require lead-safe (for replacing the hero)"),
) -> Article | None:
# Swap a read or paywalled item for the next-best one the reader can
# actually open. The client merges any active mood into `prefs` (same as
# the feed), so this needs no mood param.
fp = prefs_from_json(prefs)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
now = datetime.now(timezone.utc)
kw = dict(
include_topics=fp.include_topics or None,
include_flavors=fp.include_flavors or None,
mute_topics=list(fp.muted_topics(now)) or None,
mute_flavors=list(fp.muted_flavors(now)) or None,
max_cortisol=fp.max_cortisol,
max_ragebait=fp.max_ragebait,
)
with get_conn() as conn:
rows = queries.feed(conn, accepted_only=True, limit=120, offset=0, **kw)
for r in filter_articles(rows, fp, now):
if r["id"] in excl:
continue
if avoid_paywall and is_paywalled(r["canonical_url"]):
continue
if gentle and not safe_to_lead(r):
continue
return Article.from_row(r)
return None
@app.get("/api/candidates", response_model=list[Candidate])
def candidates(status: str | None = Query(None)) -> list[Candidate]:
from .sources import list_candidates
with get_conn() as conn:
rows = list_candidates(conn, status=status)
out = []
for r in rows:
d = dict(r)
pj = d.pop("preview_json", None)
d["preview"] = json.loads(pj) if pj else None
out.append(Candidate(**d))
return out
@app.get("/api/source-preview", response_model=SourcePreview)
def source_preview(
url: str = Query(..., max_length=2048),
sample: int = Query(25, ge=1, le=50),
classify: bool = Query(False, description="Also classify with the local model (accurate but slower)"),
) -> SourcePreview:
# Read-only sample scoring; nothing is persisted. Only http(s) is allowed.
# NOTE: fetching a user-supplied URL is an SSRF surface — before exposing
# this publicly, also block private/loopback/link-local address ranges.
if not re.match(r"^https?://", url, re.IGNORECASE):
raise HTTPException(400, "url must start with http:// or https://")
client = LocalModelClient.from_env() if classify else None
try:
data = feeds.preview_feed(url, sample=sample, client=client)
except Exception as exc:
raise HTTPException(502, f"could not preview feed: {exc}")
return SourcePreview(**data)
# Static site last, mounted at root, so /api/* and /healthz win.
if STATIC_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="site")
return app
app = create_app()