interactive intro to open social

refactor: move initialization and avatar logic to server-side

reduces client-side javascript by moving core business logic to rust:
- created /api/init endpoint for did resolution, pds extraction, and app grouping
- created /api/avatar endpoint for namespace avatar lookups
- simplified fetchAppAvatar from 30 lines to 8 lines
- removed duplicate avatar fetching logic
- app.js reduced from 760 to 714 lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+562 -171
src
static
+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
··· 17 17 hickory-resolver = "0.24" 18 18 env_logger = "0.11" 19 19 log = "0.4" 20 + reqwest = { version = "0.12", features = ["json"] }
+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
··· 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
··· 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
··· 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) {