+164
-11
Cargo.lock
+164
-11
Cargo.lock
···
62
62
"flate2",
63
63
"foldhash",
64
64
"futures-core",
65
-
"h2",
65
+
"h2 0.3.27",
66
66
"http 0.2.12",
67
67
"httparse",
68
68
"httpdate",
···
419
419
"env_logger",
420
420
"hickory-resolver",
421
421
"log",
422
+
"reqwest",
422
423
"serde",
423
424
"serde_json",
424
425
"tokio",
···
543
544
"miniz_oxide",
544
545
"object",
545
546
"rustc-demangle",
546
-
"windows-link",
547
+
"windows-link 0.2.0",
547
548
]
548
549
549
550
[[package]]
···
672
673
"num-traits",
673
674
"serde",
674
675
"wasm-bindgen",
675
-
"windows-link",
676
+
"windows-link 0.2.0",
676
677
]
677
678
678
679
[[package]]
···
1303
1304
]
1304
1305
1305
1306
[[package]]
1307
+
name = "h2"
1308
+
version = "0.4.12"
1309
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1310
+
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
1311
+
dependencies = [
1312
+
"atomic-waker",
1313
+
"bytes",
1314
+
"fnv",
1315
+
"futures-core",
1316
+
"futures-sink",
1317
+
"http 1.3.1",
1318
+
"indexmap",
1319
+
"slab",
1320
+
"tokio",
1321
+
"tokio-util",
1322
+
"tracing",
1323
+
]
1324
+
1325
+
[[package]]
1306
1326
name = "hashbrown"
1307
1327
version = "0.14.5"
1308
1328
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1467
1487
"bytes",
1468
1488
"futures-channel",
1469
1489
"futures-core",
1490
+
"h2 0.4.12",
1470
1491
"http 1.3.1",
1471
1492
"http-body",
1472
1493
"httparse",
···
1476
1497
"smallvec",
1477
1498
"tokio",
1478
1499
"want",
1500
+
]
1501
+
1502
+
[[package]]
1503
+
name = "hyper-rustls"
1504
+
version = "0.27.7"
1505
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1506
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
1507
+
dependencies = [
1508
+
"http 1.3.1",
1509
+
"hyper",
1510
+
"hyper-util",
1511
+
"rustls",
1512
+
"rustls-pki-types",
1513
+
"tokio",
1514
+
"tokio-rustls",
1515
+
"tower-service",
1479
1516
]
1480
1517
1481
1518
[[package]]
···
1513
1550
"percent-encoding",
1514
1551
"pin-project-lite",
1515
1552
"socket2 0.6.0",
1553
+
"system-configuration",
1516
1554
"tokio",
1517
1555
"tower-service",
1518
1556
"tracing",
1557
+
"windows-registry",
1519
1558
]
1520
1559
1521
1560
[[package]]
···
2143
2182
"libc",
2144
2183
"redox_syscall",
2145
2184
"smallvec",
2146
-
"windows-link",
2185
+
"windows-link 0.2.0",
2147
2186
]
2148
2187
2149
2188
[[package]]
···
2366
2405
"async-compression",
2367
2406
"base64 0.22.1",
2368
2407
"bytes",
2408
+
"encoding_rs",
2369
2409
"futures-core",
2370
2410
"futures-util",
2411
+
"h2 0.4.12",
2371
2412
"http 1.3.1",
2372
2413
"http-body",
2373
2414
"http-body-util",
2374
2415
"hyper",
2416
+
"hyper-rustls",
2375
2417
"hyper-tls",
2376
2418
"hyper-util",
2377
2419
"js-sys",
2378
2420
"log",
2421
+
"mime",
2379
2422
"native-tls",
2380
2423
"percent-encoding",
2381
2424
"pin-project-lite",
···
2413
2456
]
2414
2457
2415
2458
[[package]]
2459
+
name = "ring"
2460
+
version = "0.17.14"
2461
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2462
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2463
+
dependencies = [
2464
+
"cc",
2465
+
"cfg-if",
2466
+
"getrandom 0.2.16",
2467
+
"libc",
2468
+
"untrusted",
2469
+
"windows-sys 0.52.0",
2470
+
]
2471
+
2472
+
[[package]]
2416
2473
name = "rustc-demangle"
2417
2474
version = "0.1.26"
2418
2475
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2441
2498
]
2442
2499
2443
2500
[[package]]
2501
+
name = "rustls"
2502
+
version = "0.23.31"
2503
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2504
+
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
2505
+
dependencies = [
2506
+
"once_cell",
2507
+
"rustls-pki-types",
2508
+
"rustls-webpki",
2509
+
"subtle",
2510
+
"zeroize",
2511
+
]
2512
+
2513
+
[[package]]
2444
2514
name = "rustls-pki-types"
2445
2515
version = "1.12.0"
2446
2516
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2450
2520
]
2451
2521
2452
2522
[[package]]
2523
+
name = "rustls-webpki"
2524
+
version = "0.103.4"
2525
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2526
+
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
2527
+
dependencies = [
2528
+
"ring",
2529
+
"rustls-pki-types",
2530
+
"untrusted",
2531
+
]
2532
+
2533
+
[[package]]
2453
2534
name = "rustversion"
2454
2535
version = "1.0.22"
2455
2536
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2733
2814
"proc-macro2",
2734
2815
"quote",
2735
2816
"syn 2.0.106",
2817
+
]
2818
+
2819
+
[[package]]
2820
+
name = "system-configuration"
2821
+
version = "0.6.1"
2822
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2823
+
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
2824
+
dependencies = [
2825
+
"bitflags",
2826
+
"core-foundation",
2827
+
"system-configuration-sys",
2828
+
]
2829
+
2830
+
[[package]]
2831
+
name = "system-configuration-sys"
2832
+
version = "0.6.0"
2833
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2834
+
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
2835
+
dependencies = [
2836
+
"core-foundation-sys",
2837
+
"libc",
2736
2838
]
2737
2839
2738
2840
[[package]]
···
2872
2974
]
2873
2975
2874
2976
[[package]]
2977
+
name = "tokio-rustls"
2978
+
version = "0.26.2"
2979
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2980
+
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
2981
+
dependencies = [
2982
+
"rustls",
2983
+
"tokio",
2984
+
]
2985
+
2986
+
[[package]]
2875
2987
name = "tokio-util"
2876
2988
version = "0.7.16"
2877
2989
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3017
3129
version = "0.8.0"
3018
3130
source = "registry+https://github.com/rust-lang/crates.io-index"
3019
3131
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
3132
+
3133
+
[[package]]
3134
+
name = "untrusted"
3135
+
version = "0.9.0"
3136
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3137
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3020
3138
3021
3139
[[package]]
3022
3140
name = "url"
···
3210
3328
dependencies = [
3211
3329
"windows-implement",
3212
3330
"windows-interface",
3213
-
"windows-link",
3214
-
"windows-result",
3215
-
"windows-strings",
3331
+
"windows-link 0.2.0",
3332
+
"windows-result 0.4.0",
3333
+
"windows-strings 0.5.0",
3216
3334
]
3217
3335
3218
3336
[[package]]
···
3239
3357
3240
3358
[[package]]
3241
3359
name = "windows-link"
3360
+
version = "0.1.3"
3361
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3362
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
3363
+
3364
+
[[package]]
3365
+
name = "windows-link"
3242
3366
version = "0.2.0"
3243
3367
source = "registry+https://github.com/rust-lang/crates.io-index"
3244
3368
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
3245
3369
3246
3370
[[package]]
3371
+
name = "windows-registry"
3372
+
version = "0.5.3"
3373
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3374
+
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
3375
+
dependencies = [
3376
+
"windows-link 0.1.3",
3377
+
"windows-result 0.3.4",
3378
+
"windows-strings 0.4.2",
3379
+
]
3380
+
3381
+
[[package]]
3382
+
name = "windows-result"
3383
+
version = "0.3.4"
3384
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3385
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3386
+
dependencies = [
3387
+
"windows-link 0.1.3",
3388
+
]
3389
+
3390
+
[[package]]
3247
3391
name = "windows-result"
3248
3392
version = "0.4.0"
3249
3393
source = "registry+https://github.com/rust-lang/crates.io-index"
3250
3394
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
3251
3395
dependencies = [
3252
-
"windows-link",
3396
+
"windows-link 0.2.0",
3397
+
]
3398
+
3399
+
[[package]]
3400
+
name = "windows-strings"
3401
+
version = "0.4.2"
3402
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3403
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3404
+
dependencies = [
3405
+
"windows-link 0.1.3",
3253
3406
]
3254
3407
3255
3408
[[package]]
···
3258
3411
source = "registry+https://github.com/rust-lang/crates.io-index"
3259
3412
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
3260
3413
dependencies = [
3261
-
"windows-link",
3414
+
"windows-link 0.2.0",
3262
3415
]
3263
3416
3264
3417
[[package]]
···
3303
3456
source = "registry+https://github.com/rust-lang/crates.io-index"
3304
3457
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
3305
3458
dependencies = [
3306
-
"windows-link",
3459
+
"windows-link 0.2.0",
3307
3460
]
3308
3461
3309
3462
[[package]]
···
3343
3496
source = "registry+https://github.com/rust-lang/crates.io-index"
3344
3497
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
3345
3498
dependencies = [
3346
-
"windows-link",
3499
+
"windows-link 0.2.0",
3347
3500
"windows_aarch64_gnullvm 0.53.0",
3348
3501
"windows_aarch64_msvc 0.53.0",
3349
3502
"windows_i686_gnu 0.53.0",
+1
Cargo.toml
+1
Cargo.toml
+4
src/main.rs
+4
src/main.rs
···
2
2
use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web};
3
3
use actix_files::Files;
4
4
5
+
mod mst;
5
6
mod oauth;
6
7
mod routes;
7
8
mod templates;
···
36
37
.service(routes::client_metadata)
37
38
.service(routes::logout)
38
39
.service(routes::restore_session)
40
+
.service(routes::get_mst)
41
+
.service(routes::init)
42
+
.service(routes::get_avatar)
39
43
.service(routes::favicon)
40
44
.service(Files::new("/static", "./static"))
41
45
})
+164
src/mst.rs
+164
src/mst.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use std::collections::HashMap;
3
+
4
+
#[derive(Debug, Serialize, Deserialize, Clone)]
5
+
pub struct Record {
6
+
pub uri: String,
7
+
pub cid: String,
8
+
pub value: serde_json::Value,
9
+
}
10
+
11
+
#[derive(Debug, Serialize, Clone)]
12
+
#[serde(rename_all = "camelCase")]
13
+
pub struct MSTNode {
14
+
pub key: String,
15
+
pub cid: Option<String>,
16
+
pub uri: Option<String>,
17
+
pub value: Option<serde_json::Value>,
18
+
pub depth: i32,
19
+
pub children: Vec<MSTNode>,
20
+
}
21
+
22
+
#[derive(Debug, Serialize)]
23
+
#[serde(rename_all = "camelCase")]
24
+
pub struct MSTResponse {
25
+
pub root: MSTNode,
26
+
pub record_count: usize,
27
+
}
28
+
29
+
pub fn build_mst(records: Vec<Record>) -> MSTResponse {
30
+
let record_count = records.len();
31
+
32
+
// Extract and sort by key
33
+
let mut nodes: Vec<MSTNode> = records
34
+
.into_iter()
35
+
.map(|r| {
36
+
let key = r.uri.split('/').last().unwrap_or("").to_string();
37
+
MSTNode {
38
+
key: key.clone(),
39
+
cid: Some(r.cid),
40
+
uri: Some(r.uri),
41
+
value: Some(r.value),
42
+
depth: calculate_key_depth(&key),
43
+
children: vec![],
44
+
}
45
+
})
46
+
.collect();
47
+
48
+
nodes.sort_by(|a, b| a.key.cmp(&b.key));
49
+
50
+
// Build tree structure
51
+
let root = build_tree(nodes);
52
+
53
+
MSTResponse {
54
+
root,
55
+
record_count,
56
+
}
57
+
}
58
+
59
+
fn calculate_key_depth(key: &str) -> i32 {
60
+
// Simplified depth calculation based on key hash
61
+
let mut hash: i32 = 0;
62
+
for ch in key.chars() {
63
+
hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(ch as i32);
64
+
}
65
+
66
+
// Count leading zero bits (approximation)
67
+
let abs_hash = hash.abs() as u32;
68
+
let binary = format!("{:032b}", abs_hash);
69
+
70
+
let mut depth = 0;
71
+
let chars: Vec<char> = binary.chars().collect();
72
+
let mut i = 0;
73
+
while i < chars.len() - 1 {
74
+
if chars[i] == '0' && chars[i + 1] == '0' {
75
+
depth += 1;
76
+
i += 2;
77
+
} else {
78
+
break;
79
+
}
80
+
}
81
+
82
+
depth.min(5)
83
+
}
84
+
85
+
fn build_tree(nodes: Vec<MSTNode>) -> MSTNode {
86
+
if nodes.is_empty() {
87
+
return MSTNode {
88
+
key: "root".to_string(),
89
+
cid: None,
90
+
uri: None,
91
+
value: None,
92
+
depth: -1,
93
+
children: vec![],
94
+
};
95
+
}
96
+
97
+
// Group by depth
98
+
let mut by_depth: HashMap<i32, Vec<MSTNode>> = HashMap::new();
99
+
for node in nodes {
100
+
by_depth.entry(node.depth).or_insert_with(Vec::new).push(node);
101
+
}
102
+
103
+
let mut depths: Vec<i32> = by_depth.keys().copied().collect();
104
+
depths.sort();
105
+
106
+
// Build tree bottom-up
107
+
let mut current_level: Vec<MSTNode> = by_depth.remove(&depths[depths.len() - 1]).unwrap_or_default();
108
+
109
+
// Work backwards through depths
110
+
for i in (0..depths.len() - 1).rev() {
111
+
let depth = depths[i];
112
+
let mut parent_nodes = by_depth.remove(&depth).unwrap_or_default();
113
+
114
+
// Distribute children to parents
115
+
let children_per_parent = if parent_nodes.is_empty() {
116
+
0
117
+
} else {
118
+
(current_level.len() + parent_nodes.len() - 1) / parent_nodes.len()
119
+
};
120
+
121
+
for (i, parent) in parent_nodes.iter_mut().enumerate() {
122
+
let start = i * children_per_parent;
123
+
let end = ((i + 1) * children_per_parent).min(current_level.len());
124
+
if start < current_level.len() {
125
+
parent.children = current_level.drain(start..end).collect();
126
+
}
127
+
}
128
+
129
+
current_level = parent_nodes;
130
+
}
131
+
132
+
// Create root and attach top-level nodes
133
+
MSTNode {
134
+
key: "root".to_string(),
135
+
cid: None,
136
+
uri: None,
137
+
value: None,
138
+
depth: -1,
139
+
children: current_level,
140
+
}
141
+
}
142
+
143
+
pub async fn fetch_records(pds: &str, did: &str, collection: &str) -> Result<Vec<Record>, String> {
144
+
let url = format!(
145
+
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
146
+
pds, did, collection
147
+
);
148
+
149
+
let response = reqwest::get(&url)
150
+
.await
151
+
.map_err(|e| format!("Failed to fetch records: {}", e))?;
152
+
153
+
#[derive(Deserialize)]
154
+
struct ListRecordsResponse {
155
+
records: Vec<Record>,
156
+
}
157
+
158
+
let data: ListRecordsResponse = response
159
+
.json()
160
+
.await
161
+
.map_err(|e| format!("Failed to parse response: {}", e))?;
162
+
163
+
Ok(data.records)
164
+
}
+195
src/routes.rs
+195
src/routes.rs
···
3
3
use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope};
4
4
use serde::Deserialize;
5
5
6
+
use crate::mst;
6
7
use crate::oauth::OAuthClientType;
7
8
use crate::templates;
8
9
···
151
152
.content_type("image/svg+xml")
152
153
.body(FAVICON_SVG)
153
154
}
155
+
156
+
#[derive(Deserialize)]
157
+
pub struct MSTQuery {
158
+
pds: String,
159
+
did: String,
160
+
collection: String,
161
+
}
162
+
163
+
#[get("/api/mst")]
164
+
pub async fn get_mst(query: web::Query<MSTQuery>) -> HttpResponse {
165
+
match mst::fetch_records(&query.pds, &query.did, &query.collection).await {
166
+
Ok(records) => {
167
+
if records.is_empty() {
168
+
return HttpResponse::Ok().json(serde_json::json!({
169
+
"error": "no records found"
170
+
}));
171
+
}
172
+
173
+
let mst_data = mst::build_mst(records);
174
+
HttpResponse::Ok().json(mst_data)
175
+
}
176
+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
177
+
"error": e
178
+
})),
179
+
}
180
+
}
181
+
182
+
#[derive(Deserialize)]
183
+
pub struct InitQuery {
184
+
did: String,
185
+
}
186
+
187
+
#[derive(serde::Serialize)]
188
+
#[serde(rename_all = "camelCase")]
189
+
pub struct AppInfo {
190
+
namespace: String,
191
+
collections: Vec<String>,
192
+
}
193
+
194
+
#[derive(serde::Serialize)]
195
+
#[serde(rename_all = "camelCase")]
196
+
pub struct InitResponse {
197
+
did: String,
198
+
handle: String,
199
+
pds: String,
200
+
avatar: Option<String>,
201
+
apps: Vec<AppInfo>,
202
+
}
203
+
204
+
#[get("/api/init")]
205
+
pub async fn init(query: web::Query<InitQuery>) -> HttpResponse {
206
+
let did = &query.did;
207
+
208
+
// Fetch DID document
209
+
let did_doc_url = format!("https://plc.directory/{}", did);
210
+
let did_doc_response = match reqwest::get(&did_doc_url).await {
211
+
Ok(r) => r,
212
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
213
+
"error": format!("failed to fetch DID document: {}", e)
214
+
})),
215
+
};
216
+
217
+
let did_doc: serde_json::Value = match did_doc_response.json().await {
218
+
Ok(d) => d,
219
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
220
+
"error": format!("failed to parse DID document: {}", e)
221
+
})),
222
+
};
223
+
224
+
// Extract PDS and handle
225
+
let pds = did_doc["service"]
226
+
.as_array()
227
+
.and_then(|services| {
228
+
services.iter().find(|s| {
229
+
s["type"].as_str() == Some("AtprotoPersonalDataServer")
230
+
})
231
+
})
232
+
.and_then(|s| s["serviceEndpoint"].as_str())
233
+
.unwrap_or("")
234
+
.to_string();
235
+
236
+
let handle = did_doc["alsoKnownAs"]
237
+
.as_array()
238
+
.and_then(|aka| aka.get(0))
239
+
.and_then(|v| v.as_str())
240
+
.map(|s| s.replace("at://", ""))
241
+
.unwrap_or_else(|| did.to_string());
242
+
243
+
// Fetch user avatar from Bluesky
244
+
let avatar = fetch_user_avatar(did).await;
245
+
246
+
// Fetch collections from PDS
247
+
let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did);
248
+
let repo_response = match reqwest::get(&repo_url).await {
249
+
Ok(r) => r,
250
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
251
+
"error": format!("failed to fetch repo: {}", e)
252
+
})),
253
+
};
254
+
255
+
let repo_data: serde_json::Value = match repo_response.json().await {
256
+
Ok(d) => d,
257
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
258
+
"error": format!("failed to parse repo: {}", e)
259
+
})),
260
+
};
261
+
262
+
let collections = repo_data["collections"]
263
+
.as_array()
264
+
.map(|arr| {
265
+
arr.iter()
266
+
.filter_map(|v| v.as_str().map(String::from))
267
+
.collect::<Vec<String>>()
268
+
})
269
+
.unwrap_or_default();
270
+
271
+
// Group by namespace
272
+
let mut apps: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
273
+
for collection in collections {
274
+
let parts: Vec<&str> = collection.split('.').collect();
275
+
if parts.len() >= 2 {
276
+
let namespace = format!("{}.{}", parts[0], parts[1]);
277
+
apps.entry(namespace)
278
+
.or_insert_with(Vec::new)
279
+
.push(collection);
280
+
}
281
+
}
282
+
283
+
let apps_list: Vec<AppInfo> = apps
284
+
.into_iter()
285
+
.map(|(namespace, collections)| AppInfo { namespace, collections })
286
+
.collect();
287
+
288
+
HttpResponse::Ok().json(InitResponse {
289
+
did: did.to_string(),
290
+
handle,
291
+
pds,
292
+
avatar,
293
+
apps: apps_list,
294
+
})
295
+
}
296
+
297
+
async fn fetch_user_avatar(did: &str) -> Option<String> {
298
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
299
+
if let Ok(response) = reqwest::get(&profile_url).await {
300
+
if let Ok(profile) = response.json::<serde_json::Value>().await {
301
+
return profile["avatar"].as_str().map(String::from);
302
+
}
303
+
}
304
+
None
305
+
}
306
+
307
+
#[derive(Deserialize)]
308
+
pub struct AvatarQuery {
309
+
namespace: String,
310
+
}
311
+
312
+
#[get("/api/avatar")]
313
+
pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse {
314
+
let namespace = &query.namespace;
315
+
316
+
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
317
+
let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join(".");
318
+
let handles = vec![
319
+
reversed.clone(),
320
+
format!("{}.bsky.social", reversed),
321
+
];
322
+
323
+
for handle in handles {
324
+
// Try to resolve handle to DID
325
+
let resolve_url = format!("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", handle);
326
+
if let Ok(response) = reqwest::get(&resolve_url).await {
327
+
if let Ok(data) = response.json::<serde_json::Value>().await {
328
+
if let Some(did) = data["did"].as_str() {
329
+
// Try to get profile
330
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
331
+
if let Ok(profile_response) = reqwest::get(&profile_url).await {
332
+
if let Ok(profile) = profile_response.json::<serde_json::Value>().await {
333
+
if let Some(avatar) = profile["avatar"].as_str() {
334
+
return HttpResponse::Ok().json(serde_json::json!({
335
+
"avatarUrl": avatar
336
+
}));
337
+
}
338
+
}
339
+
}
340
+
}
341
+
}
342
+
}
343
+
}
344
+
345
+
HttpResponse::Ok().json(serde_json::json!({
346
+
"avatarUrl": null
347
+
}))
348
+
}
+34
-160
static/app.js
+34
-160
static/app.js
···
5
5
let globalPds = null;
6
6
let globalHandle = null;
7
7
8
-
// Try to fetch app avatar from their bsky profile
8
+
// Fetch app avatar from server
9
9
async function fetchAppAvatar(namespace) {
10
10
try {
11
-
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
12
-
const reversed = namespace.split('.').reverse().join('.');
13
-
// Try reversed domain, then reversed.bsky.social
14
-
const handles = [reversed, `${reversed}.bsky.social`];
15
-
16
-
for (const handle of handles) {
17
-
try {
18
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
19
-
if (!didRes.ok) continue;
20
-
21
-
const { did } = await didRes.json();
22
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
23
-
if (!profileRes.ok) continue;
24
-
25
-
const profile = await profileRes.json();
26
-
if (profile.avatar) {
27
-
return profile.avatar;
28
-
}
29
-
} catch (e) {
30
-
// Silently continue to next handle
31
-
continue;
32
-
}
33
-
}
11
+
const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`);
12
+
const data = await response.json();
13
+
return data.avatarUrl;
34
14
} catch (e) {
35
-
// Expected for namespaces without Bluesky accounts
15
+
return null;
36
16
}
37
-
return null;
38
17
}
39
18
40
19
// Logout handler
···
62
41
detail.classList.remove('visible');
63
42
});
64
43
65
-
// First resolve DID to get PDS endpoint and handle
66
-
fetch('https://plc.directory/' + did)
44
+
// Fetch initialization data from server
45
+
fetch(`/api/init?did=${encodeURIComponent(did)}`)
67
46
.then(r => r.json())
68
-
.then(didDoc => {
69
-
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
70
-
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
71
-
72
-
globalPds = pds;
73
-
globalHandle = handle;
47
+
.then(initData => {
48
+
globalPds = initData.pds;
49
+
globalHandle = initData.handle;
74
50
75
51
// Update identity display with handle
76
-
document.getElementById('handle').textContent = handle;
77
-
78
-
// Try to fetch and display user's avatar
79
-
fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
80
-
.then(r => r.json())
81
-
.then(profile => {
82
-
if (profile.avatar) {
83
-
const identity = document.querySelector('.identity');
84
-
const avatarImg = document.createElement('img');
85
-
avatarImg.src = profile.avatar;
86
-
avatarImg.className = 'identity-avatar';
87
-
avatarImg.alt = handle;
88
-
// Insert avatar before the @ label
89
-
identity.insertBefore(avatarImg, identity.firstChild);
90
-
}
91
-
})
92
-
.catch(() => {
93
-
// User may not have an avatar set
94
-
});
52
+
document.getElementById('handle').textContent = initData.handle;
95
53
96
-
// Store collections and apps for later use
97
-
let allCollections = [];
98
-
let apps = {};
99
-
100
-
// Get all collections from PDS
101
-
return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`);
102
-
})
103
-
.then(r => r.json())
104
-
.then(repo => {
105
-
const collections = repo.collections || [];
106
-
allCollections = collections;
54
+
// Display user's avatar if available
55
+
if (initData.avatar) {
56
+
const identity = document.querySelector('.identity');
57
+
const avatarImg = document.createElement('img');
58
+
avatarImg.src = initData.avatar;
59
+
avatarImg.className = 'identity-avatar';
60
+
avatarImg.alt = initData.handle;
61
+
// Insert avatar before the @ label
62
+
identity.insertBefore(avatarImg, identity.firstChild);
63
+
}
107
64
108
-
// Group by app namespace (first two parts of lexicon)
109
-
apps = {};
110
-
collections.forEach(collection => {
111
-
const parts = collection.split('.');
112
-
if (parts.length >= 2) {
113
-
const namespace = `${parts[0]}.${parts[1]}`;
114
-
if (!apps[namespace]) apps[namespace] = [];
115
-
apps[namespace].push(collection);
116
-
}
65
+
// Convert apps array to object for easier access
66
+
const apps = {};
67
+
const allCollections = [];
68
+
initData.apps.forEach(app => {
69
+
apps[app.namespace] = app.collections;
70
+
allCollections.push(...app.collections);
117
71
});
118
72
119
73
// Add identity click handler now that we have the data
···
494
448
// MST Visualization Functions
495
449
async function loadMSTStructure(lexicon, containerView) {
496
450
try {
497
-
// Fetch records (up to 100 for visualization)
498
-
const response = await fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=100`);
451
+
// Call server endpoint to build MST
452
+
const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`);
499
453
const data = await response.json();
500
454
501
-
if (!data.records || data.records.length === 0) {
502
-
containerView.innerHTML = '<div class="mst-info"><p>no records to visualize</p></div>';
455
+
if (data.error) {
456
+
containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`;
503
457
return;
504
458
}
505
459
506
-
// Extract record keys (rkeys) and keep full record data
507
-
const records = data.records.map(r => ({
508
-
key: r.uri.split('/').pop(),
509
-
cid: r.cid,
510
-
uri: r.uri,
511
-
value: r.value
512
-
}));
513
-
514
-
// Build simplified MST
515
-
const mst = buildSimplifiedMST(records);
460
+
const { root, recordCount } = data;
516
461
517
462
// Render structure
518
463
containerView.innerHTML = `
519
464
<div class="mst-info">
520
-
<p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${records.length} record${records.length !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p>
465
+
<p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${recordCount} record${recordCount !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p>
521
466
</div>
522
467
<canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas>
523
468
`;
···
526
471
setTimeout(() => {
527
472
const canvas = containerView.querySelector('.mst-canvas');
528
473
if (canvas) {
529
-
renderMSTTree(canvas, mst);
474
+
renderMSTTree(canvas, root);
530
475
}
531
476
}, 50);
532
477
···
534
479
console.error('Error loading MST structure:', e);
535
480
containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>';
536
481
}
537
-
}
538
-
539
-
function buildSimplifiedMST(records) {
540
-
// Sort records by key (TIDs are lexicographically sortable)
541
-
records.sort((a, b) => a.key.localeCompare(b.key));
542
-
543
-
// Calculate depth for each key and keep full record
544
-
const nodes = records.map(r => ({
545
-
key: r.key,
546
-
cid: r.cid,
547
-
uri: r.uri,
548
-
value: r.value,
549
-
depth: calculateKeyDepth(r.key)
550
-
}));
551
-
552
-
// Build tree structure
553
-
return buildTree(nodes);
554
-
}
555
-
556
-
function calculateKeyDepth(key) {
557
-
// Simplified depth calculation based on key hash
558
-
let hash = 0;
559
-
for (let i = 0; i < key.length; i++) {
560
-
hash = ((hash << 5) - hash) + key.charCodeAt(i);
561
-
hash = hash & hash;
562
-
}
563
-
564
-
// Count leading zero bits (approximation)
565
-
const absHash = Math.abs(hash);
566
-
const binary = absHash.toString(2).padStart(32, '0');
567
-
568
-
let depth = 0;
569
-
for (let i = 0; i < binary.length; i += 2) {
570
-
if (binary.substr(i, 2) === '00') {
571
-
depth++;
572
-
} else {
573
-
break;
574
-
}
575
-
}
576
-
577
-
return Math.min(depth, 5); // Cap at depth 5
578
-
}
579
-
580
-
function buildTree(nodes) {
581
-
// Build a simple tree structure for visualization
582
-
const root = { depth: -1, children: [], key: 'root', cid: null };
583
-
584
-
const byDepth = {};
585
-
nodes.forEach(node => {
586
-
if (!byDepth[node.depth]) byDepth[node.depth] = [];
587
-
byDepth[node.depth].push(node);
588
-
});
589
-
590
-
// Create hierarchical structure
591
-
let currentLevel = [root];
592
-
Object.keys(byDepth).sort((a, b) => parseInt(a) - parseInt(b)).forEach(depth => {
593
-
const nodesAtDepth = byDepth[depth];
594
-
const nextLevel = [];
595
-
596
-
nodesAtDepth.forEach((node, idx) => {
597
-
const parentIdx = Math.floor(idx / 2) % currentLevel.length;
598
-
const parent = currentLevel[parentIdx];
599
-
if (!parent.children) parent.children = [];
600
-
parent.children.push(node);
601
-
nextLevel.push(node);
602
-
});
603
-
604
-
currentLevel = nextLevel;
605
-
});
606
-
607
-
return root;
608
482
}
609
483
610
484
function renderMSTTree(canvas, tree) {