From 4176e640a0074b81c0d815d19d1d499082534a09 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 02:37:59 +0000 Subject: [PATCH] feat: add Peer Files UI for browsing and downloading federated content - New PeerFiles.vue view shows federated peers and their shared catalogs - Peer Files card in Cloud.vue shows when federation peers exist - New content.download-peer RPC fetches content from peer via Tor - Route: /dashboard/cloud/peers Co-Authored-By: Claude Opus 4.6 --- core/archipelago/src/api/rpc/content.rs | 65 ++++++ core/archipelago/src/api/rpc/mod.rs | 1 + loop/plan.md | 2 +- neode-ui/src/router/index.ts | 5 + neode-ui/src/views/Cloud.vue | 47 ++++- neode-ui/src/views/PeerFiles.vue | 257 ++++++++++++++++++++++++ 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/PeerFiles.vue diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index a3865a42..1c7ee9b8 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -139,6 +139,71 @@ impl RpcHandler { Ok(serde_json::json!({ "updated": true })) } + /// Download content from a peer over Tor, returning base64-encoded data. + pub(super) async fn handle_content_download_peer( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + + if !onion.ends_with(".onion") || onion.len() < 10 { + return Err(anyhow::anyhow!("Invalid onion address")); + } + + let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050") + .context("Failed to create SOCKS proxy")?; + + let client = reqwest::Client::builder() + .proxy(socks_proxy) + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("Failed to build Tor HTTP client")?; + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + + let url = format!("http://{}/content/{}", onion, content_id); + let response = client + .get(&url) + .header("X-Federation-DID", &local_did) + .send() + .await + .context("Failed to connect to peer over Tor")?; + + if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { + let body: serde_json::Value = response.json().await.unwrap_or_default(); + return Ok(serde_json::json!({ + "error": "payment_required", + "price_sats": body.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(0), + })); + } + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Peer returned: {}", response.status())); + } + + let bytes = response + .bytes() + .await + .context("Failed to read response body")?; + + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + + Ok(serde_json::json!({ + "data": encoded, + "size": bytes.len(), + })) + } + /// Browse a peer's content catalog over Tor. pub(super) async fn handle_content_browse_peer( &self, diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 5f2dd831..0620c442 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -417,6 +417,7 @@ impl RpcHandler { "content.set-pricing" => self.handle_content_set_pricing(params).await, "content.set-availability" => self.handle_content_set_availability(params).await, "content.browse-peer" => self.handle_content_browse_peer(params).await, + "content.download-peer" => self.handle_content_download_peer(params).await, // DWN (Decentralized Web Node) "dwn.status" => self.handle_dwn_status().await, diff --git a/loop/plan.md b/loop/plan.md index e3a4f592..31e66eca 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -518,7 +518,7 @@ - [x] **SHARE-03** — Test file sharing at scale. Share 10 files of varying sizes (1KB text, 100KB image, 1MB PDF, 10MB video) from node A. Browse the catalog from nodes B, C, and D simultaneously. Download the 10MB file from all 3 nodes at once. Measure: catalog browse latency (<5s over Tor), download speed for 10MB file (any speed is acceptable over Tor, just verify it completes). Verify no corrupted transfers (checksum all downloads). **Acceptance**: All files transfer correctly to all 3 peers. No timeouts, no corruption. Document transfer speeds. -- [ ] **SHARE-04** — Add peer content browsing to Cloud UI. In `neode-ui/src/views/Cloud.vue`, add a "Peer Files" tab alongside Photos/Music/Documents/All Files. This tab shows a list of federated peers (from `federation.list-nodes`). Clicking a peer calls `content.browse-peer` with their onion address and displays their shared catalog in the same FileGrid component. Add a download button on each file that fetches the content over Tor and saves locally. Show loading state while Tor connection establishes (can take 5-10s). **Acceptance**: Can browse and download peer-shared files from the Cloud page. Deploy and verify. +- [x] **SHARE-04** — Add peer content browsing to Cloud UI. In `neode-ui/src/views/Cloud.vue`, add a "Peer Files" tab alongside Photos/Music/Documents/All Files. This tab shows a list of federated peers (from `federation.list-nodes`). Clicking a peer calls `content.browse-peer` with their onion address and displays their shared catalog in the same FileGrid component. Add a download button on each file that fetches the content over Tor and saves locally. Show loading state while Tor connection establishes (can take 5-10s). **Acceptance**: Can browse and download peer-shared files from the Cloud page. Deploy and verify. ### Sprint 45: DWN Multi-Node Sync (June 2026 Week 3-4) diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 71c0f0ea..608d2420 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -113,6 +113,11 @@ const router = createRouter({ name: 'cloud', component: () => import('../views/Cloud.vue'), }, + { + path: 'cloud/peers', + name: 'peer-files', + component: () => import('../views/PeerFiles.vue'), + }, { path: 'cloud/:folderId', name: 'cloud-folder', diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index 08170065..65e626ad 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -58,6 +58,36 @@ + +
+
+
+ + + +
+
+

Peer Files

+

Browse files shared by federated nodes

+
+ + + +
+
+ + + {{ peerCount }} peers + +
+
+

Install File Browser from the App Store to get started with your cloud storage.

@@ -73,11 +103,14 @@ import { computed, ref, onMounted } from 'vue' import { useRouter, RouterLink } from 'vue-router' import { useAppStore } from '../stores/app' import { fileBrowserClient } from '@/api/filebrowser-client' +import { rpcClient } from '@/api/rpc-client' const router = useRouter() const store = useAppStore() const sectionCounts = ref>({}) const countsLoading = ref(false) +const peerCount = ref(0) +const hasFederatedPeers = computed(() => peerCount.value > 0) const APP_ALIASES: Record = { immich: ['immich_server', 'immich-server'], @@ -182,7 +215,19 @@ async function loadCounts() { } } -onMounted(() => loadCounts()) +onMounted(() => { + loadCounts() + loadPeerCount() +}) + +async function loadPeerCount() { + try { + const result = await rpcClient.federationListNodes() + peerCount.value = result?.nodes?.length ?? 0 + } catch { + peerCount.value = 0 + } +} function openSection(section: ContentSection) { router.push({ name: 'cloud-folder', params: { folderId: section.id } }) diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue new file mode 100644 index 00000000..fec2033a --- /dev/null +++ b/neode-ui/src/views/PeerFiles.vue @@ -0,0 +1,257 @@ + + +