"""FeedEcho — self-hosted RSS feed cross-poster. Routes feed items to Mastodon accounts or email addresses. Web UI for managing feeds, accounts, echoes, settings, and viewing post history. Route convention: a handler that performs blocking I/O (outbound HTTP via the sync httpx client, SMTP, DNS) is declared ``def``, not ``async def``, so FastAPI runs it in the threadpool. An ``async def`` handler runs on the single event loop, where one 30-second feed fetch stalls every other request in the process. ``tests/test_review_fixes.py`` enforces this. """ import csv import io import json import os import re import logging import threading import time import uuid import secrets import secrets as _secrets from pathlib import Path from datetime import datetime, timedelta, timezone from urllib.parse import urlsplit, urlparse, urlencode import httpx from fastapi import FastAPI, Request, Form, HTTPException, Query, UploadFile, File, BackgroundTasks from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response import xml.etree.ElementTree as ElementTree from xml.sax.saxutils import quoteattr from starlette.middleware.base import BaseHTTPMiddleware from starlette.routing import Match from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from database import as_utc_naive, dialect, get_db, init_db, timestamp_str, _column_names from _version import __version__ as APP_VERSION import auth from auth import current_user_id import security from feed_parser import discover_feed_url, fetch_feed, SSRFError, validate_outbound_url import plans import invites import import_export from plans import PlanError from mastodon import test_connection, post_status, verify_credentials from bluesky import ( BlueskyAuthError, BlueskyError, create_session as bluesky_create_session, normalize_handle as bluesky_normalize_handle, resolve_pds as bluesky_resolve_pds, session_expiry as bluesky_session_expiry, test_connection as test_bluesky_connection, ) from template_engine import render_template, available_variables, validate_template from microblog import ( MicroblogAuthError, MicroblogError, list_destinations as microblog_list_destinations, test_connection as test_microblog_connection, ) from matrix import ( MatrixAuthError, MatrixError, MatrixPermissionError, connect as matrix_connect, normalize_homeserver as matrix_normalize_homeserver, normalize_room as matrix_normalize_room, test_connection as test_matrix_connection, ) from discord import ( DiscordAuthError, DiscordError, DiscordNotFoundError, connect as discord_connect, normalize_discord_webhook_url as discord_normalize_webhook_url, test_connection as test_discord_connection, ) import telegram from telegram import ( TelegramAuthError, TelegramBadRequestError, TelegramError, TelegramNotFoundError, connect as telegram_connect, test_connection as test_telegram_connection, ) from webhook import ( CLEAR_BODY_SENTINEL as webhook_clear_body_sentinel, MAX_HEADERS_TEXT, WebhookError, dump_headers, is_clear_body as webhook_is_clear_body, load_headers, normalize_body_template as webhook_normalize_body_template, normalize_webhook_url as webhook_normalize_url, parse_headers as webhook_parse_headers, test_connection as test_webhook_connection, ) import github import scheduler # noqa: E402 — module-qualified access (tests monkeypatch through it) from scheduler import ( start_scheduler, stop_scheduler, check_feed, process_echo, next_free_slot, ) from oauth import get_authorize_url, exchange_code, verify_state from email_sender import get_smtp_settings, test_smtp_connection from utils import is_valid_email from utils import categories_from_value import logging_setup logging_setup.setup_logging() logger = logging.getLogger("feedecho") access_logger = logging.getLogger("feedecho.access") app = FastAPI(title="FeedEcho", version=APP_VERSION) BASE_DIR = Path(__file__).resolve().parent app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") jinja = Environment( loader=FileSystemLoader(BASE_DIR / "templates"), autoescape=select_autoescape(["html"]), ) def _as_utc_naive(value) -> datetime | None: """Normalize a stored timestamp (sqlite TEXT or psycopg datetime) to a naive UTC datetime, or None if unparseable/empty. Thin alias for ``database.as_utc_naive`` so the dialect rule lives in one place; see that docstring for the UTC-writer invariant. """ return as_utc_naive(value) def _iso_utc(value) -> str: """ISO-8601 UTC ('2026-08-24T06:46:00Z') for the browser-side local-time conversion; '' when the value is missing/unparseable.""" dt = _as_utc_naive(value) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") if dt else "" def _utc_text(value) -> str: """Plain 'YYYY-MM-DD HH:MM:SS' UTC fallback text (shown when JS is off).""" dt = _as_utc_naive(value) return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" def _safe_url(value) -> str: """A URL safe to place in href, or "" for anything not http(s). Item and post URLs come from feed publishers and remote APIs. Autoescaping stops HTML injection but does nothing about the scheme, so a feed serving `javascript:...` as an item link would run script in the authenticated origin on click. Rendering an empty href instead drops the link. """ url = str(value or "").strip() if url.lower().startswith(("http://", "https://")): return url if url: # debug, not warning: a feed full of odd schemes would otherwise log a # line per URL on every page render. The dropped link is visible in the # page itself. logger.debug("Refusing to render non-http(s) URL in a link: %.80r", url) return "" jinja.filters["iso_utc"] = _iso_utc jinja.filters["utc_text"] = _utc_text jinja.filters["safe_url"] = _safe_url # Admin template uses `value is trial_pending` to render the card-gated trial # sentinel as "pending card" rather than a nonsense 1999 date. jinja.tests["trial_pending"] = lambda value: plans.trial_pending(value) # Every table that holds one connected destination (one stored credential and # one outbound posting path). Plan caps and usage counts sum across ALL of # them, so a new destination type must be added here or it silently escapes # the quota. Keyed by the echoes.destination_type value it belongs to. DESTINATION_TABLES_BY_TYPE = { "mastodon": "accounts", "email": "email_accounts", "bluesky": "bluesky_accounts", "microblog": "microblog_accounts", "matrix": "matrix_accounts", "discord": "discord_accounts", "telegram": "telegram_accounts", "webhook": "webhook_accounts", "github": "github_accounts", } DESTINATION_TABLES = tuple(DESTINATION_TABLES_BY_TYPE.values()) DESTINATION_LIMITS = { "mastodon": 500, "bluesky": 300, "discord": 2000, "telegram": 4096, "matrix": None, "email": None, "microblog": None, "webhook": None, "github": None, } OAUTH_SESSION_COOKIE = "feedecho_oauth_session" OAUTH_SESSION_MAX_AGE = 10 * 60 # ── Optional shared-secret auth ────────────────────────────────────────────── # If FEEDECHO_AUTH_TOKEN is set, all requests must include it as either: # - Cookie: feedecho_auth= (set by the login page) # - X-Auth-Token: (for API/programmatic access) # If the env var is unset, auth is disabled (original behavior). # (Single-tenant mode only; multi mode replaces this with sessions.) import settings from settings import AUTH_TOKEN # noqa: F401 (re-exported for tests/legacy) # Paths exempt from auth: health check + static files + OAuth callback. # Only /oauth/callback must be reachable without a cookie (Mastodon redirects # the user's browser here). /oauth/connect requires auth so unauthenticated # users cannot trigger outbound requests to arbitrary instance URLs. _AUTH_EXEMPT_PATHS = {"/healthz", "/favicon.svg", "/static", "/oauth/callback"} _AUTH_EXEMPT_PREFIXES = ("/static", "/poke/") # S10f: index the route table once, splitting literal paths (dict lookup of # path -> methods) from parameterized ones (need the full matches() scan). # Rebuilt only when the route list length changes (billing.mount appends at # startup, before serving). Method-aware so a wrong-method request to a known # path still falls through to the router's 405, matching the pre-cache behavior. _route_index_cache: dict = {} def _route_index(app): routes = app.routes key = id(app) cached = _route_index_cache.get(key) if cached is None or cached[0] != len(routes): literal: dict = {} parameterized: list = [] for route in routes: path = getattr(route, "path", None) if not path or getattr(route, "matches", None) is None: continue methods = getattr(route, "methods", None) # Routes with no methods set (Mount for /static, WebSocket routes) # must go through the matches() scan, not the literal fast-path: # a prefix-matched Mount path would otherwise be dropped entirely # and change unauthenticated behaviour from redirect to 404. if not methods or "{" in path: parameterized.append(route) continue methods = set(methods) # Starlette treats a HEAD request as a match for a GET route. if "GET" in methods: methods.add("HEAD") literal.setdefault(path, set()).update(methods) cached = (len(routes), literal, parameterized) _route_index_cache[key] = cached return cached[1], cached[2] class RequestIdMiddleware(BaseHTTPMiddleware): """Request-id threading + structured access log. Outermost middleware: accepts a client X-Request-ID (validated against a conservative charset + length cap; anything else is replaced with a generated uuid) and attaches it to every log record emitted while the request is handled, to the response header, and to one access-log line per request. The access line's ``ip=`` field is the DERIVED client IP (the rightmost X-Forwarded-For entry when the TCP peer is a trusted proxy), not the raw TCP peer: behind Caddy the peer is always the proxy container, which tells you nothing about who is hitting the box. See auth._client_ip. """ _VALID_ID = re.compile(r"^[A-Za-z0-9._-]{1,64}$") async def dispatch(self, request: Request, call_next): raw = request.headers.get("x-request-id", "") request_id = raw if self._VALID_ID.fullmatch(raw) else uuid.uuid4().hex token = logging_setup.set_request_id(request_id) start = time.perf_counter() try: response = await call_next(request) except Exception: # Log a 500 access line while the id is still attached, then # reset scope-safely and re-raise (ServerErrorMiddleware logs # the traceback, and its record will carry the id). duration_ms = round((time.perf_counter() - start) * 1000, 1) client_ip = auth._client_ip(request) uid = getattr(request.state, "user_id", None) access_logger.error( "%s %s 500 %sms ip=%s%s (unhandled exception)", request.method, request.url.path, duration_ms, client_ip, f" user={uid}" if uid is not None else "", ) logging_setup.reset_request_id(token) raise # Access log emitted while the request id is still attached to the # context, so its own record carries the id too. response.headers["X-Request-ID"] = request_id duration_ms = round((time.perf_counter() - start) * 1000, 1) client_ip = auth._client_ip(request) uid = getattr(request.state, "user_id", None) path = request.url.path log = ( access_logger.debug if path == "/healthz" else access_logger.info ) log( "%s %s %s %sms ip=%s%s", request.method, path, response.status_code, duration_ms, client_ip, f" user={uid}" if uid is not None else "", ) logging_setup.reset_request_id(token) return response class AuthMiddleware(BaseHTTPMiddleware): """Shared-secret auth (single mode) or session auth (multi mode). Single mode: FEEDECHO_AUTH_TOKEN unset → no-op; set → cookie/header token required. Multi mode: feedecho_session cookie required on all paths except the public set. Mode is read per request so tests can flip settings.MULTI without reloading the app. Beyond allow/deny it records ``request.state.authed`` (and, in multi mode, ``request.state.user_id``) so ``render()`` can give a viewer the chrome that matches what they can actually reach. That identification is best-effort on the PUBLIC pages too: without it a signed-in user lands on /about with an anonymous nav and no way back into the app. """ _MULTI_EXEMPT_PATHS = { "/healthz", "/favicon.svg", "/oauth/callback", "/register", "/login", "/logout", # Email links are opened unauthenticated. "/verify-email", "/forgot-password", "/reset-password", # Public hosted-service disclosure page. "/about", # Pricing is a public decision page like the legal pages. "/pricing", # Legal pages are for people deciding whether to sign up. "/terms", "/privacy", # Hosted landing page: public for visitors, dashboard for authed # (the route branches on authed). "/", } _MULTI_EXEMPT_PREFIXES = ("/static", "/poke/") # Paths a card-pending trial user (registered but checkout unfinished) may # still reach so they can finish checkout, log out, or delete the abandoned # account. Everything else bounces to /settings#billing until the card is # collected (the webhook stamps the real trial_ends_at on the Stripe # subscription.created event, which clears the sentinel). _PENDING_CARD_ALLOWED = { "/settings", "/api/billing/checkout", "/api/billing/portal", "/settings/delete-account", "/logout", } # Exempt paths that still render the app's nav/footer, so the viewer is # worth identifying. Deliberately NOT the whole exempt set: # /oauth/callback authorizes on the signed OAuth state and its handler # documents that request.state.user_id is unset there; /logout must not # care who you are; /static, /healthz and /favicon.svg must not pay for a # database read. /forgot-password and /reset-password are for people who # cannot get in, where anonymous chrome is the honest answer. _MULTI_PUBLIC_PAGES = { "/login", "/register", "/about", "/verify-email", "/terms", "/privacy", "/pricing", "/" } async def dispatch(self, request: Request, call_next): if settings.MULTI: return await self._multi(request, call_next) return await self._single(request, call_next) @staticmethod def _path_is_routed(request: Request) -> bool: """Whether any registered route matches this request's path/method. Unknown paths must reach the router so the 404 handler renders the real not-found page. Without this, an unauthenticated browser request to a nonexistent URL was silently redirected to /login, so a visitor following a bad link could not tell "page doesn't exist" from "session expired" (impeccable critique 2026-08-29, P1a). S10f: literal (no-path-param) routes are checked against a cached path->methods dict first — that is the hot path for most requests. The `matches()` scan only runs for parameterized routes, which is a handful. """ literal, parameterized = _route_index(request.app) methods = literal.get(request.url.path) if methods and request.method in methods: return True scope = request.scope for route in parameterized: matches = getattr(route, "matches", None) if matches is None: continue try: # starlette Route.matches returns a (Match, path_params) tuple. if matches(scope)[0] == Match.FULL: return True except Exception: # defensive: never break auth over a route table quirk continue return False @staticmethod def _token_matches(request: Request) -> bool: """Whether this request carries the shared secret. Pure comparison, no database, so it is cheap enough to run on every request including the exempt ones — and single mode has no user attribution, so knowing "this is the operator" everywhere cannot change any authorization decision. """ token = ( request.cookies.get(auth.AUTH_COOKIE_NAME) or request.headers.get("x-auth-token") ) if not token or not settings.AUTH_TOKEN: return False return ( # Compare bytes: compare_digest raises TypeError on str arguments # containing non-ASCII, which would surface as a 500 from a cookie # an attacker (or a confused browser) fully controls. _secrets.compare_digest( token.encode("utf-8", "surrogatepass"), settings.AUTH_TOKEN.encode("utf-8", "surrogatepass"), ) ) async def _single(self, request: Request, call_next): path = request.url.path # No shared secret configured: every viewer is the operator — but # only when the operator explicitly opted out via # FEEDECHO_ALLOW_INSECURE=1. validate_config enforces that pairing at # startup; this branch is the defense-in-depth backstop for an ASGI # server started with lifespan events disabled (--lifespan off), # which would skip validate_config entirely (gate SEC-01). Fail # closed rather than fall back to open access. if not settings.AUTH_TOKEN and not settings.ALLOW_INSECURE: return Response( "Authentication misconfigured: FEEDECHO_AUTH_TOKEN is not " "set. Set it, or set FEEDECHO_ALLOW_INSECURE=1 to run " "without authentication.", status_code=500, ) if not settings.AUTH_TOKEN or self._token_matches(request): request.state.authed = True if path == "/login" and request.method == "GET": # Nothing to log into: either no token is configured or this # viewer already presented it. A login form here is a dead end. return RedirectResponse(url="/", status_code=302) return await call_next(request) # Health check, static files, the OAuth callback and the login form # itself stay reachable without the token. if ( path in _AUTH_EXEMPT_PATHS or path.startswith(tuple(_AUTH_EXEMPT_PREFIXES)) or path == "/login" ): return await call_next(request) # Unknown paths get the real 404 page instead of a login redirect. if not self._path_is_routed(request): return await call_next(request) # Redirect browser requests to login, 401 for API/JSON accept = request.headers.get("accept", "") if "text/html" in accept and request.method == "GET": return RedirectResponse(url="/login", status_code=302) return JSONResponse( {"detail": "Authentication required"}, status_code=401 ) @staticmethod def _session_user(request: Request) -> int | None: """The user behind a valid session cookie, or None. Suspension and session-epoch are enforced per request, not just at login: a valid HMAC session for a suspended account, or one issued before the last password reset, is rejected. """ token = request.cookies.get("feedecho_session") claims = security.read_session(token) if token else None if not claims: return None with get_db() as db: row = db.execute( "SELECT suspended, session_epoch FROM users WHERE id = ?", (claims["user_id"],), ).fetchone() if ( row and not row["suspended"] and row["session_epoch"] == claims.get("epoch", 0) ): return claims["user_id"] return None @staticmethod def _pending_card(uid: int) -> bool: """True when a billing account has registered but not collected a card. The register flow writes the TRIAL_PENDING sentinel as trial_ends_at; the Stripe subscription webhook replaces it with a real date once the card is collected. Until then the account must be gated off the app so a bot that abandons checkout cannot use it (posting is paused, but it should not be able to poke at feeds/echoes/accounts either). """ if not settings.BILLING_ENABLED: return False with get_db() as db: row = db.execute( "SELECT plan, trial_ends_at FROM users WHERE id = ?", (uid,) ).fetchone() return bool( row and row["plan"] == "trial" and plans.trial_pending(row["trial_ends_at"]) ) async def _multi(self, request: Request, call_next): path = request.url.path if path in self._MULTI_EXEMPT_PATHS or path.startswith( tuple(self._MULTI_EXEMPT_PREFIXES) ): if path in self._MULTI_PUBLIC_PAGES: uid = self._session_user(request) if uid is not None: request.state.user_id = uid request.state.authed = True if path == "/" and self._pending_card(uid): # The dashboard is the landing page only for anonymous # viewers; a card-pending user must finish checkout # before seeing the app. return RedirectResponse(url="/settings#billing", status_code=302) if path in ("/login", "/register") and request.method == "GET": return RedirectResponse(url="/", status_code=302) return await call_next(request) uid = self._session_user(request) if uid is not None: request.state.user_id = uid request.state.authed = True if self._pending_card(uid) and path not in self._PENDING_CARD_ALLOWED: return RedirectResponse(url="/settings#billing", status_code=302) return await call_next(request) # Unknown paths get the real 404 page instead of a login redirect. if not self._path_is_routed(request): return await call_next(request) accept = request.headers.get("accept", "") if "text/html" in accept and request.method == "GET": return RedirectResponse(url="/login", status_code=302) return JSONResponse( {"detail": "Authentication required"}, status_code=401 ) @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): return auth.login_page(request) @app.post("/login") def login_submit( request: Request, email: str = Form(""), password: str = Form(""), token: str = Form(""), ): return auth.login_submit(request, email=email, password=password, token=token) @app.get("/register", response_class=HTMLResponse) async def register_page(request: Request): return auth.register_page(request) @app.post("/register") def register_submit( request: Request, email: str = Form(...), password: str = Form(...), confirm: str = Form(...), invite_code: str = Form(""), ): return auth.register_submit( request, email=email, password=password, confirm=confirm, invite_code=invite_code, ) @app.post("/logout") def logout(request: Request): return auth.logout(request) @app.get("/forgot-password", response_class=HTMLResponse) async def forgot_page(request: Request): return auth.forgot_page(request) @app.post("/forgot-password") async def forgot_submit(request: Request, email: str = Form("")): return auth.forgot_submit(request, email=email) @app.get("/reset-password", response_class=HTMLResponse) async def reset_page(request: Request, token: str = ""): return auth.reset_page(request, token=token) @app.post("/reset-password") def reset_submit( request: Request, token: str = Form(""), password: str = Form(""), confirm: str = Form(""), ): return auth.reset_submit( request, token=token, password=password, confirm=confirm ) # ── S8: Security headers middleware ────────────────────────────────────────── # # Progressive CSP: frame-ancestors blocks clickjacking (the S9 half); # script-src 'unsafe-inline' is a concession to the 42 inline onclick handlers # and 3 inline