+19
requirements.txt
+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
+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
+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
+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>