tangled
alpha
login
or
join now
ligo.at
/
core
6
fork
atom
decentralized and customizable links page on top of atproto
ligo.at
atproto
link-in-bio
python
uv
6
fork
atom
overview
issues
2
pulls
pipelines
get_record async
nauta.one
5 months ago
77e3fdc5
704a37ae
+65
-45
5 changed files
expand all
collapse all
unified
split
pyproject.toml
src
atproto
__init__.py
db.py
main.py
uv.lock
+1
-1
pyproject.toml
···
7
7
dependencies = [
8
8
"authlib>=1.3",
9
9
"dnspython>=2.8.0",
10
10
-
"flask[dotenv]>=3.1.2",
10
10
+
"flask[async,dotenv]>=3.1.2",
11
11
"gunicorn>=23.0.0",
12
12
"httpx>=0.28.1",
13
13
]
+15
-12
src/atproto/__init__.py
···
194
194
return meta
195
195
196
196
197
197
-
def get_record(
197
197
+
async def get_record(
198
198
pds: str,
199
199
repo: str,
200
200
collection: str,
···
202
202
type: str | None = None,
203
203
) -> dict[str, Any] | None:
204
204
"""Retrieve record from PDS. Verifies type is the same as collection name."""
205
205
-
response = httpx.get(
206
206
-
f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}"
207
207
-
)
208
208
-
if not response.is_success:
209
209
-
return None
210
210
-
parsed = response.json()
211
211
-
value: dict[str, Any] = parsed["value"]
212
212
-
if value["$type"] != (type or collection):
213
213
-
return None
214
214
-
del value["$type"]
215
215
-
return value
205
205
+
206
206
+
async with httpx.AsyncClient() as client:
207
207
+
response = await client.get(
208
208
+
f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}"
209
209
+
)
210
210
+
if not response.is_success:
211
211
+
return None
212
212
+
parsed = response.json()
213
213
+
value: dict[str, Any] = parsed["value"]
214
214
+
if value["$type"] != (type or collection):
215
215
+
return None
216
216
+
del value["$type"]
217
217
+
218
218
+
return value
+1
-1
src/db.py
···
38
38
db: sqlite3.Connection | None = g.get("db", None)
39
39
if db is None:
40
40
db_path: str = app.config.get("DATABASE_URL", "ligoat.db")
41
41
-
db = g.db = sqlite3.connect(db_path)
41
41
+
db = g.db = sqlite3.connect(db_path, check_same_thread=False)
42
42
# return rows as dict-like objects
43
43
db.row_factory = sqlite3.Row
44
44
return db
+34
-29
src/main.py
···
1
1
+
import asyncio
2
2
+
import json
3
3
+
1
4
from flask import Flask, g, session, redirect, render_template, request, url_for
2
5
from typing import Any
3
3
-
import json
4
6
5
7
from .atproto import (
6
8
PdsUrl,
···
41
43
return render_template("index.html")
42
44
43
45
44
44
-
@app.get("/did:<string:did>")
45
45
-
def page_profile_with_did(did: str):
46
46
-
did = f"did:{did}"
47
47
-
if not is_valid_did(did):
48
48
-
return render_template("error.html", message="invalid did"), 400
49
49
-
return page_profile(did)
50
50
-
51
51
-
52
52
-
@app.get("/@<string:handle>")
53
53
-
def page_profile_with_handle(handle: str):
46
46
+
@app.get("/<string:atid>")
47
47
+
async def page_profile(atid: str):
54
48
reload = request.args.get("reload") is not None
55
55
-
kv = KV(app, "did_from_handle")
56
56
-
did = resolve_did_from_handle(handle, kv, reload=reload)
57
57
-
if did is None:
58
58
-
return render_template("error.html", message="did not found"), 404
59
59
-
return page_profile(did, reload=reload)
60
49
50
50
+
if atid.startswith("@"):
51
51
+
handle = atid[1:].lower()
52
52
+
did = resolve_did_from_handle(handle, reload=reload)
53
53
+
if did is None:
54
54
+
return render_template("error.html", message="did not found"), 404
55
55
+
elif is_valid_did(atid):
56
56
+
did = atid
57
57
+
else:
58
58
+
return render_template("error.html", message="invalid did or handle"), 400
61
59
62
62
-
def page_profile(did: str, reload: bool = False):
63
60
if _is_did_blocked(did):
64
64
-
app.logger.debug(f"handling blocked did {did}")
65
61
return render_template("error.html", message="profile not found"), 404
62
62
+
66
63
kv = KV(app, "pds_from_did")
67
64
pds = resolve_pds_from_did(did, kv, reload=reload)
68
65
if pds is None:
69
66
return render_template("error.html", message="pds not found"), 404
70
70
-
profile, _ = load_profile(pds, did, reload=reload)
71
71
-
links = load_links(pds, did, reload=reload)
67
67
+
(profile, _), links = await asyncio.gather(
68
68
+
load_profile(pds, did, reload=reload),
69
69
+
load_links(pds, did, reload=reload),
70
70
+
)
72
71
if links is None:
73
72
return render_template("error.html", message="profile not found"), 404
74
73
···
103
102
104
103
105
104
@app.get("/editor")
106
106
-
def page_editor():
105
105
+
async def page_editor():
107
106
user = get_user()
108
107
if user is None:
109
108
return redirect("/login")
···
112
111
pds: str = user.pds_url
113
112
handle: str | None = user.handle
114
113
115
115
-
profile, from_bluesky = load_profile(pds, did, reload=True)
116
116
-
links = load_links(pds, did, reload=True) or [{"background": "#fa0"}]
114
114
+
(profile, from_bluesky), links = await asyncio.gather(
115
115
+
load_profile(pds, did, reload=True),
116
116
+
load_links(pds, did, reload=True),
117
117
+
)
117
118
118
119
return render_template(
119
120
"editor.html",
120
121
handle=handle,
121
122
profile=profile,
122
123
profile_from_bluesky=from_bluesky,
123
123
-
links=json.dumps(links),
124
124
+
links=json.dumps(links or [{"background": "#fa0"}]),
124
125
)
125
126
126
127
···
195
196
return "come back soon"
196
197
197
198
198
198
-
def load_links(pds: str, did: str, reload: bool = False) -> list[dict[str, str]] | None:
199
199
+
async def load_links(
200
200
+
pds: str,
201
201
+
did: str,
202
202
+
reload: bool = False,
203
203
+
) -> list[dict[str, str]] | None:
199
204
kv = KV(app, "links_from_did")
200
205
links = kv.get(did)
201
206
···
203
208
app.logger.debug(f"returning cached links for {did}")
204
209
return json.loads(links)
205
210
206
206
-
record = get_record(pds, did, f"{SCHEMA}.actor.links", "self")
211
211
+
record = await get_record(pds, did, f"{SCHEMA}.actor.links", "self")
207
212
if record is None:
208
213
return None
209
214
···
213
218
return links
214
219
215
220
216
216
-
def load_profile(
221
221
+
async def load_profile(
217
222
pds: str,
218
223
did: str,
219
224
reload: bool = False,
···
226
231
return json.loads(profile), False
227
232
228
233
from_bluesky = False
229
229
-
record = get_record(pds, did, f"{SCHEMA}.actor.profile", "self")
234
234
+
record = await get_record(pds, did, f"{SCHEMA}.actor.profile", "self")
230
235
if record is None:
231
231
-
record = get_record(pds, did, "app.bsky.actor.profile", "self")
236
236
+
record = await get_record(pds, did, "app.bsky.actor.profile", "self")
232
237
from_bluesky = True
233
238
if record is None:
234
239
return None, False
+14
-2
uv.lock
···
16
16
]
17
17
18
18
[[package]]
19
19
+
name = "asgiref"
20
20
+
version = "3.10.0"
21
21
+
source = { registry = "https://pypi.org/simple" }
22
22
+
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
23
23
+
wheels = [
24
24
+
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
25
25
+
]
26
26
+
27
27
+
[[package]]
19
28
name = "authlib"
20
29
version = "1.6.5"
21
30
source = { registry = "https://pypi.org/simple" }
···
173
182
]
174
183
175
184
[package.optional-dependencies]
185
185
+
async = [
186
186
+
{ name = "asgiref" },
187
187
+
]
176
188
dotenv = [
177
189
{ name = "python-dotenv" },
178
190
]
···
263
275
dependencies = [
264
276
{ name = "authlib" },
265
277
{ name = "dnspython" },
266
266
-
{ name = "flask", extra = ["dotenv"] },
278
278
+
{ name = "flask", extra = ["async", "dotenv"] },
267
279
{ name = "gunicorn" },
268
280
{ name = "httpx" },
269
281
]
···
272
284
requires-dist = [
273
285
{ name = "authlib", specifier = ">=1.3" },
274
286
{ name = "dnspython", specifier = ">=2.8.0" },
275
275
-
{ name = "flask", extras = ["dotenv"], specifier = ">=3.1.2" },
287
287
+
{ name = "flask", extras = ["async", "dotenv"], specifier = ">=3.1.2" },
276
288
{ name = "gunicorn", specifier = ">=23.0.0" },
277
289
{ name = "httpx", specifier = ">=0.28.1" },
278
290
]