decentralized and customizable links page on top of atproto

rename to ligo.at

+5 -1
Makefile
··· 1 1 .PHONY: debug 2 2 debug: 3 - flask run --debug -h '0.0.0.0' -p 8080 3 + flask --app 'src.main' run --debug -h '0.0.0.0' -p 8080 4 + 5 + .PHONY: run 6 + run: 7 + gunicorn --bind ':$(PORT)' 'src.main:app'
-1
app.py
··· 1 - from src.main import app
+1
pyproject.toml
··· 8 8 "authlib>=1.3", 9 9 "dnspython>=2.8.0", 10 10 "flask[dotenv]>=3.1.2", 11 + "gunicorn>=23.0.0", 11 12 "requests>=2.32", 12 13 "requests-hardened>=1.2.0", 13 14 ]
+24 -12
src/atproto/atproto_oauth.py src/atproto/oauth.py
··· 1 1 import sqlite3 2 - from typing import Any 2 + from typing import Any, NamedTuple 3 3 import time 4 4 import json 5 5 from authlib.jose import JsonWebKey, Key ··· 13 13 from ..types import OAuthAuthRequest, OAuthSession 14 14 15 15 from ..security import is_safe_url, hardened_http 16 + 17 + 18 + class OAuthTokens(NamedTuple): 19 + access_token: str 20 + refresh_token: str 21 + scope: str 22 + sub: str 16 23 17 24 18 25 # Prepares and sends a pushed auth request (PAR) via HTTP POST to the Authorization Server. ··· 93 100 94 101 95 102 # Completes the auth flow by sending an initial auth token request. 96 - # Returns token response (dict) and DPoP nonce (str) 103 + # Returns token response (OAuthTokens) and DPoP nonce (str) 104 + # IMPORTANT: the 'tokens.sub' field must be verified against the original request by code calling this function. 97 105 def initial_token_request( 98 106 auth_request: OAuthAuthRequest, 99 107 code: str, 100 108 app_url: str, 101 109 client_secret_jwk: Key, 102 - ) -> tuple[dict[str, str], str]: 110 + ) -> tuple[OAuthTokens, str]: 103 111 authserver_url = auth_request.authserver_iss 104 112 105 113 # Re-fetch server metadata ··· 150 158 with hardened_http.get_session() as sess: 151 159 resp = sess.post(token_url, data=params, headers={"DPoP": dpop_proof}) 152 160 153 - token_body = resp.json() 154 - print(token_body) 155 - 156 161 resp.raise_for_status() 157 - 158 - # IMPORTANT: the 'sub' field must be verified against the original request by code calling this function. 162 + token_body = resp.json() 163 + try: 164 + tokens = OAuthTokens(**token_body) 165 + except TypeError: 166 + raise Exception("invalid token body") 159 167 160 - return token_body, dpop_authserver_nonce 168 + return tokens, dpop_authserver_nonce 161 169 162 170 163 - # Returns token response (dict) and DPoP nonce (str) 171 + # Returns token response (OAuthTokens) and DPoP nonce (str) 164 172 def refresh_token_request( 165 173 user: OAuthSession, 166 174 app_url: str, 167 175 client_secret_jwk: Key, 168 - ) -> tuple[dict[str, str], str]: 176 + ) -> tuple[OAuthTokens, str]: 169 177 authserver_url = user.authserver_iss 170 178 171 179 # Re-fetch server metadata ··· 218 226 219 227 resp.raise_for_status() 220 228 token_body = resp.json() 229 + try: 230 + tokens = OAuthTokens(**token_body) 231 + except TypeError: 232 + raise Exception("invalid token body") 221 233 222 - return token_body, dpop_authserver_nonce 234 + return tokens, dpop_authserver_nonce 223 235 224 236 225 237 # Helper to demonstrate making a request (HTTP GET or POST) to the user's PDS ("Resource Server" in OAuth terminology) using DPoP and access token.
+1
src/db.py
··· 8 8 if db is None: 9 9 db_path: str = app.config.get("DATABASE_URL", "ligoat.db") 10 10 db = g.db = sqlite3.connect(db_path) 11 + # return rows as dict-like objects 11 12 db.row_factory = sqlite3.Row 12 13 return db 13 14
+1 -1
src/main.py
··· 3 3 import json 4 4 5 5 from .atproto import PdsUrl, get_record, resolve_did_from_handle, resolve_pds_from_did 6 - from .atproto.atproto_oauth import pds_authed_req 6 + from .atproto.oauth import pds_authed_req 7 7 from .db import close_db_connection, get_db, init_db 8 8 from .oauth import oauth 9 9 from .types import OAuthSession
+6 -6
src/oauth.py
··· 4 4 5 5 import json 6 6 7 - from .atproto.atproto_oauth import initial_token_request, send_par_auth_request 8 7 from .atproto import ( 9 8 is_valid_did, 10 9 is_valid_handle, ··· 13 12 fetch_authserver_meta, 14 13 resolve_identity, 15 14 ) 15 + from .atproto.oauth import initial_token_request, send_par_auth_request 16 16 from .security import is_safe_url 17 17 from .types import OAuthAuthRequest 18 18 from .db import get_db ··· 151 151 if row.did: 152 152 # If we started with an account identifier, this is simple 153 153 did, handle, pds_url = row.did, row.handle, row.pds_url 154 - assert tokens["sub"] == did 154 + assert tokens.sub == did 155 155 else: 156 - did = tokens["sub"] 156 + did = tokens.sub 157 157 assert is_valid_did(did) 158 158 identity = resolve_identity(did) 159 159 if not identity: ··· 165 165 authserver_url = resolve_authserver_from_pds(pds_url) 166 166 assert authserver_url == authserver_iss 167 167 168 - assert row.scope == tokens["scope"] 168 + assert row.scope == tokens.scope 169 169 170 170 current_app.logger.debug("storing user did and handle") 171 171 db = get_db(current_app) ··· 177 177 handle, 178 178 pds_url, 179 179 authserver_iss, 180 - tokens["access_token"], 181 - tokens["refresh_token"], 180 + tokens.access_token, 181 + tokens.refresh_token, 182 182 dpop_authserver_nonce, 183 183 None, 184 184 auth_request.dpop_private_jwk,
+3 -3
src/templates/editor.html
··· 2 2 <html> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>edit your profile &mdash; atlinks</title> 5 + <title>edit your profile &mdash; ligo.at</title> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> 7 7 <link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" /> 8 8 <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> ··· 12 12 <body> 13 13 <div class="wrapper editor" x-data="{ links: {{ links }}, linksChanged: false }"> 14 14 <header> 15 - <h1>atlinks</h1> 15 + <h1>ligo.at</h1> 16 16 <span class="tagline">edit your profile & links</span> 17 17 </header> 18 18 ··· 34 34 </label> 35 35 {% if profile_from_bluesky %} 36 36 <p> 37 - <span class="caption">Profile was fetched from Bluesky. On save it will use an independent, atlinks only copy.</span> 37 + <span class="caption">Profile was fetched from Bluesky. On save it will use an independent, ligo.at only copy.</span> 38 38 </p> 39 39 {% endif %} 40 40 <label>
+3 -3
src/templates/index.html
··· 2 2 <html> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>atlinks</title> 5 + <title>ligo.at</title> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 7 <link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" /> 8 8 <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> ··· 10 10 <body> 11 11 <div class="wrapper home"> 12 12 <header> 13 - <h1>atlinks</h1> 14 - <span class="tagline">name pending</span> 13 + <h1>ligo.at</h1> 14 + <span class="tagline">(noun) connection</span> 15 15 </header> 16 16 <p> 17 17 Get your own links page for all your social profiles. Decentralized thanks to
+2 -2
src/templates/login.html
··· 2 2 <html> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>login &mdash; atlinks</title> 5 + <title>login &mdash; ligo.at</title> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> 7 7 <link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" /> 8 8 <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> ··· 10 10 <body> 11 11 <div class="wrapper login"> 12 12 <header> 13 - <h1>atlinks</h1> 13 + <h1>ligo.at</h1> 14 14 <span class="tagline">log in to your account</span> 15 15 </header> 16 16 <form action="{{ url_for('auth_login') }}" method="post">
+1 -1
src/templates/profile.html
··· 31 31 {% endfor %} 32 32 </ul> 33 33 <footer> 34 - made with <a href="/" target="_self">atlinks</a> 34 + made with <a href="https://ligo.at" target="_self">ligo.at</a> 35 35 </footer> 36 36 </div> 37 37 <!-- .wrapper -->
+23
uv.lock
··· 196 196 ] 197 197 198 198 [[package]] 199 + name = "gunicorn" 200 + version = "23.0.0" 201 + source = { registry = "https://pypi.org/simple" } 202 + dependencies = [ 203 + { name = "packaging" }, 204 + ] 205 + sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } 206 + wheels = [ 207 + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, 208 + ] 209 + 210 + [[package]] 199 211 name = "idna" 200 212 version = "3.10" 201 213 source = { registry = "https://pypi.org/simple" } ··· 233 245 { name = "authlib" }, 234 246 { name = "dnspython" }, 235 247 { name = "flask", extra = ["dotenv"] }, 248 + { name = "gunicorn" }, 236 249 { name = "requests" }, 237 250 { name = "requests-hardened" }, 238 251 ] ··· 242 255 { name = "authlib", specifier = ">=1.3" }, 243 256 { name = "dnspython", specifier = ">=2.8.0" }, 244 257 { name = "flask", extras = ["dotenv"], specifier = ">=3.1.2" }, 258 + { name = "gunicorn", specifier = ">=23.0.0" }, 245 259 { name = "requests", specifier = ">=2.32" }, 246 260 { name = "requests-hardened", specifier = ">=1.2.0" }, 247 261 ] ··· 296 310 { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, 297 311 { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, 298 312 { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, 313 + ] 314 + 315 + [[package]] 316 + name = "packaging" 317 + version = "25.0" 318 + source = { registry = "https://pypi.org/simple" } 319 + sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 320 + wheels = [ 321 + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 299 322 ] 300 323 301 324 [[package]]