import json # ============================================================================= # App setup # ============================================================================= import asyncio import os import secrets import sqlite3 import time from datetime import datetime from typing import Any from urllib.parse import urlencode, urlparse import httpx import regex import requests from atproto import Client, models from atproto_client.exceptions import BadRequestError, NetworkError from atproto_identity.resolver import IdResolver from authlib.jose import JsonWebKey from markupsafe import Markup from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import TextLexer, guess_lexer from quart import ( Quart, Response, abort, flash, g, jsonify, redirect, render_template, request, session, url_for, ) from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Response as WerkzeugResponse from atproto_oauth import ( fetch_authserver_meta, initial_token_request, is_safe_url, pds_authed_req, refresh_token_request, resolve_pds_authserver, revoke_token_request, send_par_auth_request, ) from config import load_config from identity import ( fetch_profile, fetch_recent_bites, fetch_replies, hydrate_replies, resolve_did, resolve_identity, ) app = Quart(__name__) DATA_DIR = os.environ.get("MORSEL_DATA_DIR", ".") load_config(app, data_dir=DATA_DIR) CLIENT_SECRET_JWK = JsonWebKey.import_key(json.loads(app.config["CLIENT_SECRET_JWK"])) CLIENT_PUB_JWK = json.loads(CLIENT_SECRET_JWK.as_json(is_private=False)) assert "d" not in CLIENT_PUB_JWK OAUTH_SCOPE = "atproto repo:blue.morsels.bite repo:blue.morsels.reply" COLLECTION = "blue.morsels.bite" SLINGSHOT_URL = "https://slingshot.microcosm.blue" _avatar_cache: dict[str, tuple[bytes, str, float]] = {} AVATAR_TTL = 3600 _bite_cache: dict[str, tuple[Any, float]] = {} BITE_TTL = 3600 # ============================================================================= # Database # ============================================================================= def get_db() -> sqlite3.Connection: if "db" not in g: db_path = app.config.get("DATABASE_URL", os.path.join(DATA_DIR, "morsel.db")) g.db = sqlite3.connect(db_path) g.db.execute("PRAGMA journal_mode=WAL") g.db.row_factory = sqlite3.Row return g.db def query_db( query: str, args: tuple | list = (), one: bool = False ) -> sqlite3.Row | list[sqlite3.Row] | None: db = get_db() cur = db.cursor() cur.execute(query, args) rv = cur.fetchall() db.commit() cur.close() return (rv[0] if rv else None) if one else rv @app.before_serving async def init_db() -> None: async with app.app_context(): db = get_db() schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") with open(schema_path, "r") as f: db.cursor().executescript(f.read()) db.commit() # ============================================================================= # Helpers # ============================================================================= @app.route("/avatar/") async def avatar_proxy(did: str) -> WerkzeugResponse: now = time.time() if did in _avatar_cache: data, content_type, ts = _avatar_cache[did] if now - ts < AVATAR_TTL: return Response(data, mimetype=content_type) # Resolve PDS to find avatar handle, pds_url = await resolve_identity(did) if pds_url is None: return Response(status=404) profile = await fetch_profile(did, pds_url) cdn_url = profile.get("avatar_url") blob_url = profile.get("avatar_blob_url") if not cdn_url and not blob_url: return Response(status=404) resp = None async with httpx.AsyncClient() as http: for url in [cdn_url, blob_url]: if not url: continue try: resp = await http.get(url, timeout=5) if resp.status_code == 200: break except (httpx.HTTPError, ValueError): continue if resp is None or resp.status_code != 200: return Response(status=502) content_type = resp.headers.get("Content-Type", "image/jpeg") _avatar_cache[did] = (resp.content, content_type, now) return Response(resp.content, mimetype=content_type) def compute_client_id(url_root: str) -> tuple[str, str]: parsed = urlparse(url_root) if parsed.hostname in ["localhost", "127.0.0.1"]: redirect_uri = f"http://127.0.0.1:{parsed.port}/oauth/callback" client_id = "http://localhost?" + urlencode( {"redirect_uri": redirect_uri, "scope": OAUTH_SCOPE} ) else: app_url = url_root.replace("http://", "https://") redirect_uri = f"{app_url}oauth/callback" client_id = f"{app_url}oauth-client-metadata.json" return client_id, redirect_uri def highlight_code(content: str | None) -> str: lexer = guess_lexer(content) if content else TextLexer() formatter = HtmlFormatter(nowrap=True) return highlight(content or "", lexer, formatter).rstrip("\n") async def require_identity( identifier: str, redirect_endpoint: str, **redirect_kwargs: Any ) -> tuple[str, str | None, str, dict] | WerkzeugResponse: """Resolve an identifier to a DID, redirecting handles to canonical DID URLs. Returns (did, handle, pds_url, profile) or redirects/aborts. Callers must check the return — if it's a Response (redirect), return it directly. """ did = await resolve_did(identifier) if did is None: abort(404, "User not found") if did != identifier: return redirect(url_for(redirect_endpoint, identifier=did, **redirect_kwargs)) handle, pds_url = await resolve_identity(did) if handle is None and pds_url is None: abort(404, "User not found.") if pds_url is None: abort(502, "Could not reach this user's server.") profile = await fetch_profile(did, pds_url) return did, handle, pds_url, profile def fetch_bites(pds_url: str, did: str, limit: int = 100) -> list[dict[str, str]]: """Fetch bite records from a user's PDS. Returns a list of dicts.""" try: client = Client(pds_url) response = client.com.atproto.repo.list_records( models.ComAtprotoRepoListRecords.Params( repo=did, collection=COLLECTION, limit=limit, ) ) except BadRequestError: abort(404, "User not found.") except NetworkError: abort(502, "Could not reach this user's server.") except Exception: abort(500, "Something went wrong loading bites.") bites = [] for record in response.records: try: bites.append( { "rkey": record.uri.split("/")[-1], "title": record.value["title"], "content": record.value["content"], "created_at": record.value["createdAt"], } ) except KeyError, TypeError: continue return bites async def authed_pds_request( method: str, path: str, body: dict | None = None ) -> WerkzeugResponse | requests.Response | None: """Make an authenticated request to the logged-in user's PDS, refreshing tokens if needed. Returns the response, or redirects to login on auth failure. """ pds_url = g.user["pds_url"] did = g.user["did"] url = f"{pds_url}/xrpc/{path}" try: resp = pds_authed_req(method, url, user=g.user, db=get_db(), body=body) except Exception: await flash("Request timed out. Try again.", "error") return redirect(request.referrer or url_for("index")) if resp.status_code == 401: # type: ignore[union-attr] client_id, _ = compute_client_id(request.url_root) try: tokens, dpop_nonce = await asyncio.to_thread( refresh_token_request, g.user, client_id, CLIENT_SECRET_JWK, ) query_db( "UPDATE oauth_session SET access_token = ?, refresh_token = ?, dpop_authserver_nonce = ? WHERE did = ?;", [tokens["access_token"], tokens["refresh_token"], dpop_nonce, did], ) g.user = query_db( "SELECT * FROM oauth_session WHERE did = ?", [did], one=True, ) resp = pds_authed_req(method, url, user=g.user, db=get_db(), body=body) except Exception: await flash("Session expired, please log in again", "error") return redirect(url_for("oauth_login")) return resp async def delete_record( collection: str, record_rkey: str ) -> WerkzeugResponse | requests.Response | None: """Delete a record from the logged-in user's repo.""" body = { "repo": g.user["did"], "collection": collection, "rkey": record_rkey, } return await authed_pds_request("POST", "com.atproto.repo.deleteRecord", body=body) async def check_csrf() -> None: token = session.get("csrf_token") submitted = (await request.form).get("csrf_token") if not token or token != submitted: abort(400, "Invalid or missing security token. Please try again.") # ============================================================================= # Hooks and filters # ============================================================================= @app.teardown_appcontext async def close_db(exception: BaseException | None) -> None: db = g.pop("db", None) if db is not None: db.close() @app.before_request async def ensure_csrf_token() -> None: if "csrf_token" not in session: session["csrf_token"] = secrets.token_hex(32) @app.before_request async def load_logged_in_user() -> None: user_did = session.get("user_did") if user_did is None: g.user = None else: g.user = query_db( "SELECT * FROM oauth_session WHERE did = ?", [user_did], one=True ) @app.template_filter("humandate") def humandate_filter(value: str) -> str: """Return a