diff --git a/pyproject.toml b/pyproject.toml index 8241621..a133127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ description = "Decentralized links page on top of AT Protocol" readme = "readme.md" requires-python = ">=3.13" dependencies = [ + "aiohttp[speedups]>=3.13.0", "authlib>=1.3", "dnspython>=2.8.0", "flask[async,dotenv]>=3.1.2", "gunicorn>=23.0.0", - "httpx>=0.28.1", ] diff --git a/src/atproto/__init__.py b/src/atproto/__init__.py index 9ad92ad..39f0ccf 100644 --- a/src/atproto/__init__.py +++ b/src/atproto/__init__.py @@ -1,5 +1,5 @@ +import aiohttp import dns.resolver as dns -import httpx from re import match as regex_match from typing import Any @@ -145,11 +145,11 @@ async def resolve_doc_from_did( did: DID, directory: str = PLC_DIRECTORY, ) -> dict[str, Any] | None: - async with httpx.AsyncClient() as client: + async with aiohttp.ClientSession() as client: if did.startswith("did:plc:"): response = await client.get(f"{directory}/{did}") - if response.is_success: - return response.json() + if response.ok: + return await response.json() return None if did.startswith("did:web:"): @@ -173,11 +173,11 @@ async def resolve_authserver_from_pds( assert is_safe_url(pds_url) endpoint = f"{pds_url}/.well-known/oauth-protected-resource" - async with httpx.AsyncClient() as client: + async with aiohttp.ClientSession() as client: response = await client.get(endpoint) - if response.status_code != 200: + if response.status != 200: return None - parsed: dict[str, list[str]] = response.json() + parsed: dict[str, list[str]] = await response.json() authserver_url = parsed["authorization_servers"][0] print(f"caching authserver {authserver_url} for PDS {pds_url}") kv.set(pds_url, value=authserver_url) @@ -188,11 +188,11 @@ async def fetch_authserver_meta(authserver_url: str) -> dict[str, str] | None: """Returns metadata from the authserver""" assert is_safe_url(authserver_url) endpoint = f"{authserver_url}/.well-known/oauth-authorization-server" - async with httpx.AsyncClient() as client: + async with aiohttp.ClientSession() as client: response = await client.get(endpoint) - if not response.is_success: + if not response.ok: return None - meta: dict[str, Any] = response.json() + meta: dict[str, Any] = await response.json() assert is_valid_authserver_meta(meta, authserver_url) return meta @@ -206,13 +206,13 @@ async def get_record( ) -> dict[str, Any] | None: """Retrieve record from PDS. Verifies type is the same as collection name.""" - async with httpx.AsyncClient() as client: + async with aiohttp.ClientSession() as client: response = await client.get( f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" ) - if not response.is_success: + if not response.ok: return None - parsed = response.json() + parsed = await response.json() value: dict[str, Any] = parsed["value"] if value["$type"] != (type or collection): return None diff --git a/src/atproto/oauth.py b/src/atproto/oauth.py index 09d6b0a..88b8e51 100644 --- a/src/atproto/oauth.py +++ b/src/atproto/oauth.py @@ -4,7 +4,7 @@ import json from authlib.jose import JsonWebKey, Key, jwt from authlib.common.security import generate_token from authlib.oauth2.rfc7636 import create_s256_code_challenge -from httpx import Response +from aiohttp import ClientResponse from . import fetch_authserver_meta @@ -34,7 +34,7 @@ async def send_par_auth_request( scope: str, client_secret_jwk: Key, dpop_private_jwk: Key, -) -> tuple[str, str, str, Response]: +) -> tuple[str, str, str, ClientResponse]: par_url = authserver_meta["pushed_authorization_request_endpoint"] state = generate_token() pkce_verifier = generate_token(48) @@ -81,7 +81,8 @@ async def send_par_auth_request( ) # Handle DPoP missing/invalid nonce error by retrying with server-provided nonce - if resp.status_code == 400 and resp.json()["error"] == "use_dpop_nonce": + respjson = await resp.json() + if resp.status == 400 and respjson["error"] == "use_dpop_nonce": dpop_authserver_nonce = resp.headers["DPoP-Nonce"] print(f"retrying with new auth server DPoP nonce: {dpop_authserver_nonce}") dpop_proof = _authserver_dpop_jwt( @@ -149,7 +150,8 @@ async def initial_token_request( resp = await session.post(token_url, data=params, headers={"DPoP": dpop_proof}) # Handle DPoP missing/invalid nonce error by retrying with server-provided nonce - if resp.status_code == 400 and resp.json()["error"] == "use_dpop_nonce": + respjson = await resp.json() + if resp.status == 400 and respjson["error"] == "use_dpop_nonce": dpop_authserver_nonce = resp.headers["DPoP-Nonce"] print(f"retrying with new auth server DPoP nonce: {dpop_authserver_nonce}") # print(server_nonce) @@ -164,7 +166,7 @@ async def initial_token_request( ) resp.raise_for_status() - token_body = resp.json() + token_body = await resp.json() tokens = OAuthTokens(**token_body) return tokens, dpop_authserver_nonce @@ -213,7 +215,8 @@ async def refresh_token_request( resp = await session.post(token_url, data=params, headers={"DPoP": dpop_proof}) # Handle DPoP missing/invalid nonce error by retrying with server-provided nonce - if resp.status_code == 400 and resp.json()["error"] == "use_dpop_nonce": + respjson = await resp.json() + if resp.status == 400 and respjson["error"] == "use_dpop_nonce": dpop_authserver_nonce = resp.headers["DPoP-Nonce"] print(f"retrying with new auth server DPoP nonce: {dpop_authserver_nonce}") # print(server_nonce) @@ -225,11 +228,11 @@ async def refresh_token_request( token_url, data=params, headers={"DPoP": dpop_proof} ) - if resp.status_code not in [200, 201]: + if resp.status not in [200, 201]: print(f"Token Refresh Error: {resp.json()}") resp.raise_for_status() - token_body = resp.json() + token_body = await resp.json() tokens = OAuthTokens(**token_body) return tokens, dpop_authserver_nonce @@ -243,12 +246,12 @@ async def pds_authed_req( user: OAuthSession, update_dpop_pds_nonce: Callable[[str], None], body: dict[str, Any] | None = None, -) -> Response | None: +) -> ClientResponse | None: dpop_private_jwk = JsonWebKey.import_key(json.loads(user.dpop_private_jwk)) dpop_pds_nonce = user.dpop_pds_nonce access_token = user.access_token - response: Response | None = None + response: ClientResponse | None = None # Might need to retry request with a new nonce. for i in range(2): @@ -272,10 +275,8 @@ async def pds_authed_req( # If we got a new server-provided DPoP nonce, store it in database and retry. # NOTE: the type of error might also be communicated in the `WWW-Authenticate` HTTP response header. - if ( - response.status_code in [400, 401] - and response.json()["error"] == "use_dpop_nonce" - ): + respjson = await response.json() + if response.status in [400, 401] and respjson["error"] == "use_dpop_nonce": dpop_pds_nonce = response.headers["DPoP-Nonce"] print(f"retrying with new PDS DPoP nonce: {dpop_pds_nonce}") update_dpop_pds_nonce(dpop_pds_nonce) diff --git a/src/main.py b/src/main.py index f5c3c4f..42c73f5 100644 --- a/src/main.py +++ b/src/main.py @@ -122,7 +122,7 @@ async def page_editor(): handle=handle, profile=profile, profile_from_bluesky=from_bluesky, - links=json.dumps(links or [{"background": "#fa0"}]), + links=json.dumps(links or []), ) @@ -160,21 +160,20 @@ async def post_editor_links(): return redirect("/login", 303) links: list[dict[str, str]] = [] - urls = request.form.getlist("link-url") + hrefs = request.form.getlist("link-href") titles = request.form.getlist("link-title") - details = request.form.getlist("link-detail") - backgrounds = request.form.getlist("link-background") - for url, title, detail, background in zip(urls, titles, details, backgrounds): - if not url or not title or not background: + subtitles = request.form.getlist("link-subtitle") + backgrounds = request.form.getlist("link-background-color") + for href, title, subtitle, background in zip(hrefs, titles, subtitles, backgrounds): + if not href or not title or not background: break link: dict[str, str] = { - "url": url, + "href": href, "title": title, - "color": background, - "background": background, + "backgroundColor": background, } - if detail: - link["detail"] = detail + if subtitle: + link["subtitle"] = subtitle links.append(link) await put_record( @@ -203,20 +202,19 @@ async def load_links( reload: bool = False, ) -> list[dict[str, str]] | None: kv = KV(app, "links_from_did") - links = kv.get(did) + recordstr = kv.get(did) - if links is not None and not reload: + if recordstr is not None and not reload: app.logger.debug(f"returning cached links for {did}") - return json.loads(links) + return json.loads(recordstr)["links"] record = await get_record(pds, did, f"{SCHEMA}.actor.links", "self") if record is None: return None - links = record["links"] app.logger.debug(f"caching links for {did}") - kv.set(did, value=json.dumps(links)) - return links + kv.set(did, value=json.dumps(record)) + return record["links"] async def load_profile( @@ -224,13 +222,13 @@ async def load_profile( did: str, fallback_with_bluesky: bool = True, reload: bool = False, -) -> tuple[tuple[str, str] | None, bool]: +) -> tuple[dict[str, str] | None, bool]: kv = KV(app, "profile_from_did") - profile = kv.get(did) + recordstr = kv.get(did) - if profile is not None and not reload: + if recordstr is not None and not reload: app.logger.debug(f"returning cached profile for {did}") - return json.loads(profile), False + return json.loads(recordstr), False from_bluesky = False record = await get_record(pds, did, f"{SCHEMA}.actor.profile", "self") @@ -240,10 +238,9 @@ async def load_profile( if record is None: return None, False - profile = (record["displayName"], record["description"]) app.logger.debug(f"caching profile for {did}") - kv.set(did, value=json.dumps(profile)) - return profile, from_bluesky + kv.set(did, value=json.dumps(record)) + return record, from_bluesky # TODO: move to .atproto @@ -274,7 +271,7 @@ async def put_record( user=user, update_dpop_pds_nonce=update_dpop_pds_nonce, ) - if not response or not response.is_success: + if not response or not response.ok: app.logger.warning("PDS HTTP ERROR") diff --git a/src/oauth.py b/src/oauth.py index 92bccd6..a37abf4 100644 --- a/src/oauth.py +++ b/src/oauth.py @@ -88,13 +88,14 @@ async def oauth_start(): dpop_private_jwk, ) - if resp.status_code == 400: + if resp.status == 400: current_app.logger.debug("PAR request returned error 400") current_app.logger.debug(resp.text) return redirect(url_for("page_login"), 303) _ = resp.raise_for_status() - par_request_uri: str = resp.json()["request_uri"] + respjson: dict[str, str] = await resp.json() + par_request_uri: str = respjson["request_uri"] current_app.logger.debug(f"saving oauth_auth_request to DB state={state}") oauth_request = OAuthAuthRequest( diff --git a/src/security.py b/src/security.py index 61ea1d3..dd2d3c6 100644 --- a/src/security.py +++ b/src/security.py @@ -1,5 +1,5 @@ from urllib.parse import urlparse -import httpx +import aiohttp # this is a crude/partial filter that looks at HTTPS URLs and checks if they seem "safe" for server-side requests (SSRF). This is only a partial mitigation, the actual HTTP client also needs to prevent other attacks and behaviors. @@ -30,10 +30,9 @@ def is_safe_url(url: str) -> bool: class HardenedHttp: - def get_session(self) -> httpx.AsyncClient: - return httpx.AsyncClient( - timeout=httpx.Timeout(20, connect=5), - follow_redirects=False, + def get_session(self) -> aiohttp.ClientSession: + return aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(20, connect=5), headers={ "User-Agent": "ligo.at/0", }, diff --git a/src/templates/editor.html b/src/templates/editor.html index a3a07bf..844b3b9 100644 --- a/src/templates/editor.html +++ b/src/templates/editor.html @@ -30,11 +30,11 @@
{% if profile_from_bluesky %}

@@ -63,24 +63,24 @@

- + + +
diff --git a/src/templates/profile.html b/src/templates/profile.html index b323795..b3c0c9b 100644 --- a/src/templates/profile.html +++ b/src/templates/profile.html @@ -2,7 +2,7 @@ - Links for {{ profile.0 }} + Links for {{ profile.displayName }} @@ -15,19 +15,19 @@
-

{{ profile.0 }}

- {% if profile.1 %} - {{ profile.1 }} +

{{ profile.displayName }}

+ {% if profile.description %} + {{ profile.description }} {% endif %}