decentralized and customizable links page on top of atproto

store session in db

Changed files
+127 -71
src
+1
.gitignore
··· 1 1 .env 2 2 .venv 3 + *.db 3 4 *.pyc
+7
src/atproto2/__init__.py
··· 149 149 return meta 150 150 151 151 152 + def get_record(pds: str, repo: str, collection: str, record: str) -> str | None: 153 + response = http_get( 154 + f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" 155 + ) 156 + return response 157 + 158 + 152 159 def http_get_json(url: str) -> Any | None: 153 160 response = requests.get(url) 154 161 if response.ok:
+26
src/db.py
··· 1 + import sqlite3 2 + 3 + from flask import Flask, g 4 + 5 + 6 + def get_db(app: Flask) -> sqlite3.Connection: 7 + db: sqlite3.Connection | None = g.get("db", None) 8 + if db is None: 9 + db_path: str = app.config.get("DATABASE_URL", "ligoat.db") 10 + db = g.db = sqlite3.connect(db_path) 11 + db.row_factory = sqlite3.Row 12 + return db 13 + 14 + 15 + def close_db_connection(_exception: BaseException | None): 16 + db: sqlite3.Connection | None = g.get("db", None) 17 + if db is not None: 18 + db.close() 19 + 20 + 21 + def init_db(app: Flask): 22 + with app.app_context(): 23 + db = get_db(app) 24 + with app.open_resource("schema.sql", mode="r") as schema: 25 + _ = db.cursor().executescript(schema.read()) 26 + db.commit()
+57 -44
src/main.py
··· 1 1 from atproto import Client 2 - from atproto.exceptions import AtProtocolError 3 2 from atproto_client.models import ComAtprotoRepoCreateRecord 4 - from atproto_client.models.app.bsky.actor.defs import ProfileViewDetailed 5 - from flask import Flask, session, redirect, render_template, request 3 + from flask import Flask, g, session, redirect, render_template, request, url_for 6 4 from urllib import request as http_request 7 5 import json 8 6 9 - from .atproto2 import resolve_did_from_handle, resolve_pds_from_did 7 + from .atproto2 import get_record, resolve_did_from_handle, resolve_pds_from_did 8 + from .db import close_db_connection, get_db, init_db 10 9 from .oauth import oauth 11 10 12 11 app = Flask(__name__) 13 12 _ = app.config.from_prefixed_env() 14 13 app.register_blueprint(oauth) 14 + init_db(app) 15 15 16 - pdss: dict[str, str] = {} 17 - dids: dict[str, str] = {} 18 16 links: dict[str, list[dict[str, str]]] = {} 19 17 profiles: dict[str, tuple[str, str]] = {} 20 18 21 19 SCHEMA = "one.nauta" 22 20 23 21 22 + @app.before_request 23 + def load_user_to_context(): 24 + did: str | None = session.get("user_did") 25 + if did is None: 26 + g.user = None 27 + else: 28 + db = get_db(app) 29 + g.user = db.execute( 30 + "select * from oauth_session where did = ?", 31 + (did,), 32 + ).fetchone() 33 + 34 + 35 + def get_user() -> dict[str, str] | None: 36 + return g.user 37 + 38 + 39 + @app.teardown_appcontext 40 + def app_teardown(exception: BaseException | None): 41 + close_db_connection(exception) 42 + 43 + 24 44 @app.get("/") 25 45 def page_home(): 26 46 return render_template("index.html") ··· 50 70 51 71 @app.get("/login") 52 72 def page_login(): 53 - if "session" in session: 73 + if get_user() is not None: 54 74 return redirect("/editor") 55 75 return render_template("login.html") 76 + 77 + 78 + @app.post("/login") 79 + def auth_login(): 80 + username = request.form.get("username") 81 + if not username: 82 + return redirect(url_for("page_login"), 303) 83 + return redirect(url_for("oauth.oauth_start", username=username), 303) 56 84 57 85 58 86 @app.get("/editor") 59 87 def page_editor(): 60 - sess: str | None = session.get("session") 61 - if sess is None or not sess: 88 + user = get_user() 89 + if user is None: 62 90 return redirect("/login") 63 - client = Client() 64 - profile: ProfileViewDetailed | None 65 - try: 66 - profile = client.login(session_string=sess) 67 - except AtProtocolError: 68 - session.clear() 69 - return redirect("/login", 303) 91 + 92 + did: str = user["did"] 93 + pds: str = user["pds_url"] 94 + handle: str | None = user["handle"] 70 95 71 - pds = resolve_pds_from_did(profile.did) 72 - if not pds: 73 - return "did not found", 404 74 - pro, from_bluesky = load_profile(pds, profile.did, reload=True) 75 - links = load_links(pds, profile.did, reload=True) or [{"background": "#fa0"}] 96 + profile, from_bluesky = load_profile(pds, did, reload=True) 97 + links = load_links(pds, did, reload=True) or [{"background": "#fa0"}] 76 98 77 99 return render_template( 78 100 "editor.html", 79 - handle=profile.handle, 80 - profile=pro, 101 + handle=handle, 102 + profile=profile, 81 103 profile_from_bluesky=from_bluesky, 82 104 links=json.dumps(links), 83 105 ) ··· 85 107 86 108 @app.post("/editor/profile") 87 109 def post_editor_profile(): 88 - sess: str | None = session.get("session") 89 - if sess is None or not sess: 110 + user = get_user() 111 + if user is None: 90 112 return redirect("/login", 303) 113 + 91 114 client = Client() 92 - profile = client.login(session_string=sess) 115 + profile = client.login(session_string=user["did"]) 93 116 94 117 display_name = request.form.get("displayName") 95 118 description = request.form.get("description") or "" ··· 190 213 return profile, from_bluesky 191 214 192 215 193 - def get_record(pds: str, repo: str, collection: str, record: str) -> str | None: 194 - response = http_get( 195 - f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" 196 - ) 197 - return response 198 - 199 - 200 216 def put_record(client: Client, repo: str, collection: str, rkey: str, record): 201 217 data_model = ComAtprotoRepoCreateRecord.Data( 202 218 collection=collection, ··· 223 239 224 240 @app.route("/auth/logout") 225 241 def auth_logout(): 242 + user = get_user() 243 + if user is not None: 244 + db = get_db(app) 245 + cursor = db.cursor() 246 + _ = cursor.execute("delete from oauth_session where did = ?", (user["did"],)) 247 + db.commit() 248 + cursor.close() 226 249 session.clear() 227 - return redirect("/") 228 - 229 - 230 - @app.post("/auth/login") 231 - def auth_login(): 232 - handle = request.form.get("handle") 233 - if not handle: 234 - return redirect("/login", 303) 235 - if handle.startswith("@"): 236 - handle = handle[1:] 237 - return redirect(app.url_for("oauth.oauth_start", username=handle)) 250 + return redirect("/", 303)
+22 -24
src/oauth.py
··· 5 5 import json 6 6 7 7 from .atproto2.atproto_oauth import initial_token_request, send_par_auth_request 8 - 9 8 from .atproto2.atproto_security import is_safe_url 10 - 11 9 from .atproto2 import ( 12 10 pds_endpoint_from_doc, 13 11 resolve_authserver_from_pds, 14 12 resolve_authserver_meta, 15 13 resolve_identity, 16 14 ) 15 + from .db import get_db 17 16 18 17 oauth = Blueprint("oauth", __name__, url_prefix="/oauth") 19 18 20 19 21 20 oauth_auth_requests: dict[str, dict[str, str]] = {} 22 - oauth_session: dict[str, dict[str, str]] = {} 23 - 24 - 25 - @oauth.get("/home") 26 - def oauth_home(): 27 - user_did = session["user_did"] 28 - user_handle = session["user_handle"] 29 - return f"{user_did} {user_handle}" 30 21 31 22 32 23 @oauth.get("/start") ··· 75 66 dpop_private_jwk, 76 67 ) 77 68 if resp.status_code == 400: 78 - print(f"PAR HTTP 400: {resp.json()}") 69 + current_app.logger.info(f"PAR HTTP 400: {resp.json()}") 79 70 resp.raise_for_status() 80 71 81 72 par_request_uri = resp.json()["request_uri"] ··· 105 96 106 97 auth_request = oauth_auth_requests.get(state) 107 98 if auth_request is None: 108 - return redirect(url_for("oauth.oauth_home"), 303) 99 + return redirect(url_for("page_login"), 303) 109 100 110 101 current_app.logger.debug(f"Deleting auth request for state={state}") 111 102 _ = oauth_auth_requests.pop(state) ··· 135 126 136 127 assert row["scope"] == tokens["scope"] 137 128 138 - oauth_session[did] = { 139 - "did": did, 140 - "handle": handle, 141 - "pds_url": pds_url, 142 - "authserver_iss": authserver_iss, 143 - "access_token": tokens["access_token"], 144 - "refresh_token": tokens["refresh_token"], 145 - "dpop_authserver_nonce": dpop_authserver_nonce, 146 - "dpop_private_jwk": auth_request["dpop_private_jwk"], 147 - } 148 - 149 129 current_app.logger.debug("storing user did and handle") 130 + db = get_db(current_app) 131 + cursor = db.cursor() 132 + _ = cursor.execute( 133 + "insert or replace into oauth_session values (?, ?, ?, ?, ?, ?, ?, ?, ?)", 134 + ( 135 + did, 136 + handle, 137 + pds_url, 138 + authserver_iss, 139 + tokens["access_token"], 140 + tokens["refresh_token"], 141 + dpop_authserver_nonce, 142 + None, 143 + auth_request["dpop_private_jwk"], 144 + ), 145 + ) 146 + db.commit() 147 + cursor.close() 150 148 151 149 session["user_did"] = did 152 150 session["user_handle"] = auth_request["handle"] 153 151 154 - return redirect(url_for("oauth.oauth_home")) 152 + return redirect(url_for("page_login")) 155 153 156 154 157 155 @oauth.get("/metadata")
+11
src/schema.sql
··· 1 + create table if not exists oauth_session ( 2 + did text not null primary key, 3 + handle text, 4 + pds_url text not null, 5 + authserver_iss text not null, 6 + access_token text, 7 + refresh_token text, 8 + dpop_authserver_nonce text not null, 9 + dpop_pds_nonce text, 10 + dpop_private_jwk text not null 11 + ) strict, without rowid;
+1 -1
src/templates/editor.html
··· 19 19 <p> 20 20 <a href="/@{{ handle }}">see profile</a> 21 21 <span>·</span> 22 - <a href="/auth/logout">logout</a> 22 + <a href="{{ url_for('auth_logout') }}">logout</a> 23 23 </p> 24 24 25 25 <h2>profile</h2>
+2 -2
src/templates/login.html
··· 13 13 <h1>atlinks</h1> 14 14 <span class="tagline">log in to your account</span> 15 15 </header> 16 - <form action="/auth/login" method="post"> 16 + <form action="{{ url_for('auth_login') }}" method="post"> 17 17 <label> 18 18 <span>handle</span> 19 - <input type="text" name="handle" required /> 19 + <input type="text" name="username" required /> 20 20 </label> 21 21 <input type="submit" value="log in" /> 22 22 </form>