diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 4267a81b..63ac10e7 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -348,10 +348,11 @@ impl ApiHandler { async fn handle_content_catalog(config: &Config) -> Result> { match content_server::load_catalog(&config.data_dir).await { Ok(catalog) => { - // Only expose public metadata, not file paths + // Only expose public metadata for available items let items: Vec = catalog .items .iter() + .filter(|i| !matches!(i.availability, content_server::Availability::Nobody)) .map(|i| { serde_json::json!({ "id": i.id, diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index fbb3f64d..a3865a42 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -31,7 +31,7 @@ impl RpcHandler { .and_then(|v| v.as_str()) .unwrap_or(""); - let item = ContentItem { + let mut item = ContentItem { id: uuid::Uuid::new_v4().to_string(), filename: filename.to_string(), mime_type: mime_type.to_string(), @@ -42,6 +42,12 @@ impl RpcHandler { added_at: chrono::Utc::now().to_rfc3339(), }; + // Resolve actual file size from disk + let file_path = content_server::content_file_path(&self.config.data_dir, &item); + if let Ok(metadata) = std::fs::metadata(&file_path) { + item.size_bytes = metadata.len(); + } + content_server::add_item(&self.config.data_dir, item.clone()).await?; Ok(serde_json::json!({ "item": item })) } diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index d51d7fe5..f8a1fdce 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -89,8 +89,26 @@ pub async fn save_catalog(data_dir: &Path, catalog: &ContentCatalog) -> Result<( } /// Get the full filesystem path for a content item. +/// Checks the dedicated content/files/ directory first, then falls back to the +/// FileBrowser data directory (where users manage files via the web UI). pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf { - data_dir.join(CONTENT_DIR).join(&item.filename) + // Strip leading slash from filename for path joining + let clean_name = item.filename.trim_start_matches('/'); + + // Primary: dedicated content directory + let primary = data_dir.join(CONTENT_DIR).join(clean_name); + if primary.exists() { + return primary; + } + + // Fallback: FileBrowser data directory (users share files managed via FileBrowser) + let fb_path = data_dir.join("filebrowser").join(clean_name); + if fb_path.exists() { + return fb_path; + } + + // Return primary path even if it doesn't exist (caller checks existence) + primary } /// Add a content item to the catalog. diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index eade7f46..5d32c982 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -138,6 +138,22 @@ server { # CORS handled by backend } + # Content sharing — peer access over Tor (no auth) + location /content { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # DWN endpoints — peer access over Tor (no auth) + location /dwn { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy apps that set X-Frame-Options - strip header so iframe works location /app/nextcloud/ { proxy_pass http://127.0.0.1:8085/; @@ -672,6 +688,22 @@ server { # CORS handled by backend } + # Content sharing — peer access over Tor (no auth) + location /content { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # DWN endpoints — peer access over Tor (no auth) + location /dwn { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /rpc/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; diff --git a/loop/plan.md b/loop/plan.md index c57573db..c15e8330 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -512,7 +512,7 @@ ### Sprint 44: File Sharing Across Nodes (June 2026 Week 1-2) -- [ ] **SHARE-01** — Test content sharing between two federated nodes. On node A (192.168.1.228): upload a test file to FileBrowser, then call `content.add` with the filename to share it. Call `content.set-pricing` with `access: "free"`. Call `content.set-availability` with `availability: "all_peers"`. On node B (192.168.1.198): call `content.browse-peer` with node A's onion address. Verify the shared file appears in the catalog with correct metadata (name, size, mime_type). Download the file via the content server's HTTP endpoint over Tor. Compare checksums. **Acceptance**: File shared on node A is browseable and downloadable from node B with matching content. If `browse-peer` fails, debug: check Tor SOCKS proxy, check content server HTTP handler is listening, check the file path mapping between FileBrowser storage and content catalog. +- [x] **SHARE-01** — Test content sharing between two federated nodes. On node A (192.168.1.228): upload a test file to FileBrowser, then call `content.add` with the filename to share it. Call `content.set-pricing` with `access: "free"`. Call `content.set-availability` with `availability: "all_peers"`. On node B (192.168.1.198): call `content.browse-peer` with node A's onion address. Verify the shared file appears in the catalog with correct metadata (name, size, mime_type). Download the file via the content server's HTTP endpoint over Tor. Compare checksums. **Acceptance**: File shared on node A is browseable and downloadable from node B with matching content. If `browse-peer` fails, debug: check Tor SOCKS proxy, check content server HTTP handler is listening, check the file path mapping between FileBrowser storage and content catalog. - [ ] **SHARE-02** — Test access control modes. On node A, share 3 files: one `free`, one `peers_only`, one `paid` (price: 100 sats). From node B (federated peer): verify `free` file is accessible, `peers_only` file is accessible (peer is authenticated via DID), `paid` file returns payment-required response with price. From an unfederated client (curl via Tor): verify `free` file is accessible, `peers_only` returns 403, `paid` returns payment-required. Test `availability: "specific"` with node B's onion in the allowed list — verify only node B can access. **Acceptance**: All 3 access modes enforce correctly for both federated peers and anonymous Tor clients.