decentralized and customizable links page on top of atproto
at authservers 9.9 kB view raw
1import asyncio 2import json 3from typing import Any, NamedTuple 4 5from aiohttp.client import ClientSession 6from flask import Flask, g, redirect, render_template, request, session, url_for 7from flask_htmx import HTMX 8from flask_htmx import make_response as htmx_response 9 10from src.atproto import ( 11 PdsUrl, 12 get_record, 13 is_valid_did, 14 resolve_did_from_handle, 15 resolve_pds_from_did, 16) 17from src.atproto.oauth import pds_authed_req 18from src.atproto.types import OAuthSession 19from src.auth import get_auth_session, save_auth_session 20from src.db import KV, close_db_connection, get_db, init_db 21from src.oauth import oauth 22 23app = Flask(__name__) 24_ = app.config.from_prefixed_env() 25app.register_blueprint(oauth) 26htmx = HTMX() 27htmx.init_app(app) 28init_db(app) 29 30 31@app.before_request 32async def load_user_to_context(): 33 g.user = get_auth_session(session) 34 35 36def get_user() -> OAuthSession | None: 37 return g.user 38 39 40@app.teardown_appcontext 41async def app_teardown(exception: BaseException | None): 42 close_db_connection(exception) 43 44 45@app.get("/") 46def page_home(): 47 return render_template("index.html") 48 49 50@app.get("/<string:atid>") 51async def page_profile(atid: str): 52 reload = request.args.get("reload") is not None 53 54 db = get_db(app) 55 didkv = KV(db, app.logger, "did_from_handle") 56 pdskv = KV(db, app.logger, "pds_from_did") 57 58 async with ClientSession() as client: 59 if atid.startswith("@"): 60 handle = atid[1:].lower() 61 did = await resolve_did_from_handle(client, handle, kv=didkv, reload=reload) 62 if did is None: 63 return render_template("error.html", message="did not found"), 404 64 elif is_valid_did(atid): 65 handle = None 66 did = atid 67 else: 68 return render_template("error.html", message="invalid did or handle"), 400 69 70 if _is_did_blocked(did): 71 return render_template("error.html", message="profile not found"), 404 72 73 pds = await resolve_pds_from_did(client, did=did, kv=pdskv, reload=reload) 74 if pds is None: 75 return render_template("error.html", message="pds not found"), 404 76 (profile, _), link_sections = await asyncio.gather( 77 load_profile(client, pds, did, reload=reload), 78 load_links(client, pds, did, reload=reload), 79 ) 80 if profile is None or link_sections is None: 81 return render_template("error.html", message="profile not found"), 404 82 83 if reload: 84 # remove the ?reload parameter 85 return redirect(request.path) 86 87 if handle: 88 profile["handle"] = handle 89 athref = f"at://{did}/at.ligo.actor.links/self" 90 return render_template( 91 "profile.html", 92 profile=profile, 93 links=link_sections[0].links, 94 sections=link_sections, 95 athref=athref, 96 ) 97 98 99@app.get("/login") 100def page_login(): 101 if get_user() is not None: 102 return redirect("/editor") 103 return render_template("login.html") 104 105 106@app.post("/login") 107def auth_login(): 108 value = request.form.get("username") or request.form.get("authserver") 109 if value and value[0] == "@": 110 value = value[1:] 111 if not value: 112 return redirect(url_for("page_login"), 303) 113 return redirect(url_for("oauth.oauth_start", username_or_authserver=value), 303) 114 115 116@app.route("/auth/logout") 117def auth_logout(): 118 session.clear() 119 return redirect(url_for("page_login"), 303) 120 121 122@app.get("/editor") 123async def page_editor(): 124 user = get_user() 125 if user is None: 126 return redirect("/login", 302) 127 128 did: str = user.did 129 pds: str = user.pds_url 130 handle: str | None = user.handle 131 132 async with ClientSession() as client: 133 (profile, from_bluesky), link_sections = await asyncio.gather( 134 load_profile(client, pds, did), 135 load_links(client, pds, did), 136 ) 137 138 links = [] 139 if link_sections: 140 links = link_sections[0].links 141 142 return render_template( 143 "editor.html", 144 handle=handle, 145 profile=profile, 146 profile_from_bluesky=from_bluesky, 147 links=json.dumps(links), 148 ) 149 150 151@app.post("/editor/profile") 152async def post_editor_profile(): 153 user = get_user() 154 if user is None: 155 url = url_for("auth_logout") 156 return htmx_response(redirect=url) if htmx else redirect(url, 303) 157 158 display_name = request.form.get("displayName") 159 description = request.form.get("description", "") 160 if not display_name: 161 return redirect("/editor", 303) 162 163 record = { 164 "$type": "at.ligo.actor.profile", 165 "displayName": display_name, 166 "description": description, 167 } 168 169 success = await put_record( 170 user=user, 171 pds=user.pds_url, 172 repo=user.did, 173 collection="at.ligo.actor.profile", 174 rkey="self", 175 record=record, 176 ) 177 178 if success: 179 kv = KV(app, app.logger, "profile_from_did") 180 kv.set(user.did, json.dumps(record)) 181 else: 182 app.logger.warning("log out user for now") 183 url = url_for("auth_logout") 184 return htmx_response(redirect=url) if htmx else redirect(url, 303) 185 186 if htmx: 187 return htmx_response( 188 render_template("_editor_profile.html", profile=record), 189 reswap="outerHTML", 190 ) 191 192 return redirect(url_for("page_editor"), 303) 193 194 195@app.post("/editor/links") 196async def post_editor_links(): 197 user = get_user() 198 if user is None: 199 url = url_for("auth_logout") 200 return htmx_response(redirect=url) if htmx else redirect(url, 303) 201 202 links: list[dict[str, str]] = [] 203 hrefs = request.form.getlist("link-href") 204 titles = request.form.getlist("link-title") 205 subtitles = request.form.getlist("link-subtitle") 206 backgrounds = request.form.getlist("link-background-color") 207 for href, title, subtitle, background in zip(hrefs, titles, subtitles, backgrounds): 208 if not href or not title or not background: 209 break 210 link: dict[str, str] = { 211 "href": href, 212 "title": title, 213 "backgroundColor": background, 214 } 215 if subtitle: 216 link["subtitle"] = subtitle 217 links.append(link) 218 219 record = { 220 "$type": "at.ligo.actor.links", 221 "sections": [ 222 { 223 "title": "", 224 "links": links, 225 } 226 ], 227 } 228 229 success = await put_record( 230 user=user, 231 pds=user.pds_url, 232 repo=user.did, 233 collection="at.ligo.actor.links", 234 rkey="self", 235 record=record, 236 ) 237 238 if success: 239 kv = KV(app, app.logger, "links_from_did") 240 kv.set(user.did, json.dumps(record)) 241 else: 242 app.logger.warning("log out user for now") 243 url = url_for("auth_logout") 244 return htmx_response(redirect=url) if htmx else redirect(url, 303) 245 246 if htmx: 247 return htmx_response( 248 render_template("_editor_links.html", links=record["sections"][0]["links"]), 249 reswap="outerHTML", 250 ) 251 252 return redirect(url_for("page_editor"), 303) 253 254 255@app.get("/terms") 256def page_terms(): 257 return render_template("terms.html") 258 259 260class LinkSection(NamedTuple): 261 title: str 262 links: list[dict[str, str]] 263 264 265async def load_links( 266 client: ClientSession, 267 pds: str, 268 did: str, 269 reload: bool = False, 270) -> list[LinkSection] | None: 271 kv = KV(app, app.logger, "links_from_did") 272 record_json = kv.get(did) 273 274 if record_json is not None and not reload: 275 parsed = json.loads(record_json) 276 return _links_or_sections(parsed) 277 278 record = await get_record(client, pds, did, "at.ligo.actor.links", "self") 279 if record is None: 280 return None 281 282 kv.set(did, value=json.dumps(record)) 283 return _links_or_sections(record) 284 285 286def _links_or_sections(raw: dict[str, Any]) -> list[LinkSection] | None: 287 if "sections" in raw: 288 return list(map(lambda s: LinkSection(**s), raw["sections"])) 289 elif "links" in raw: 290 return [LinkSection("", raw["links"])] 291 else: 292 return None 293 294 295async def load_profile( 296 client: ClientSession, 297 pds: str, 298 did: str, 299 fallback_with_bluesky: bool = True, 300 reload: bool = False, 301) -> tuple[dict[str, str] | None, bool]: 302 kv = KV(app, app.logger, "profile_from_did") 303 record_json = kv.get(did) 304 305 if record_json is not None and not reload: 306 return json.loads(record_json), False 307 308 (record, bsky_record) = await asyncio.gather( 309 get_record(client, pds, did, "at.ligo.actor.profile", "self"), 310 get_record(client, pds, did, "app.bsky.actor.profile", "self"), 311 ) 312 313 from_bluesky = False 314 if record is None and fallback_with_bluesky: 315 record = bsky_record 316 from_bluesky = True 317 318 if record is not None: 319 kv.set(did, value=json.dumps(record)) 320 321 return record, from_bluesky 322 323 324# TODO: move to .atproto 325async def put_record( 326 user: OAuthSession, 327 pds: PdsUrl, 328 repo: str, 329 collection: str, 330 rkey: str, 331 record: dict[str, Any], 332) -> bool: 333 """Writes the record onto the users PDS. Returns bool for success.""" 334 335 endpoint = f"{pds}/xrpc/com.atproto.repo.putRecord" 336 body = { 337 "repo": repo, 338 "collection": collection, 339 "rkey": rkey, 340 "record": record, 341 } 342 343 def update_dpop_pds_nonce(nonce: str): 344 session_ = user._replace(dpop_pds_nonce=nonce) 345 save_auth_session(session, session_) 346 347 response = await pds_authed_req( 348 method="POST", 349 url=endpoint, 350 body=body, 351 user=user, 352 update_dpop_pds_nonce=update_dpop_pds_nonce, 353 ) 354 355 if not response.ok: 356 app.logger.warning(f"put_record failed with status {response.status}") 357 app.logger.warning(await response.text()) 358 359 return response.ok 360 361 362def _is_did_blocked(did: str) -> bool: 363 kv = KV(app, app.logger, "blockeddids") 364 return kv.get(did) is not None