+7
src/atproto2/__init__.py
+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
+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
+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
+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
+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
+1
-1
src/templates/editor.html
+2
-2
src/templates/login.html
+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>