decentralized and customizable links page on top of atproto

login & edit profile

Changed files
+133 -3
src
+19
requirements.txt
··· 1 + annotated-types==0.7.0 2 + anyio==4.11.0 3 + atproto==0.0.62 1 4 blinker==1.9.0 5 + certifi==2025.10.5 6 + cffi==2.0.0 2 7 click==8.3.0 8 + cryptography==45.0.7 9 + dnspython==2.8.0 3 10 Flask==3.1.2 4 11 gunicorn==23.0.0 12 + h11==0.16.0 13 + httpcore==1.0.9 14 + httpx==0.28.1 15 + idna==3.10 5 16 itsdangerous==2.2.0 6 17 Jinja2==3.1.6 18 + libipld==3.1.1 7 19 MarkupSafe==3.0.3 8 20 packaging==25.0 21 + pycparser==2.23 22 + pydantic==2.11.10 23 + pydantic_core==2.33.2 24 + sniffio==1.3.1 25 + typing-inspection==0.4.2 26 + typing_extensions==4.15.0 27 + websockets==13.1 9 28 Werkzeug==3.1.3
+89 -3
src/main.py
··· 1 - from flask import Flask, redirect, render_template, request 1 + from atproto import Client 2 + from atproto.exceptions import AtProtocolError 3 + from atproto_client.models import ComAtprotoRepoCreateRecord 4 + from atproto_client.models.app.bsky.actor.defs import ProfileViewDetailed 5 + from flask import Flask, make_response, redirect, render_template, request 2 6 from urllib import request as http_request 3 7 import json 4 8 ··· 12 16 SCHEMA = "one.nauta" 13 17 14 18 15 - @app.route("/") 19 + @app.get("/") 16 20 def page_home(): 17 21 return render_template("index.html") 18 22 19 23 20 - @app.route("/<string:handle>") 24 + @app.get("/<string:handle>") 21 25 def page_profile(handle: str): 22 26 if handle == "favicon.ico": 23 27 return "not found", 404 ··· 42 46 return render_template("profile.html", profile=profile, links=links) 43 47 44 48 49 + @app.get("/editor") 50 + def page_editor(): 51 + session = request.cookies.get("session") 52 + if session is None or not session: 53 + return redirect("/") 54 + client = Client() 55 + profile: ProfileViewDetailed | None 56 + try: 57 + profile = client.login(session_string=session) 58 + except AtProtocolError: 59 + return redirect("/") 60 + 61 + pds = resolve_pds_from_did(profile.did) 62 + if not pds: 63 + return "did not found", 404 64 + pro = load_profile(pds, profile.did, reload=True) 65 + # links = load_links(pds, profile.did, reload=True) 66 + 67 + return render_template("editor.html", profile=pro) 68 + 69 + 70 + @app.post("/editor/profile") 71 + def post_editor(): 72 + display_name = request.form.get("displayName") 73 + description = request.form.get("description") 74 + if not display_name or not description: 75 + return redirect("/editor", 303) 76 + 77 + session = request.cookies.get("session") 78 + if session is None or not session: 79 + return redirect("/", 303) 80 + client = Client() 81 + profile = client.login(session_string=session) 82 + 83 + data_model = ComAtprotoRepoCreateRecord.Data( 84 + collection=f"{SCHEMA}.actor.profile", 85 + repo=profile.did, 86 + rkey="self", 87 + record={ 88 + "$type": f"{SCHEMA}.actor.profile", 89 + "displayName": display_name, 90 + "description": description, 91 + }, 92 + ) 93 + _ = client.invoke_procedure( 94 + "com.atproto.repo.putRecord", 95 + data=data_model, 96 + input_encoding="application/json", 97 + ) 98 + return redirect("/editor", 303) 99 + 100 + 45 101 def load_links(pds: str, did: str, reload: bool = False) -> list[dict[str, str]] | None: 46 102 if did in links and not reload: 47 103 app.logger.debug(f"returning cached links for {did}") ··· 125 181 return http_request.urlopen(url).read() 126 182 except http_request.HTTPError: 127 183 return None 184 + 185 + 186 + # AUTH 187 + 188 + 189 + @app.route("/auth/logout") 190 + def auth_logout(): 191 + r = make_response(redirect("/")) 192 + r.delete_cookie("session") 193 + return r 194 + 195 + 196 + @app.post("/auth/login") 197 + def auth_login(): 198 + handle = request.form.get("handle") 199 + password = request.form.get("password") 200 + if not handle or not password: 201 + return redirect("/") 202 + if handle.startswith("@"): 203 + handle = handle[1:] 204 + session_string: str | None 205 + try: 206 + client = Client() 207 + _ = client.login(handle, password) 208 + session_string = client.export_session_string() 209 + except AtProtocolError: 210 + return redirect("/", 303) 211 + r = make_response(redirect("/editor", code=303)) 212 + r.set_cookie("session", session_string) 213 + return r
+18
src/templates/editor.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <link rel="stylesheet" href="{{ url_for('static', filename='inter.css') }}" /> 6 + <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> 7 + </head> 8 + <body> 9 + profile 10 + <form action="/editor/profile" method="post"> 11 + <input type="text" name="displayName" value="{{ profile.0 }}" required /> 12 + <input type="text" name="description" value="{{ profile.1 }}" /> 13 + <button type="submit">save!</button> 14 + </form> 15 + 16 + <a href="/auth/logout">logout</a> 17 + </body> 18 + </html>
+7
src/templates/index.html
··· 20 20 <p> 21 21 Coming soon! 22 22 </p> 23 + 24 + <form action="/auth/login" method="post"> 25 + <input type="text" name="handle" placeholder="handle" required /> 26 + <input type="password" name="password" placeholder="app password" required /> 27 + <button type="submit">log in</button> 28 + </form> 29 + 23 30 <footer> 24 31 Made by <a href="/nauta.one">@nauta.one</a> 25 32 </footer>