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
add link sections
nauta.one
3 months ago
6b9d6b8b
639af6ec
+71
-39
5 changed files
expand all
collapse all
unified
split
src
main.py
oauth.py
static
profile
default.css
style.css
templates
profile.html
+50
-25
src/main.py
···
1
1
import asyncio
2
2
import json
3
3
+
from typing import Any, NamedTuple
3
4
4
4
-
from aiohttp.client import ClientResponse, ClientSession
5
5
-
from flask import Flask, g, session, redirect, render_template, request, url_for
6
6
-
from flask_htmx import HTMX, make_response as htmx_reponse
7
7
-
from typing import Any
5
5
+
from aiohttp.client import ClientSession
6
6
+
from flask import Flask, g, redirect, render_template, request, session, url_for
7
7
+
from flask_htmx import HTMX
8
8
+
from flask_htmx import make_response as htmx_response
8
9
9
10
from .atproto import (
10
11
PdsUrl,
···
25
26
htmx = HTMX()
26
27
htmx.init_app(app)
27
28
init_db(app)
28
28
-
29
29
-
SCHEMA = "at.ligo"
30
29
31
30
32
31
@app.before_request
···
74
73
pds = await resolve_pds_from_did(client, did=did, kv=pdskv, reload=reload)
75
74
if pds is None:
76
75
return render_template("error.html", message="pds not found"), 404
77
77
-
(profile, _), links = await asyncio.gather(
76
76
+
(profile, _), link_sections = await asyncio.gather(
78
77
load_profile(client, pds, did, reload=reload),
79
78
load_links(client, pds, did, reload=reload),
80
79
)
81
81
-
if links is None:
80
80
+
if profile is None or link_sections is None:
82
81
return render_template("error.html", message="profile not found"), 404
83
82
84
83
if reload:
85
84
# remove the ?reload parameter
86
85
return redirect(request.path)
87
86
88
88
-
profile["handle"] = handle
87
87
+
if handle:
88
88
+
profile["handle"] = handle
89
89
athref = f"at://{did}/at.ligo.actor.links/self"
90
90
return render_template(
91
91
"profile.html",
92
92
profile=profile,
93
93
-
links=links,
93
93
+
links=link_sections[0].links,
94
94
+
sections=link_sections,
94
95
athref=athref,
95
96
)
96
97
···
129
130
handle: str | None = user.handle
130
131
131
132
async with ClientSession() as client:
132
132
-
(profile, from_bluesky), links = await asyncio.gather(
133
133
+
(profile, from_bluesky), link_sections = await asyncio.gather(
133
134
load_profile(client, pds, did),
134
135
load_links(client, pds, did),
135
136
)
136
137
138
138
+
links = []
139
139
+
if link_sections:
140
140
+
links = link_sections[0].links
141
141
+
137
142
return render_template(
138
143
"editor.html",
139
144
handle=handle,
140
145
profile=profile,
141
146
profile_from_bluesky=from_bluesky,
142
142
-
links=json.dumps(links or []),
147
147
+
links=json.dumps(links),
143
148
)
144
149
145
150
···
155
160
return redirect("/editor", 303)
156
161
157
162
record = {
158
158
-
"$type": f"{SCHEMA}.actor.profile",
163
163
+
"$type": "at.ligo.actor.profile",
159
164
"displayName": display_name,
160
165
"description": description,
161
166
}
···
164
169
user=user,
165
170
pds=user.pds_url,
166
171
repo=user.did,
167
167
-
collection=f"{SCHEMA}.actor.profile",
172
172
+
collection="at.ligo.actor.profile",
168
173
rkey="self",
169
174
record=record,
170
175
)
···
174
179
kv.set(user.did, json.dumps(record))
175
180
176
181
if htmx:
177
177
-
return htmx_reponse(
182
182
+
return htmx_response(
178
183
render_template("_editor_profile.html", profile=record),
179
184
reswap="outerHTML",
180
185
)
···
206
211
links.append(link)
207
212
208
213
record = {
209
209
-
"$type": f"{SCHEMA}.actor.links",
210
210
-
"links": links,
214
214
+
"$type": "at.ligo.actor.links",
215
215
+
"sections": [
216
216
+
{
217
217
+
"title": "",
218
218
+
"links": links,
219
219
+
}
220
220
+
],
211
221
}
212
222
213
223
success = await put_record(
214
224
user=user,
215
225
pds=user.pds_url,
216
226
repo=user.did,
217
217
-
collection=f"{SCHEMA}.actor.links",
227
227
+
collection="at.ligo.actor.links",
218
228
rkey="self",
219
229
record=record,
220
230
)
···
224
234
kv.set(user.did, json.dumps(record))
225
235
226
236
if htmx:
227
227
-
return htmx_reponse(
228
228
-
render_template("_editor_links.html", links=record["links"]),
237
237
+
return htmx_response(
238
238
+
render_template("_editor_links.html", links=record["sections"][0]["links"]),
229
239
reswap="outerHTML",
230
240
)
231
241
···
237
247
return render_template("terms.html")
238
248
239
249
250
250
+
class LinkSection(NamedTuple):
251
251
+
title: str
252
252
+
links: list[dict[str, str]]
253
253
+
254
254
+
240
255
async def load_links(
241
256
client: ClientSession,
242
257
pds: str,
243
258
did: str,
244
259
reload: bool = False,
245
245
-
) -> list[dict[str, str]] | None:
260
260
+
) -> list[LinkSection] | None:
246
261
kv = KV(app, app.logger, "links_from_did")
247
262
record_json = kv.get(did)
248
263
249
264
if record_json is not None and not reload:
250
250
-
return json.loads(record_json)["links"]
265
265
+
parsed = json.loads(record_json)
266
266
+
return _links_or_sections(parsed)
251
267
252
252
-
record = await get_record(client, pds, did, f"{SCHEMA}.actor.links", "self")
268
268
+
record = await get_record(client, pds, did, "at.ligo.actor.links", "self")
253
269
if record is None:
254
270
return None
255
271
256
272
kv.set(did, value=json.dumps(record))
257
257
-
return record["links"]
273
273
+
return _links_or_sections(record)
274
274
+
275
275
+
276
276
+
def _links_or_sections(raw: dict[str, Any]) -> list[LinkSection] | None:
277
277
+
if "sections" in raw:
278
278
+
return list(map(lambda s: LinkSection(**s), raw["sections"]))
279
279
+
elif "links" in raw:
280
280
+
return [LinkSection("", raw["links"])]
281
281
+
else:
282
282
+
return None
258
283
259
284
260
285
async def load_profile(
···
271
296
return json.loads(record_json), False
272
297
273
298
(record, bsky_record) = await asyncio.gather(
274
274
-
get_record(client, pds, did, f"{SCHEMA}.actor.profile", "self"),
299
299
+
get_record(client, pds, did, "at.ligo.actor.profile", "self"),
275
300
get_record(client, pds, did, "app.bsky.actor.profile", "self"),
276
301
)
277
302
+11
-11
src/oauth.py
···
1
1
+
import json
2
2
+
from urllib.parse import urlencode
3
3
+
1
4
from aiohttp.client import ClientSession
2
5
from authlib.jose import JsonWebKey, Key
3
6
from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for
4
4
-
from urllib.parse import urlencode
5
5
-
6
6
-
import json
7
7
8
8
-
from .auth import (
9
9
-
delete_auth_request,
10
10
-
get_auth_request,
11
11
-
save_auth_request,
12
12
-
save_auth_session,
13
13
-
)
14
14
-
from .db import KV, get_db
15
8
from .atproto import (
9
9
+
fetch_authserver_meta,
16
10
is_valid_did,
17
11
is_valid_handle,
18
12
pds_endpoint_from_doc,
19
13
resolve_authserver_from_pds,
20
20
-
fetch_authserver_meta,
21
14
resolve_identity,
22
15
)
23
16
from .atproto.oauth import initial_token_request, send_par_auth_request
24
17
from .atproto.types import OAuthAuthRequest, OAuthSession
18
18
+
from .auth import (
19
19
+
delete_auth_request,
20
20
+
get_auth_request,
21
21
+
save_auth_request,
22
22
+
save_auth_session,
23
23
+
)
24
24
+
from .db import KV, get_db
25
25
from .security import is_safe_url
26
26
27
27
oauth = Blueprint("oauth", __name__, url_prefix="/oauth")
+2
-1
src/static/profile/default.css
···
36
36
37
37
.wrapper {
38
38
margin: auto;
39
39
-
max-width: 25em;
39
39
+
max-width: 20em;
40
40
}
41
41
42
42
.wrapper.profile {
···
63
63
.links-container ul {
64
64
list-style: none;
65
65
padding: 0;
66
66
+
margin: 2em 0;
66
67
}
67
68
68
69
.link-item {
+4
src/static/style.css
···
21
21
text-wrap: pretty;
22
22
}
23
23
24
24
+
.wrapper {
25
25
+
max-width: 25em;
26
26
+
}
27
27
+
24
28
.wrapper.error p {
25
29
text-align: center;
26
30
}
+4
-2
src/templates/profile.html
···
35
35
{% endif %}
36
36
</header>
37
37
<div class="links-container">
38
38
-
<ul>
39
39
-
{% for link in links %}
38
38
+
{% for section in sections %}
39
39
+
<ul class="link-section">
40
40
+
{% for link in section.links %}
40
41
<li style="color: {{ link.backgroundColor }}">
41
42
<a href="{{ link.href }}">
42
43
<div class="link-item">
···
49
50
</li>
50
51
{% endfor %}
51
52
</ul>
53
53
+
{% endfor %}
52
54
</div>
53
55
<footer>
54
56
made with <a href="https://ligo.at" target="_self">ligo.at</a>