151 lines
6.7 KiB
Python
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()
|