ddcfab3a11
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>
1870 lines
79 KiB
Python
1870 lines
79 KiB
Python
"""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 = (
|
||
"You’re 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()
|