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

add link sections

+71 -39
+50 -25
src/main.py
··· 1 1 import asyncio 2 2 import json 3 + from typing import Any, NamedTuple 3 4 4 - from aiohttp.client import ClientResponse, ClientSession 5 - from flask import Flask, g, session, redirect, render_template, request, url_for 6 - from flask_htmx import HTMX, make_response as htmx_reponse 7 - from typing import Any 5 + from aiohttp.client import ClientSession 6 + from flask import Flask, g, redirect, render_template, request, session, url_for 7 + from flask_htmx import HTMX 8 + from flask_htmx import make_response as htmx_response 8 9 9 10 from .atproto import ( 10 11 PdsUrl, ··· 25 26 htmx = HTMX() 26 27 htmx.init_app(app) 27 28 init_db(app) 28 - 29 - SCHEMA = "at.ligo" 30 29 31 30 32 31 @app.before_request ··· 74 73 pds = await resolve_pds_from_did(client, did=did, kv=pdskv, reload=reload) 75 74 if pds is None: 76 75 return render_template("error.html", message="pds not found"), 404 77 - (profile, _), links = await asyncio.gather( 76 + (profile, _), link_sections = await asyncio.gather( 78 77 load_profile(client, pds, did, reload=reload), 79 78 load_links(client, pds, did, reload=reload), 80 79 ) 81 - if links is None: 80 + if profile is None or link_sections is None: 82 81 return render_template("error.html", message="profile not found"), 404 83 82 84 83 if reload: 85 84 # remove the ?reload parameter 86 85 return redirect(request.path) 87 86 88 - profile["handle"] = handle 87 + if handle: 88 + profile["handle"] = handle 89 89 athref = f"at://{did}/at.ligo.actor.links/self" 90 90 return render_template( 91 91 "profile.html", 92 92 profile=profile, 93 - links=links, 93 + links=link_sections[0].links, 94 + sections=link_sections, 94 95 athref=athref, 95 96 ) 96 97 ··· 129 130 handle: str | None = user.handle 130 131 131 132 async with ClientSession() as client: 132 - (profile, from_bluesky), links = await asyncio.gather( 133 + (profile, from_bluesky), link_sections = await asyncio.gather( 133 134 load_profile(client, pds, did), 134 135 load_links(client, pds, did), 135 136 ) 136 137 138 + links = [] 139 + if link_sections: 140 + links = link_sections[0].links 141 + 137 142 return render_template( 138 143 "editor.html", 139 144 handle=handle, 140 145 profile=profile, 141 146 profile_from_bluesky=from_bluesky, 142 - links=json.dumps(links or []), 147 + links=json.dumps(links), 143 148 ) 144 149 145 150 ··· 155 160 return redirect("/editor", 303) 156 161 157 162 record = { 158 - "$type": f"{SCHEMA}.actor.profile", 163 + "$type": "at.ligo.actor.profile", 159 164 "displayName": display_name, 160 165 "description": description, 161 166 } ··· 164 169 user=user, 165 170 pds=user.pds_url, 166 171 repo=user.did, 167 - collection=f"{SCHEMA}.actor.profile", 172 + collection="at.ligo.actor.profile", 168 173 rkey="self", 169 174 record=record, 170 175 ) ··· 174 179 kv.set(user.did, json.dumps(record)) 175 180 176 181 if htmx: 177 - return htmx_reponse( 182 + return htmx_response( 178 183 render_template("_editor_profile.html", profile=record), 179 184 reswap="outerHTML", 180 185 ) ··· 206 211 links.append(link) 207 212 208 213 record = { 209 - "$type": f"{SCHEMA}.actor.links", 210 - "links": links, 214 + "$type": "at.ligo.actor.links", 215 + "sections": [ 216 + { 217 + "title": "", 218 + "links": links, 219 + } 220 + ], 211 221 } 212 222 213 223 success = await put_record( 214 224 user=user, 215 225 pds=user.pds_url, 216 226 repo=user.did, 217 - collection=f"{SCHEMA}.actor.links", 227 + collection="at.ligo.actor.links", 218 228 rkey="self", 219 229 record=record, 220 230 ) ··· 224 234 kv.set(user.did, json.dumps(record)) 225 235 226 236 if htmx: 227 - return htmx_reponse( 228 - render_template("_editor_links.html", links=record["links"]), 237 + return htmx_response( 238 + render_template("_editor_links.html", links=record["sections"][0]["links"]), 229 239 reswap="outerHTML", 230 240 ) 231 241 ··· 237 247 return render_template("terms.html") 238 248 239 249 250 + class LinkSection(NamedTuple): 251 + title: str 252 + links: list[dict[str, str]] 253 + 254 + 240 255 async def load_links( 241 256 client: ClientSession, 242 257 pds: str, 243 258 did: str, 244 259 reload: bool = False, 245 - ) -> list[dict[str, str]] | None: 260 + ) -> list[LinkSection] | None: 246 261 kv = KV(app, app.logger, "links_from_did") 247 262 record_json = kv.get(did) 248 263 249 264 if record_json is not None and not reload: 250 - return json.loads(record_json)["links"] 265 + parsed = json.loads(record_json) 266 + return _links_or_sections(parsed) 251 267 252 - record = await get_record(client, pds, did, f"{SCHEMA}.actor.links", "self") 268 + record = await get_record(client, pds, did, "at.ligo.actor.links", "self") 253 269 if record is None: 254 270 return None 255 271 256 272 kv.set(did, value=json.dumps(record)) 257 - return record["links"] 273 + return _links_or_sections(record) 274 + 275 + 276 + def _links_or_sections(raw: dict[str, Any]) -> list[LinkSection] | None: 277 + if "sections" in raw: 278 + return list(map(lambda s: LinkSection(**s), raw["sections"])) 279 + elif "links" in raw: 280 + return [LinkSection("", raw["links"])] 281 + else: 282 + return None 258 283 259 284 260 285 async def load_profile( ··· 271 296 return json.loads(record_json), False 272 297 273 298 (record, bsky_record) = await asyncio.gather( 274 - get_record(client, pds, did, f"{SCHEMA}.actor.profile", "self"), 299 + get_record(client, pds, did, "at.ligo.actor.profile", "self"), 275 300 get_record(client, pds, did, "app.bsky.actor.profile", "self"), 276 301 ) 277 302
+11 -11
src/oauth.py
··· 1 + import json 2 + from urllib.parse import urlencode 3 + 1 4 from aiohttp.client import ClientSession 2 5 from authlib.jose import JsonWebKey, Key 3 6 from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for 4 - from urllib.parse import urlencode 5 - 6 - import json 7 7 8 - from .auth import ( 9 - delete_auth_request, 10 - get_auth_request, 11 - save_auth_request, 12 - save_auth_session, 13 - ) 14 - from .db import KV, get_db 15 8 from .atproto import ( 9 + fetch_authserver_meta, 16 10 is_valid_did, 17 11 is_valid_handle, 18 12 pds_endpoint_from_doc, 19 13 resolve_authserver_from_pds, 20 - fetch_authserver_meta, 21 14 resolve_identity, 22 15 ) 23 16 from .atproto.oauth import initial_token_request, send_par_auth_request 24 17 from .atproto.types import OAuthAuthRequest, OAuthSession 18 + from .auth import ( 19 + delete_auth_request, 20 + get_auth_request, 21 + save_auth_request, 22 + save_auth_session, 23 + ) 24 + from .db import KV, get_db 25 25 from .security import is_safe_url 26 26 27 27 oauth = Blueprint("oauth", __name__, url_prefix="/oauth")
+2 -1
src/static/profile/default.css
··· 36 36 37 37 .wrapper { 38 38 margin: auto; 39 - max-width: 25em; 39 + max-width: 20em; 40 40 } 41 41 42 42 .wrapper.profile { ··· 63 63 .links-container ul { 64 64 list-style: none; 65 65 padding: 0; 66 + margin: 2em 0; 66 67 } 67 68 68 69 .link-item {
+4
src/static/style.css
··· 21 21 text-wrap: pretty; 22 22 } 23 23 24 + .wrapper { 25 + max-width: 25em; 26 + } 27 + 24 28 .wrapper.error p { 25 29 text-align: center; 26 30 }
+4 -2
src/templates/profile.html
··· 35 35 {% endif %} 36 36 </header> 37 37 <div class="links-container"> 38 - <ul> 39 - {% for link in links %} 38 + {% for section in sections %} 39 + <ul class="link-section"> 40 + {% for link in section.links %} 40 41 <li style="color: {{ link.backgroundColor }}"> 41 42 <a href="{{ link.href }}"> 42 43 <div class="link-item"> ··· 49 50 </li> 50 51 {% endfor %} 51 52 </ul> 53 + {% endfor %} 52 54 </div> 53 55 <footer> 54 56 made with <a href="https://ligo.at" target="_self">ligo.at</a>