interactive intro to open social at-me.zzstoatzz.io

a bunch of quality of life + MST viz #8

merged opened by zzstoatzz.io targeting main from feat/url-validation

bunch of things

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xbtmt2zjwlrfegqvch7fboei/sh.tangled.repo.pull/3m2zjvvxuwg22
+963 -108
Diff #0
+390 -97
static/app.js
··· 2 3 4 5 6 7 8 9 ··· 25 26 27 28 29 30 31 32 33 34 35 ··· 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 ··· 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 ··· 233 234 235 236 237 238 239 240 ··· 282 283 284 285 286 287 288 289 290 ··· 299 300 301 302 303 304 305 306 307 - item.addEventListener('click', (e) => { 308 - e.stopPropagation(); 309 - const lexicon = item.dataset.lexicon; 310 - const existingRecords = item.querySelector('.record-list'); 311 312 - if (existingRecords) { 313 - existingRecords.remove(); 314 - return; 315 - } 316 317 - const recordListDiv = document.createElement('div'); 318 - recordListDiv.className = 'record-list'; 319 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 320 - item.appendChild(recordListDiv); 321 322 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`) 323 - .then(r => r.json()) 324 - .then(data => { 325 - if (data.records && data.records.length > 0) { 326 - let recordsHtml = ''; 327 - data.records.forEach((record, idx) => { 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 - recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 345 - } 346 347 - recordListDiv.innerHTML = recordsHtml; 348 349 - // Use event delegation for copy and load more buttons 350 - recordListDiv.addEventListener('click', (e) => { 351 - // Handle copy button 352 - if (e.target.classList.contains('copy-btn')) { 353 - e.stopPropagation(); 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 - 371 - 372 - 373 - 374 - 375 - 376 - 377 - 378 - 379 - 380 - 381 - 382 - 383 - 384 - 385 - 386 - 387 - 388 - 389 - 390 - 391 - 392 - 393 - 394 - 395 - 396 - 397 - 398 - 399 - 400 - 401 - }); 402 - 403 - loadMoreBtn.remove(); 404 - recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 405 - 406 - if (moreData.cursor && moreData.records.length === 5) { 407 - recordListDiv.insertAdjacentHTML('beforeend', 408 - `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 409 - ); 410 - } 411 - 412 - } 413 - }); 414 - } else { 415 - recordListDiv.innerHTML = '<div class="record">no records found</div>'; 416 - } 417 - }) 418 - .catch(e => { 419 - console.error('Error fetching records:', e); 420 - recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 421 - }); 422 - }); 423 - }); 424 - 425 - 426 - 427 - 428 - 429 - 430 - 431 - 432 - 433 - 434 - 435 - 436 - 437 - 438 - document.getElementById('field').innerHTML = 'error loading records'; 439 - console.error(e); 440 - });
··· 2 3 4 5 + let globalPds = null; 6 + let globalHandle = null; 7 8 + // Fetch app avatar from server 9 + async function fetchAppAvatar(namespace) { 10 + try { 11 + const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`); 12 + const data = await response.json(); 13 + return data.avatarUrl; 14 + } catch (e) { 15 + return null; 16 + } 17 + } 18 19 + // Logout handler 20 21 22 ··· 38 39 40 41 + detail.classList.remove('visible'); 42 + }); 43 44 + // Fetch initialization data from server 45 + fetch(`/api/init?did=${encodeURIComponent(did)}`) 46 + .then(r => r.json()) 47 + .then(initData => { 48 + globalPds = initData.pds; 49 + globalHandle = initData.handle; 50 51 + // Update identity display with handle 52 + document.getElementById('handle').textContent = initData.handle; 53 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 + } 64 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); 71 + }); 72 73 + // Add identity click handler now that we have the data 74 75 76 ··· 148 149 150 151 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 152 153 + // Reverse namespace for display (app.bsky -> bsky.app) 154 + const displayName = namespace.split('.').reverse().join('.'); 155 + const url = `https://${displayName}`; 156 157 + div.innerHTML = ` 158 + <div class="app-circle" data-namespace="${namespace}">${firstLetter}</div> 159 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName}</a> 160 + `; 161 162 + // Try to fetch and display avatar 163 164 165 166 167 + } 168 + }); 169 170 + // Validate URL 171 + fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 172 + .then(r => r.json()) 173 + .then(data => { 174 + const link = div.querySelector('.app-name'); 175 + if (!data.valid) { 176 + link.classList.add('invalid-link'); 177 + link.setAttribute('title', 'this domain is not reachable'); 178 + link.style.pointerEvents = 'none'; 179 + } 180 + }) 181 + .catch(() => { 182 + // Silently fail validation check 183 + }); 184 185 + div.addEventListener('click', () => { 186 + const detail = document.getElementById('detail'); 187 + const collections = apps[namespace]; 188 189 190 ··· 277 278 279 280 + item.addEventListener('click', (e) => { 281 + e.stopPropagation(); 282 + const lexicon = item.dataset.lexicon; 283 + const existingContent = item.querySelector('.collection-content'); 284 285 + if (existingContent) { 286 + existingContent.remove(); 287 + return; 288 + } 289 290 + // Create container for tabs and content 291 + const contentDiv = document.createElement('div'); 292 + contentDiv.className = 'collection-content'; 293 294 + // Will add tabs after we know record count 295 + contentDiv.innerHTML = ` 296 + <div class="collection-view-content"> 297 + <div class="collection-view records-view active"> 298 + <div class="loading">loading records...</div> 299 + </div> 300 + <div class="collection-view structure-view"> 301 + <div class="loading">loading structure...</div> 302 + </div> 303 + </div> 304 + `; 305 + item.appendChild(contentDiv); 306 307 + const recordsView = contentDiv.querySelector('.records-view'); 308 + const structureView = contentDiv.querySelector('.structure-view'); 309 310 + // Load records first to determine if we should show structure tab 311 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 312 + .then(r => r.json()) 313 + .then(data => { 314 + // Add tabs if there are enough records for structure view 315 + const hasEnoughRecords = data.records && data.records.length >= 5; 316 + if (hasEnoughRecords) { 317 + const tabsHtml = ` 318 + <div class="collection-tabs"> 319 + <button class="collection-tab active" data-tab="records">records</button> 320 + <button class="collection-tab" data-tab="structure">mst</button> 321 + </div> 322 + `; 323 + contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 324 325 + // Tab switching logic 326 + contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 327 + tab.addEventListener('click', (e) => { 328 + e.stopPropagation(); 329 + const tabName = tab.dataset.tab; 330 331 + // Update active tab 332 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 333 + tab.classList.add('active'); 334 335 + // Update active view 336 + contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 337 + if (tabName === 'records') { 338 + recordsView.classList.add('active'); 339 + } else if (tabName === 'structure') { 340 + structureView.classList.add('active'); 341 + // Load structure if not already loaded 342 + if (structureView.querySelector('.loading')) { 343 + loadMSTStructure(lexicon, structureView); 344 + } 345 + } 346 + }); 347 + }); 348 + } 349 350 + if (data.records && data.records.length > 0) { 351 + let recordsHtml = ''; 352 + data.records.forEach((record, idx) => { 353 354 355 ··· 366 367 368 369 + recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 370 + } 371 372 + recordsView.innerHTML = recordsHtml; 373 374 + // Use event delegation for copy and load more buttons 375 + recordsView.addEventListener('click', (e) => { 376 + // Handle copy button 377 + if (e.target.classList.contains('copy-btn')) { 378 + e.stopPropagation(); 379 380 381 ··· 423 424 425 426 + }); 427 428 + loadMoreBtn.remove(); 429 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 430 431 + if (moreData.cursor && moreData.records.length === 5) { 432 + recordsView.insertAdjacentHTML('beforeend', 433 + `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 434 + ); 435 + } 436 437 + } 438 + }); 439 + } else { 440 + recordsView.innerHTML = '<div class="record">no records found</div>'; 441 + } 442 + }) 443 + .catch(e => { 444 + console.error('Error fetching records:', e); 445 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 446 + }); 447 + }); 448 + }); 449 450 451 ··· 460 461 462 463 + document.getElementById('field').innerHTML = 'error loading records'; 464 + console.error(e); 465 + }); 466 467 + // MST Visualization Functions 468 + async function loadMSTStructure(lexicon, containerView) { 469 + try { 470 + // Call server endpoint to build MST 471 + const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`); 472 + const data = await response.json(); 473 474 + if (data.error) { 475 + containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`; 476 + return; 477 + } 478 479 + const { root, recordCount } = data; 480 481 + // Render structure 482 + containerView.innerHTML = ` 483 + <div class="mst-info"> 484 + <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> 485 + </div> 486 + <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 487 + `; 488 489 + // Render tree on canvas 490 + setTimeout(() => { 491 + const canvas = containerView.querySelector('.mst-canvas'); 492 + if (canvas) { 493 + renderMSTTree(canvas, root); 494 + } 495 + }, 50); 496 497 + } catch (e) { 498 + console.error('Error loading MST structure:', e); 499 + containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 500 + } 501 + } 502 503 + function renderMSTTree(canvas, tree) { 504 + const ctx = canvas.getContext('2d'); 505 + const width = canvas.width = canvas.offsetWidth; 506 + const height = canvas.height = canvas.offsetHeight; 507 508 + // Calculate tree layout 509 + const layout = layoutTree(tree, width, height); 510 511 + // Get CSS colors 512 + const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 513 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 514 + const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim(); 515 + const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 516 + const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 517 + const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 518 519 + let hoveredNode = null; 520 521 + function draw() { 522 + // Clear canvas 523 + ctx.clearRect(0, 0, width, height); 524 525 + // Draw connections first 526 + layout.forEach(node => { 527 + if (node.children) { 528 + node.children.forEach(child => { 529 + ctx.beginPath(); 530 + ctx.moveTo(node.x, node.y); 531 + ctx.lineTo(child.x, child.y); 532 + ctx.strokeStyle = borderColor; 533 + ctx.lineWidth = 1; 534 + ctx.stroke(); 535 + }); 536 + } 537 + }); 538 539 + // Draw nodes 540 + layout.forEach(node => { 541 + const isRoot = node.depth === -1; 542 + const isLeaf = !node.children || node.children.length === 0; 543 + const isHovered = hoveredNode === node; 544 545 + // Node circle 546 + ctx.beginPath(); 547 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 548 549 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 550 + ctx.fill(); 551 552 + ctx.strokeStyle = isHovered ? textColor : borderColor; 553 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 554 + ctx.stroke(); 555 + }); 556 557 + // Draw label for hovered node 558 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 559 + const padding = 6; 560 + const fontSize = 10; 561 + ctx.font = `${fontSize}px monospace`; 562 + const textWidth = ctx.measureText(hoveredNode.key).width; 563 564 + // Position tooltip above node 565 + const tooltipX = hoveredNode.x; 566 + const tooltipY = hoveredNode.y - 20; 567 + const boxWidth = textWidth + padding * 2; 568 + const boxHeight = fontSize + padding * 2; 569 570 + // Draw tooltip background 571 + ctx.fillStyle = bgColor; 572 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 573 574 + // Draw tooltip border 575 + ctx.strokeStyle = borderColor; 576 + ctx.lineWidth = 1; 577 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 578 579 + // Draw text 580 + ctx.fillStyle = textColor; 581 + ctx.textAlign = 'center'; 582 + ctx.textBaseline = 'middle'; 583 + ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 584 + } 585 + } 586 587 + // Mouse move handler 588 + canvas.addEventListener('mousemove', (e) => { 589 + const rect = canvas.getBoundingClientRect(); 590 + const mouseX = e.clientX - rect.left; 591 + const mouseY = e.clientY - rect.top; 592 593 + let foundNode = null; 594 + for (const node of layout) { 595 + const isRoot = node.depth === -1; 596 + const radius = isRoot ? 12 : 8; 597 + const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 598 + if (dist <= radius) { 599 + foundNode = node; 600 + break; 601 + } 602 + } 603 604 + if (foundNode !== hoveredNode) { 605 + hoveredNode = foundNode; 606 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 607 + draw(); 608 + } 609 + }); 610 611 + // Mouse leave handler 612 + canvas.addEventListener('mouseleave', () => { 613 + if (hoveredNode) { 614 + hoveredNode = null; 615 + canvas.style.cursor = 'default'; 616 + draw(); 617 + } 618 + }); 619 620 + // Click handler 621 + canvas.addEventListener('click', (e) => { 622 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 623 + showNodeModal(hoveredNode); 624 + } 625 + }); 626 627 + // Initial draw 628 + draw(); 629 + } 630 631 + function showNodeModal(node) { 632 + // Create modal 633 + const modal = document.createElement('div'); 634 + modal.className = 'mst-node-modal'; 635 + modal.innerHTML = ` 636 + <div class="mst-node-modal-content"> 637 + <button class="mst-node-close">ร—</button> 638 + <h3>record in MST</h3> 639 + <div class="mst-node-info"> 640 + <div class="mst-node-field"> 641 + <span class="mst-node-label">TID:</span> 642 + <span class="mst-node-value">${node.key}</span> 643 + </div> 644 + <div class="mst-node-field"> 645 + <span class="mst-node-label">CID:</span> 646 + <span class="mst-node-value">${node.cid}</span> 647 + </div> 648 + ${node.uri ? ` 649 + <div class="mst-node-field"> 650 + <span class="mst-node-label">URI:</span> 651 + <span class="mst-node-value">${node.uri}</span> 652 + </div> 653 + ` : ''} 654 + </div> 655 + <div class="mst-node-explanation"> 656 + <p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p> 657 + </div> 658 + ${node.value ? ` 659 + <div class="mst-node-data"> 660 + <div class="mst-node-data-header">record data</div> 661 + <pre>${JSON.stringify(node.value, null, 2)}</pre> 662 + </div> 663 + ` : ''} 664 + </div> 665 + `; 666 667 + // Add to DOM 668 + document.body.appendChild(modal); 669 670 + // Close handlers 671 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 672 + modal.remove(); 673 + }); 674 675 + modal.addEventListener('click', (e) => { 676 + if (e.target === modal) { 677 + modal.remove(); 678 + } 679 + }); 680 + } 681 682 + function layoutTree(tree, width, height) { 683 + const nodes = []; 684 + const padding = 40; 685 + const availableWidth = width - padding * 2; 686 + const availableHeight = height - padding * 2; 687 688 + // Calculate max depth and total nodes at each depth 689 + const depthCounts = {}; 690 + function countDepths(node, depth) { 691 + if (!depthCounts[depth]) depthCounts[depth] = 0; 692 + depthCounts[depth]++; 693 + if (node.children) { 694 + node.children.forEach(child => countDepths(child, depth + 1)); 695 + } 696 + } 697 + countDepths(tree, 0); 698 699 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 700 + const verticalSpacing = availableHeight / (maxDepth + 1); 701 702 + // Track positions at each depth to avoid overlap 703 + const positionsByDepth = {}; 704 705 + function traverse(node, depth, minX, maxX) { 706 + if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 707 708 + // Calculate position based on available space 709 + const x = (minX + maxX) / 2; 710 + const y = padding + verticalSpacing * depth; 711 712 + const layoutNode = { ...node, x, y }; 713 + nodes.push(layoutNode); 714 + positionsByDepth[depth].push(x); 715 716 + if (node.children && node.children.length > 0) { 717 + layoutNode.children = []; 718 + const childWidth = (maxX - minX) / node.children.length; 719 720 + node.children.forEach((child, idx) => { 721 + const childMinX = minX + childWidth * idx; 722 + const childMaxX = minX + childWidth * (idx + 1); 723 + const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 724 + layoutNode.children.push(childLayout); 725 + }); 726 + } 727 728 + return layoutNode; 729 + } 730 731 + traverse(tree, 0, padding, width - padding); 732 + return nodes; 733 + }
+164 -11
Cargo.lock
··· 62 "flate2", 63 "foldhash", 64 "futures-core", 65 - "h2", 66 "http 0.2.12", 67 "httparse", 68 "httpdate", ··· 419 "env_logger", 420 "hickory-resolver", 421 "log", 422 "serde", 423 "serde_json", 424 "tokio", ··· 543 "miniz_oxide", 544 "object", 545 "rustc-demangle", 546 - "windows-link", 547 ] 548 549 [[package]] ··· 672 "num-traits", 673 "serde", 674 "wasm-bindgen", 675 - "windows-link", 676 ] 677 678 [[package]] ··· 1302 "tracing", 1303 ] 1304 1305 [[package]] 1306 name = "hashbrown" 1307 version = "0.14.5" ··· 1467 "bytes", 1468 "futures-channel", 1469 "futures-core", 1470 "http 1.3.1", 1471 "http-body", 1472 "httparse", ··· 1478 "want", 1479 ] 1480 1481 [[package]] 1482 name = "hyper-tls" 1483 version = "0.6.0" ··· 1513 "percent-encoding", 1514 "pin-project-lite", 1515 "socket2 0.6.0", 1516 "tokio", 1517 "tower-service", 1518 "tracing", 1519 ] 1520 1521 [[package]] ··· 2143 "libc", 2144 "redox_syscall", 2145 "smallvec", 2146 - "windows-link", 2147 ] 2148 2149 [[package]] ··· 2366 "async-compression", 2367 "base64 0.22.1", 2368 "bytes", 2369 "futures-core", 2370 "futures-util", 2371 "http 1.3.1", 2372 "http-body", 2373 "http-body-util", 2374 "hyper", 2375 "hyper-tls", 2376 "hyper-util", 2377 "js-sys", 2378 "log", 2379 "native-tls", 2380 "percent-encoding", 2381 "pin-project-lite", ··· 2412 "subtle", 2413 ] 2414 2415 [[package]] 2416 name = "rustc-demangle" 2417 version = "0.1.26" ··· 2440 "windows-sys 0.61.1", 2441 ] 2442 2443 [[package]] 2444 name = "rustls-pki-types" 2445 version = "1.12.0" ··· 2449 "zeroize", 2450 ] 2451 2452 [[package]] 2453 name = "rustversion" 2454 version = "1.0.22" ··· 2735 "syn 2.0.106", 2736 ] 2737 2738 [[package]] 2739 name = "tagptr" 2740 version = "0.2.0" ··· 2871 "tokio", 2872 ] 2873 2874 [[package]] 2875 name = "tokio-util" 2876 version = "0.7.16" ··· 3018 source = "registry+https://github.com/rust-lang/crates.io-index" 3019 checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3020 3021 [[package]] 3022 name = "url" 3023 version = "2.5.7" ··· 3210 dependencies = [ 3211 "windows-implement", 3212 "windows-interface", 3213 - "windows-link", 3214 - "windows-result", 3215 - "windows-strings", 3216 ] 3217 3218 [[package]] ··· 3237 "syn 2.0.106", 3238 ] 3239 3240 [[package]] 3241 name = "windows-link" 3242 version = "0.2.0" 3243 source = "registry+https://github.com/rust-lang/crates.io-index" 3244 checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 3245 3246 [[package]] 3247 name = "windows-result" 3248 version = "0.4.0" 3249 source = "registry+https://github.com/rust-lang/crates.io-index" 3250 checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 3251 dependencies = [ 3252 - "windows-link", 3253 ] 3254 3255 [[package]] ··· 3258 source = "registry+https://github.com/rust-lang/crates.io-index" 3259 checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 3260 dependencies = [ 3261 - "windows-link", 3262 ] 3263 3264 [[package]] ··· 3303 source = "registry+https://github.com/rust-lang/crates.io-index" 3304 checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 3305 dependencies = [ 3306 - "windows-link", 3307 ] 3308 3309 [[package]] ··· 3343 source = "registry+https://github.com/rust-lang/crates.io-index" 3344 checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 3345 dependencies = [ 3346 - "windows-link", 3347 "windows_aarch64_gnullvm 0.53.0", 3348 "windows_aarch64_msvc 0.53.0", 3349 "windows_i686_gnu 0.53.0",
··· 62 "flate2", 63 "foldhash", 64 "futures-core", 65 + "h2 0.3.27", 66 "http 0.2.12", 67 "httparse", 68 "httpdate", ··· 419 "env_logger", 420 "hickory-resolver", 421 "log", 422 + "reqwest", 423 "serde", 424 "serde_json", 425 "tokio", ··· 544 "miniz_oxide", 545 "object", 546 "rustc-demangle", 547 + "windows-link 0.2.0", 548 ] 549 550 [[package]] ··· 673 "num-traits", 674 "serde", 675 "wasm-bindgen", 676 + "windows-link 0.2.0", 677 ] 678 679 [[package]] ··· 1303 "tracing", 1304 ] 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]] 1326 name = "hashbrown" 1327 version = "0.14.5" ··· 1487 "bytes", 1488 "futures-channel", 1489 "futures-core", 1490 + "h2 0.4.12", 1491 "http 1.3.1", 1492 "http-body", 1493 "httparse", ··· 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", 1516 + ] 1517 + 1518 [[package]] 1519 name = "hyper-tls" 1520 version = "0.6.0" ··· 1550 "percent-encoding", 1551 "pin-project-lite", 1552 "socket2 0.6.0", 1553 + "system-configuration", 1554 "tokio", 1555 "tower-service", 1556 "tracing", 1557 + "windows-registry", 1558 ] 1559 1560 [[package]] ··· 2182 "libc", 2183 "redox_syscall", 2184 "smallvec", 2185 + "windows-link 0.2.0", 2186 ] 2187 2188 [[package]] ··· 2405 "async-compression", 2406 "base64 0.22.1", 2407 "bytes", 2408 + "encoding_rs", 2409 "futures-core", 2410 "futures-util", 2411 + "h2 0.4.12", 2412 "http 1.3.1", 2413 "http-body", 2414 "http-body-util", 2415 "hyper", 2416 + "hyper-rustls", 2417 "hyper-tls", 2418 "hyper-util", 2419 "js-sys", 2420 "log", 2421 + "mime", 2422 "native-tls", 2423 "percent-encoding", 2424 "pin-project-lite", ··· 2455 "subtle", 2456 ] 2457 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]] 2473 name = "rustc-demangle" 2474 version = "0.1.26" ··· 2497 "windows-sys 0.61.1", 2498 ] 2499 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]] 2514 name = "rustls-pki-types" 2515 version = "1.12.0" ··· 2519 "zeroize", 2520 ] 2521 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]] 2534 name = "rustversion" 2535 version = "1.0.22" ··· 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", 2838 + ] 2839 + 2840 [[package]] 2841 name = "tagptr" 2842 version = "0.2.0" ··· 2973 "tokio", 2974 ] 2975 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]] 2987 name = "tokio-util" 2988 version = "0.7.16" ··· 3130 source = "registry+https://github.com/rust-lang/crates.io-index" 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" 3138 + 3139 [[package]] 3140 name = "url" 3141 version = "2.5.7" ··· 3328 dependencies = [ 3329 "windows-implement", 3330 "windows-interface", 3331 + "windows-link 0.2.0", 3332 + "windows-result 0.4.0", 3333 + "windows-strings 0.5.0", 3334 ] 3335 3336 [[package]] ··· 3355 "syn 2.0.106", 3356 ] 3357 3358 + [[package]] 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" 3366 version = "0.2.0" 3367 source = "registry+https://github.com/rust-lang/crates.io-index" 3368 checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 3369 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]] 3391 name = "windows-result" 3392 version = "0.4.0" 3393 source = "registry+https://github.com/rust-lang/crates.io-index" 3394 checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 3395 dependencies = [ 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", 3406 ] 3407 3408 [[package]] ··· 3411 source = "registry+https://github.com/rust-lang/crates.io-index" 3412 checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 3413 dependencies = [ 3414 + "windows-link 0.2.0", 3415 ] 3416 3417 [[package]] ··· 3456 source = "registry+https://github.com/rust-lang/crates.io-index" 3457 checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 3458 dependencies = [ 3459 + "windows-link 0.2.0", 3460 ] 3461 3462 [[package]] ··· 3496 source = "registry+https://github.com/rust-lang/crates.io-index" 3497 checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 3498 dependencies = [ 3499 + "windows-link 0.2.0", 3500 "windows_aarch64_gnullvm 0.53.0", 3501 "windows_aarch64_msvc 0.53.0", 3502 "windows_i686_gnu 0.53.0",
+1
Cargo.toml
··· 17 hickory-resolver = "0.24" 18 env_logger = "0.11" 19 log = "0.4"
··· 17 hickory-resolver = "0.24" 18 env_logger = "0.11" 19 log = "0.4" 20 + reqwest = { version = "0.12", features = ["json"] }
+5
src/main.rs
··· 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 use actix_files::Files; 4 5 mod oauth; 6 mod routes; 7 mod templates; ··· 36 .service(routes::client_metadata) 37 .service(routes::logout) 38 .service(routes::restore_session) 39 .service(routes::favicon) 40 .service(Files::new("/static", "./static")) 41 })
··· 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 use actix_files::Files; 4 5 + mod mst; 6 mod oauth; 7 mod routes; 8 mod templates; ··· 37 .service(routes::client_metadata) 38 .service(routes::logout) 39 .service(routes::restore_session) 40 + .service(routes::get_mst) 41 + .service(routes::init) 42 + .service(routes::get_avatar) 43 + .service(routes::validate_url) 44 .service(routes::favicon) 45 .service(Files::new("/static", "./static")) 46 })
+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 + }
+239
src/routes.rs
··· 3 use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 4 use serde::Deserialize; 5 6 use crate::oauth::OAuthClientType; 7 use crate::templates; 8 ··· 150 151 .content_type("image/svg+xml") 152 .body(FAVICON_SVG) 153 }
··· 3 use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 4 use serde::Deserialize; 5 6 + use crate::mst; 7 use crate::oauth::OAuthClientType; 8 use crate::templates; 9 ··· 151 152 .content_type("image/svg+xml") 153 .body(FAVICON_SVG) 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 + } 349 + 350 + #[derive(Deserialize)] 351 + pub struct ValidateUrlQuery { 352 + url: String, 353 + } 354 + 355 + #[get("/api/validate-url")] 356 + pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse { 357 + let url = &query.url; 358 + 359 + // Build client with redirect following and timeout 360 + let client = reqwest::Client::builder() 361 + .timeout(std::time::Duration::from_secs(3)) 362 + .redirect(reqwest::redirect::Policy::limited(5)) 363 + .build() 364 + .unwrap(); 365 + 366 + // Try HEAD first, fall back to GET if HEAD doesn't succeed 367 + let is_valid = match client.head(url).send().await { 368 + Ok(response) => { 369 + let status = response.status(); 370 + if status.is_success() || status.is_redirection() { 371 + true 372 + } else { 373 + // HEAD returned error status (like 405), try GET 374 + match client.get(url).send().await { 375 + Ok(get_response) => get_response.status().is_success(), 376 + Err(_) => false, 377 + } 378 + } 379 + } 380 + Err(_) => { 381 + // HEAD request failed completely, try GET as fallback 382 + match client.get(url).send().await { 383 + Ok(response) => response.status().is_success(), 384 + Err(_) => false, 385 + } 386 + } 387 + }; 388 + 389 + HttpResponse::Ok().json(serde_json::json!({ 390 + "valid": is_valid 391 + })) 392 }

History

1 round 1 comment
sign up or login to add to the discussion
zzstoatzz.io submitted #0
5 commits
expand
feat: add MST visualization with interactive hover and click
refactor: move initialization and avatar logic to server-side
feat: fix namespace display and add clickable app links
feat: add URL validation for app namespace links
fix: improve URL validation with GET fallback for HEAD method errors
expand 1 comment
pull request successfully merged