decentralized and customizable links page on top of atproto ligo.at
atproto link-in-bio python uv
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

add basic key-value storage

+136 -68
+1 -1
Makefile
··· 7 7 8 8 .PHONY: run 9 9 run: 10 - uv run -- gunicorn --bind ':$(PORT)' 'src.main:app' 10 + uv run -- dotenv run -- gunicorn -w 4 --bind ':$(PORT)' 'src.main:app'
+46 -35
src/atproto/__init__.py
··· 3 3 from typing import Any 4 4 import httpx 5 5 6 + from ..db import KV 7 + 6 8 from .validator import is_valid_authserver_meta 7 9 from ..security import is_safe_url 8 10 ··· 14 16 AuthserverUrl = str 15 17 PdsUrl = str 16 18 DID = str 17 - 18 - authservers: dict[PdsUrl, AuthserverUrl] = {} 19 - dids: dict[str, DID] = {} 20 - pdss: dict[DID, PdsUrl] = {} 21 19 22 20 23 21 def is_valid_handle(handle: str) -> bool: ··· 28 26 return regex_match(DID_REGEX, did) is not None 29 27 30 28 31 - def resolve_identity(query: str) -> tuple[str, str, dict[str, Any]] | None: 29 + def resolve_identity(query: str, didkv: KV) -> tuple[str, str, dict[str, Any]] | None: 32 30 """Resolves an identity to a DID, handle and DID document, verifies handles bi directionally.""" 33 31 34 32 if is_valid_handle(query): 35 33 handle = query 36 - did = resolve_did_from_handle(handle) 34 + did = resolve_did_from_handle(handle, didkv) 37 35 if not did: 38 36 return None 39 37 doc = resolve_doc_from_did(did) ··· 52 50 handle = handle_from_doc(doc) 53 51 if not handle: 54 52 return None 55 - if resolve_did_from_handle(handle) != did: 53 + if resolve_did_from_handle(handle, didkv) != did: 56 54 return None 57 55 return (did, handle, doc) 58 56 ··· 79 77 return None 80 78 81 79 82 - def resolve_did_from_handle(handle: str, reload: bool = False) -> str | None: 80 + def resolve_did_from_handle(handle: str, kv: KV, reload: bool = False) -> str | None: 83 81 """Returns the DID for a given handle""" 84 82 85 - if handle in dids and not reload: 83 + if not is_valid_handle(handle): 84 + return None 85 + 86 + did = kv.get(handle) 87 + if did is not None and not reload: 86 88 print(f"returning cached did for {handle}") 87 - return dids[handle] 89 + return did 88 90 89 91 answer = resolve_dns(f"_atproto.{handle}", "TXT") 90 92 for record in answer: ··· 92 94 if value.startswith("did="): 93 95 did = value[4:] 94 96 if is_valid_did(did): 97 + print(f"caching did {did} for {handle}") 98 + kv.set(handle, value=did) 95 99 return did 96 100 97 101 return None ··· 106 110 return None 107 111 108 112 109 - def resolve_pds_from_did(did: DID, reload: bool = False) -> PdsUrl | None: 110 - if did in pdss and not reload: 113 + def resolve_pds_from_did(did: DID, kv: KV, reload: bool = False) -> PdsUrl | None: 114 + pds = kv.get(did) 115 + if pds is not None and not reload: 111 116 print(f"returning cached pds for {did}") 112 - return pdss[did] 117 + return pds 113 118 114 119 doc = resolve_doc_from_did(did) 115 120 if doc is None: 116 121 return None 117 122 pds = doc["service"][0]["serviceEndpoint"] 118 - pdss[did] = pds 123 + if pds is None: 124 + return None 119 125 print(f"caching pds {pds} for {did}") 126 + kv.set(did, value=pds) 120 127 return pds 121 128 122 129 ··· 139 146 140 147 def resolve_authserver_from_pds( 141 148 pds_url: PdsUrl, 149 + kv: KV, 142 150 reload: bool = False, 143 151 ) -> AuthserverUrl | None: 144 152 """Returns the authserver URL for the PDS.""" 145 153 146 - if pds_url in authservers and not reload: 154 + authserver_url = kv.get(pds_url) 155 + if authserver_url is not None and not reload: 147 156 print(f"returning cached authserver for PDS {pds_url}") 148 - return authservers[pds_url] 157 + return authserver_url 149 158 150 159 assert is_safe_url(pds_url) 151 160 endpoint = f"{pds_url}/.well-known/oauth-protected-resource" ··· 155 164 parsed: dict[str, list[str]] = response.json() 156 165 authserver_url = parsed["authorization_servers"][0] 157 166 print(f"caching authserver {authserver_url} for PDS {pds_url}") 158 - authservers[pds_url] = authserver_url 167 + kv.set(pds_url, value=authserver_url) 159 168 return authserver_url 160 169 161 170 ··· 163 172 """Returns metadata from the authserver""" 164 173 assert is_safe_url(authserver_url) 165 174 endpoint = f"{authserver_url}/.well-known/oauth-authorization-server" 166 - meta = http_get_json(endpoint) 175 + response = httpx.get(endpoint) 176 + if not response.is_success: 177 + return None 178 + meta: dict[str, Any] = response.json() 167 179 assert is_valid_authserver_meta(meta, authserver_url) 168 180 return meta 169 181 170 182 171 - def get_record(pds: str, repo: str, collection: str, record: str) -> str | None: 172 - response = http_get( 183 + def get_record( 184 + pds: str, 185 + repo: str, 186 + collection: str, 187 + record: str, 188 + ) -> dict[str, Any] | None: 189 + """Retrieve record from PDS. Verifies type is the same as collection name.""" 190 + response = httpx.get( 173 191 f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" 174 192 ) 175 - return response 176 - 177 - 178 - def http_get_json(url: str) -> Any | None: 179 - response = httpx.get(url) 180 - if response.is_success: 181 - return response.json() 182 - return None 183 - 184 - 185 - def http_get(url: str) -> str | None: 186 - response = httpx.get(url) 187 - if response.is_success: 188 - return response.text 189 - return None 193 + if not response.is_success: 194 + return None 195 + parsed = response.json() 196 + value: dict[str, Any] = parsed["value"] 197 + if value["$type"] != collection: 198 + return None 199 + del value["$type"] 200 + return value
+40
src/db.py
··· 1 + from abc import ABC, abstractmethod 2 + from typing import override 1 3 from flask import Flask, g 2 4 3 5 import sqlite3 6 + from sqlite3 import Connection 7 + 8 + 9 + class KV(ABC): 10 + @abstractmethod 11 + def get(self, key: str) -> str | None: 12 + pass 13 + 14 + @abstractmethod 15 + def set(self, key: str, value: str): 16 + pass 17 + 18 + 19 + class Keyval(KV): 20 + db: Connection 21 + prefix: str 22 + 23 + def __init__(self, app: Flask, prefix: str): 24 + self.db = get_db(app) 25 + self.prefix = prefix 26 + 27 + @override 28 + def get(self, key: str) -> str | None: 29 + cursor = self.db.cursor() 30 + row = cursor.execute( 31 + "select value from keyval where prefix = ? and key = ?", 32 + (self.prefix, key), 33 + ).fetchone() 34 + return None if row is None else row["value"] 35 + 36 + @override 37 + def set(self, key: str, value: str): 38 + cursor = self.db.cursor() 39 + _ = cursor.execute( 40 + "insert or replace into keyval (prefix, key, value) values (?, ?, ?)", 41 + (self.prefix, key, value), 42 + ) 43 + self.db.commit() 4 44 5 45 6 46 def get_db(app: Flask) -> sqlite3.Connection:
+29 -26
src/main.py
··· 10 10 resolve_pds_from_did, 11 11 ) 12 12 from .atproto.oauth import pds_authed_req 13 - from .db import close_db_connection, init_db 13 + from .db import Keyval, close_db_connection, init_db 14 14 from .oauth import get_auth_session, oauth, save_auth_session 15 15 from .types import OAuthSession 16 16 ··· 18 18 _ = app.config.from_prefixed_env() 19 19 app.register_blueprint(oauth) 20 20 init_db(app) 21 - 22 - links: dict[str, list[dict[str, str]]] = {} 23 - profiles: dict[str, tuple[str, str]] = {} 24 21 25 22 SCHEMA = "at.ligo" 26 23 ··· 55 52 @app.get("/@<string:handle>") 56 53 def page_profile_with_handle(handle: str): 57 54 reload = request.args.get("reload") is not None 58 - 59 - did = resolve_did_from_handle(handle, reload=reload) 55 + kv = Keyval(app, "did_from_handle") 56 + did = resolve_did_from_handle(handle, kv, reload=reload) 60 57 if did is None: 61 58 return "did not found", 404 62 59 return page_profile(did, reload=reload) 63 60 64 61 65 62 def page_profile(did: str, reload: bool = False): 66 - pds = resolve_pds_from_did(did, reload=reload) 63 + kv = Keyval(app, "pds_from_did") 64 + pds = resolve_pds_from_did(did, kv, reload=reload) 67 65 if pds is None: 68 66 return "pds not found", 404 69 67 profile, _ = load_profile(pds, did, reload=reload) ··· 195 193 196 194 197 195 def load_links(pds: str, did: str, reload: bool = False) -> list[dict[str, str]] | None: 198 - if did in links and not reload: 196 + kv = Keyval(app, "links_from_did") 197 + links = kv.get(did) 198 + 199 + if links is not None and not reload: 199 200 app.logger.debug(f"returning cached links for {did}") 200 - return links[did] 201 + return json.loads(links) 201 202 202 - response = get_record(pds, did, f"{SCHEMA}.actor.links", "self") 203 - if response is None: 203 + record = get_record(pds, did, f"{SCHEMA}.actor.links", "self") 204 + if record is None: 204 205 return None 205 206 206 - record = json.loads(response) 207 - links_ = record["value"]["links"] 207 + links = record["links"] 208 208 app.logger.debug(f"caching links for {did}") 209 - links[did] = links_ 210 - return links_ 209 + kv.set(did, value=json.dumps(links)) 210 + return links 211 211 212 212 213 213 def load_profile( 214 - pds: str, did: str, reload: bool = False 214 + pds: str, 215 + did: str, 216 + reload: bool = False, 215 217 ) -> tuple[tuple[str, str] | None, bool]: 216 - if did in profiles and not reload: 218 + kv = Keyval(app, "profile_from_did") 219 + profile = kv.get(did) 220 + 221 + if profile is not None and not reload: 217 222 app.logger.debug(f"returning cached profile for {did}") 218 - return profiles[did], False 223 + return json.loads(profile), False 219 224 220 225 from_bluesky = False 221 - response = get_record(pds, did, f"{SCHEMA}.actor.profile", "self") 222 - if response is None: 223 - response = get_record(pds, did, "app.bsky.actor.profile", "self") 226 + record = get_record(pds, did, f"{SCHEMA}.actor.profile", "self") 227 + if record is None: 228 + record = get_record(pds, did, "app.bsky.actor.profile", "self") 224 229 from_bluesky = True 225 - if response is None: 230 + if record is None: 226 231 return None, False 227 232 228 - record = json.loads(response) 229 - value: dict[str, str] = record["value"] 230 - profile = (value["displayName"], value["description"]) 233 + profile = (record["displayName"], record["description"]) 231 234 app.logger.debug(f"caching profile for {did}") 232 - profiles[did] = profile 235 + kv.set(did, value=json.dumps(profile)) 233 236 return profile, from_bluesky 234 237 235 238
+13 -5
src/oauth.py
··· 6 6 7 7 import json 8 8 9 + from .db import Keyval 10 + 9 11 from .atproto import ( 10 12 is_valid_did, 11 13 is_valid_handle, ··· 28 30 if not username: 29 31 return redirect(url_for("page_login"), 303) 30 32 33 + pdskv = Keyval(current_app, "authserver_from_pds") 34 + 31 35 if is_valid_handle(username) or is_valid_did(username): 32 36 login_hint = username 33 - identity = resolve_identity(username) 37 + kv = Keyval(current_app, "did_from_handle") 38 + identity = resolve_identity(username, didkv=kv) 34 39 if identity is None: 35 40 return "couldnt resolve identity", 500 36 41 did, handle, doc = identity ··· 38 43 if not pds_url: 39 44 return "pds not found", 404 40 45 current_app.logger.debug(f"account PDS: {pds_url}") 41 - authserver_url = resolve_authserver_from_pds(pds_url) 46 + authserver_url = resolve_authserver_from_pds(pds_url, pdskv) 42 47 if not authserver_url: 43 48 return "authserver not found", 404 44 49 45 50 elif username.startswith("https://") and is_safe_url(username): 46 51 did, handle, pds_url = None, None, None 47 52 login_hint = None 48 - authserver_url = resolve_authserver_from_pds(username) or username 53 + authserver_url = resolve_authserver_from_pds(username, pdskv) or username 49 54 50 55 else: 51 56 return "not a valid handle, did or auth server", 400 ··· 134 139 135 140 row = auth_request 136 141 142 + didkv = Keyval(current_app, "did_from_handle") 143 + authserverkv = Keyval(current_app, "authserver_from_pds") 144 + 137 145 if row.did: 138 146 # If we started with an account identifier, this is simple 139 147 did, handle, pds_url = row.did, row.handle, row.pds_url ··· 141 149 else: 142 150 did = tokens.sub 143 151 assert is_valid_did(did) 144 - identity = resolve_identity(did) 152 + identity = resolve_identity(did, didkv=didkv) 145 153 if not identity: 146 154 return "could not resolve identity", 500 147 155 did, handle, did_doc = identity 148 156 pds_url = pds_endpoint_from_doc(did_doc) 149 157 if not pds_url: 150 158 return "could not resolve pds", 500 151 - authserver_url = resolve_authserver_from_pds(pds_url) 159 + authserver_url = resolve_authserver_from_pds(pds_url, authserverkv) 152 160 assert authserver_url == authserver_iss 153 161 154 162 assert row.scope == tokens.scope
+7 -1
src/schema.sql
··· 1 - -- empty for now 1 + drop table if exists keyval; 2 + create table if not exists keyval ( 3 + prefix text not null, 4 + key text not null, 5 + value text, 6 + primary key (prefix, key) 7 + ) strict, without rowid;