Promote-candidate UI: add-a-source pipeline in the admin console
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.
* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
http(s) only, every redirect hop re-validated via enrich._host_is_public,
body size-capped, bounded redirects, no cookies. preview_feed gains a
`fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
before the outbound fetch, no DB conn held during network); /{id}/preview
(explicit re-preview); /{id}/promote (paused by default, returns the new
source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
preview (pass rate, recent count, example headlines) with Promote (as paused,
or Activate immediately) / Re-preview / Reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+93
-1
@@ -31,7 +31,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share, summarize
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share, sources, summarize
|
||||
from .markup import reply_html_to_text, sanitize_reply_html
|
||||
from .db import connect
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
@@ -394,6 +394,19 @@ 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 SourceReviewBody(BaseModel):
|
||||
flag: bool = False
|
||||
reason: str | None = None
|
||||
@@ -918,6 +931,85 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True, "visible": body.visible}
|
||||
|
||||
# --- 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) -> dict:
|
||||
# SSRF-safe fetch (admin-pasted URL is untrusted); heuristic-only (fast).
|
||||
try:
|
||||
return feeds.preview_feed(url, sample=20, fetcher=feeds.safe_fetch_feed)
|
||||
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)
|
||||
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) -> 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)
|
||||
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}/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 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)
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/review")
|
||||
def admin_source_review(sid: int, body: SourceReviewBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
|
||||
Reference in New Issue
Block a user