151 lines
6.7 KiB
Python

"""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", "<h1>Not found</h1>"))
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()