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 2 3 3 4 4 5 + let globalPds = null; 6 + let globalHandle = null; 5 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 + } 6 18 19 + // Logout handler 7 20 8 21 9 22 ··· 25 38 26 39 27 40 41 + detail.classList.remove('visible'); 42 + }); 28 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; 29 50 51 + // Update identity display with handle 52 + document.getElementById('handle').textContent = initData.handle; 30 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 + } 31 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 + }); 32 72 73 + // Add identity click handler now that we have the data 33 74 34 75 35 76 ··· 107 148 108 149 109 150 151 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 110 152 153 + // Reverse namespace for display (app.bsky -> bsky.app) 154 + const displayName = namespace.split('.').reverse().join('.'); 155 + const url = `https://${displayName}`; 111 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 + `; 112 161 162 + // Try to fetch and display avatar 113 163 114 164 115 165 116 166 167 + } 168 + }); 117 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 + }); 118 184 185 + div.addEventListener('click', () => { 186 + const detail = document.getElementById('detail'); 187 + const collections = apps[namespace]; 119 188 120 189 121 190 ··· 208 277 209 278 210 279 280 + item.addEventListener('click', (e) => { 281 + e.stopPropagation(); 282 + const lexicon = item.dataset.lexicon; 283 + const existingContent = item.querySelector('.collection-content'); 211 284 285 + if (existingContent) { 286 + existingContent.remove(); 287 + return; 288 + } 212 289 290 + // Create container for tabs and content 291 + const contentDiv = document.createElement('div'); 292 + contentDiv.className = 'collection-content'; 213 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); 214 306 307 + const recordsView = contentDiv.querySelector('.records-view'); 308 + const structureView = contentDiv.querySelector('.structure-view'); 215 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); 216 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; 217 330 331 + // Update active tab 332 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 333 + tab.classList.add('active'); 218 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 + } 219 349 350 + if (data.records && data.records.length > 0) { 351 + let recordsHtml = ''; 352 + data.records.forEach((record, idx) => { 220 353 221 354 222 355 ··· 233 366 234 367 235 368 369 + recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 370 + } 236 371 372 + recordsView.innerHTML = recordsHtml; 237 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(); 238 379 239 380 240 381 ··· 282 423 283 424 284 425 426 + }); 285 427 428 + loadMoreBtn.remove(); 429 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 286 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 + } 287 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 + }); 288 449 289 450 290 451 ··· 299 460 300 461 301 462 463 + document.getElementById('field').innerHTML = 'error loading records'; 464 + console.error(e); 465 + }); 302 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(); 303 473 474 + if (data.error) { 475 + containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`; 476 + return; 477 + } 304 478 479 + const { root, recordCount } = data; 305 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 + `; 306 488 307 - item.addEventListener('click', (e) => { 308 - e.stopPropagation(); 309 - const lexicon = item.dataset.lexicon; 310 - const existingRecords = item.querySelector('.record-list'); 489 + // Render tree on canvas 490 + setTimeout(() => { 491 + const canvas = containerView.querySelector('.mst-canvas'); 492 + if (canvas) { 493 + renderMSTTree(canvas, root); 494 + } 495 + }, 50); 311 496 312 - if (existingRecords) { 313 - existingRecords.remove(); 314 - return; 315 - } 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 + } 316 502 317 - const recordListDiv = document.createElement('div'); 318 - recordListDiv.className = 'record-list'; 319 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 320 - item.appendChild(recordListDiv); 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; 321 507 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) => { 508 + // Calculate tree layout 509 + const layout = layoutTree(tree, width, height); 328 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(); 329 518 519 + let hoveredNode = null; 330 520 521 + function draw() { 522 + // Clear canvas 523 + ctx.clearRect(0, 0, width, height); 331 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 + }); 332 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; 333 544 545 + // Node circle 546 + ctx.beginPath(); 547 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 334 548 549 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 550 + ctx.fill(); 335 551 552 + ctx.strokeStyle = isHovered ? textColor : borderColor; 553 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 554 + ctx.stroke(); 555 + }); 336 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; 337 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; 338 569 570 + // Draw tooltip background 571 + ctx.fillStyle = bgColor; 572 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 339 573 574 + // Draw tooltip border 575 + ctx.strokeStyle = borderColor; 576 + ctx.lineWidth = 1; 577 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 340 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 + } 341 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; 342 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 + } 343 603 344 - recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 345 - } 604 + if (foundNode !== hoveredNode) { 605 + hoveredNode = foundNode; 606 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 607 + draw(); 608 + } 609 + }); 346 610 347 - recordListDiv.innerHTML = recordsHtml; 611 + // Mouse leave handler 612 + canvas.addEventListener('mouseleave', () => { 613 + if (hoveredNode) { 614 + hoveredNode = null; 615 + canvas.style.cursor = 'default'; 616 + draw(); 617 + } 618 + }); 348 619 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(); 620 + // Click handler 621 + canvas.addEventListener('click', (e) => { 622 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 623 + showNodeModal(hoveredNode); 624 + } 625 + }); 354 626 627 + // Initial draw 628 + draw(); 629 + } 355 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 + `; 356 666 667 + // Add to DOM 668 + document.body.appendChild(modal); 357 669 670 + // Close handlers 671 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 672 + modal.remove(); 673 + }); 358 674 675 + modal.addEventListener('click', (e) => { 676 + if (e.target === modal) { 677 + modal.remove(); 678 + } 679 + }); 680 + } 359 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; 360 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); 361 698 699 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 700 + const verticalSpacing = availableHeight / (maxDepth + 1); 362 701 702 + // Track positions at each depth to avoid overlap 703 + const positionsByDepth = {}; 363 704 705 + function traverse(node, depth, minX, maxX) { 706 + if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 364 707 708 + // Calculate position based on available space 709 + const x = (minX + maxX) / 2; 710 + const y = padding + verticalSpacing * depth; 365 711 712 + const layoutNode = { ...node, x, y }; 713 + nodes.push(layoutNode); 714 + positionsByDepth[depth].push(x); 366 715 716 + if (node.children && node.children.length > 0) { 717 + layoutNode.children = []; 718 + const childWidth = (maxX - minX) / node.children.length; 367 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 + } 368 727 728 + return layoutNode; 729 + } 369 730 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 - }); 731 + traverse(tree, 0, padding, width - padding); 732 + return nodes; 733 + }
+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]] ··· 1302 1303 "tracing", 1303 1304 ] 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 + 1305 1325 [[package]] 1306 1326 name = "hashbrown" 1307 1327 version = "0.14.5" ··· 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", ··· 1478 1499 "want", 1479 1500 ] 1480 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 + 1481 1518 [[package]] 1482 1519 name = "hyper-tls" 1483 1520 version = "0.6.0" ··· 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", ··· 2412 2455 "subtle", 2413 2456 ] 2414 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 + 2415 2472 [[package]] 2416 2473 name = "rustc-demangle" 2417 2474 version = "0.1.26" ··· 2440 2497 "windows-sys 0.61.1", 2441 2498 ] 2442 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 + 2443 2513 [[package]] 2444 2514 name = "rustls-pki-types" 2445 2515 version = "1.12.0" ··· 2449 2519 "zeroize", 2450 2520 ] 2451 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 + 2452 2533 [[package]] 2453 2534 name = "rustversion" 2454 2535 version = "1.0.22" ··· 2735 2816 "syn 2.0.106", 2736 2817 ] 2737 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 + 2738 2840 [[package]] 2739 2841 name = "tagptr" 2740 2842 version = "0.2.0" ··· 2871 2973 "tokio", 2872 2974 ] 2873 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 + 2874 2986 [[package]] 2875 2987 name = "tokio-util" 2876 2988 version = "0.7.16" ··· 3018 3130 source = "registry+https://github.com/rust-lang/crates.io-index" 3019 3131 checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3020 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 + 3021 3139 [[package]] 3022 3140 name = "url" 3023 3141 version = "2.5.7" ··· 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]] ··· 3237 3355 "syn 2.0.106", 3238 3356 ] 3239 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 + 3240 3364 [[package]] 3241 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 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 + 3246 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"] }
+5
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) 43 + .service(routes::validate_url) 39 44 .service(routes::favicon) 40 45 .service(Files::new("/static", "./static")) 41 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 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 ··· 150 151 151 152 .content_type("image/svg+xml") 152 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 + })) 153 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