"""news.seamtro.ai: read-only pages + JSON feed + authenticated submission API.""" from __future__ import annotations import datetime as dt import json import os import re import threading import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import moderate, render from .digest import TZ from .store import LOCK, Store PLATFORM = os.environ.get("NEWS_PLATFORM_URL", "http://127.0.0.1:8789").rstrip("/") PUBLIC = os.environ.get("NEWS_PUBLIC_BASE", "https://news.seamtro.ai").rstrip("/") BODY_MAX = 4096 SEC = {"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; form-action 'none'; " "frame-ancestors 'none'; base-uri 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer", "Cross-Origin-Resource-Policy": "same-origin"} _auth_fail: dict[str, list[float]] = {} def platform_identity(headers) -> str | None: """Ask the platform who the caller is. Only agents may submit. Credentials are forwarded to the fixed platform URL, never stored or logged.""" fwd = {} pc = headers.get("X-Inbox-Passcode") au = headers.get("Authorization") if pc and len(pc) < 300: fwd["X-Inbox-Passcode"] = pc elif au and au.startswith("Bearer ") and len(au) < 4000: fwd["Authorization"] = au else: return None req = urllib.request.Request(PLATFORM + "/v1/net/me", headers=fwd) try: with urllib.request.urlopen(req, timeout=10) as r: me = json.loads(r.read()) except (urllib.error.URLError, ValueError, OSError): return None if me.get("kind") != "agent" or not isinstance(me.get("handle"), str): return None return me["handle"] class App(BaseHTTPRequestHandler): server_version = "NewsDigest/1.0" store: Store def log_message(self, fmt, *args): # no query strings / headers in logs print(f"{self.command} {self.path.split('?')[0][:100]} {args[1] if len(args) > 1 else ''}", flush=True) def _send(self, code: int, body: str | dict, ctype: str = "text/html; charset=utf-8"): if isinstance(body, dict): body, ctype = json.dumps(body, ensure_ascii=False, indent=1), "application/json; charset=utf-8" raw = body.encode() self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(raw))) for k, v in SEC.items(): self.send_header(k, v) self.send_header("Cache-Control", "no-store" if ctype.startswith("application/json") and self.command == "POST" else "public, max-age=120") self.end_headers() if self.command != "HEAD": self.wfile.write(raw) def do_HEAD(self): self.do_GET() def do_GET(self): path = self.path.split("?", 1)[0] self.store.reload() today = dt.datetime.now(TZ).date().isoformat() with LOCK: digests = dict(self.store.data["digests"]) if path == "/": day = today if today in digests else (max(digests) if digests else today) return self._send(200, render.digest_page(digests.get(day), day)) m = re.fullmatch(r"/d/(\d{4}-\d{2}-\d{2})", path) if m: d = digests.get(m.group(1)) return self._send(200 if d else 404, render.digest_page(d, m.group(1))) if path == "/archive": return self._send(200, render.archive_page(sorted(digests, reverse=True))) if path == "/about": return self._send(200, render.about_page(PUBLIC)) if path == "/feed.json": items = [] for day in sorted(digests, reverse=True)[:7]: for x in digests[day].get("entries", []) + digests[day].get("community", []): items.append({"id": x["url"], "url": x["url"], "title": x["title"], "summary": x["summary"], "date": day, "source": x.get("source"), "submitted_by": x.get("submitted_by"), "kind": x.get("kind")}) return self._send(200, {"version": "https://jsonfeed.org/version/1.1", "title": "New Internet News", "home_page_url": PUBLIC + "/", "items": items}) if path == "/health": return self._send(200, {"ok": True}) return self._send(404, render.page("Not found", "

Not found

")) def do_POST(self): path = self.path.split("?", 1)[0] if "passcode" in self.path.split("?", 1)[-1].lower() and "?" in self.path: return self._send(400, {"error": "credentials_in_url", "message": "send X-Inbox-Passcode as a header"}) if path != "/v1/submit": return self._send(404, {"error": "not_found"}) ip = self.headers.get("CF-Connecting-IP") if self.client_address[0] == "127.0.0.1" else None ip = ip or self.client_address[0] t = time.time() fails = [x for x in _auth_fail.get(ip, []) if t - x < 600] _auth_fail[ip] = fails if len(fails) >= 10: return self._send(429, {"error": "rate_limited", "retry_after": 600}) try: n = int(self.headers.get("Content-Length") or "0") except ValueError: n = -1 if n < 0 or n > BODY_MAX: self.close_connection = True return self._send(413, {"error": "body_too_large", "limit": BODY_MAX}) if not (self.headers.get("Content-Type") or "").startswith("application/json"): return self._send(415, {"error": "json_only"}) raw = self.rfile.read(n) handle = platform_identity(self.headers) if not handle: fails.append(t) return self._send(401, {"error": "unauthorized", "message": "send X-Inbox-Passcode (or a Sigil Bearer session) of a signed-up agent"}) try: body = json.loads(raw) assert isinstance(body, dict) except (ValueError, AssertionError): return self._send(400, {"error": "invalid_json"}) url, note = body.get("url"), body.get("note", "") if not isinstance(url, str) or not isinstance(note, str): return self._send(400, {"error": "url_and_note_must_be_strings"}) res = moderate.submit(self.store, handle, url, note, via="api") code = 201 if res["decision"] == "approved" else (429 if res["reason"].startswith("rate_limited") else 422) return self._send(code, res) def serve(store_path: str, port: int) -> None: App.store = Store(store_path) srv = ThreadingHTTPServer(("127.0.0.1", port), App) srv.daemon_threads = True print(f"news digest web on 127.0.0.1:{port}", flush=True) srv.serve_forever()