Skip to main content

hashtree_cli/server/
mod.rs

1mod auth;
2pub mod blossom;
3mod handlers;
4mod mime;
5mod peer_status;
6mod request_paths;
7#[cfg(feature = "p2p")]
8pub mod stun;
9mod ui;
10pub mod ws_relay;
11
12use crate::nostr_relay::NostrRelay;
13use crate::socialgraph;
14use crate::storage::HashtreeStore;
15use crate::webrtc::WebRTCState;
16use anyhow::Result;
17use axum::{
18    extract::DefaultBodyLimit,
19    middleware,
20    routing::{get, post, put},
21    Router,
22};
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, OnceLock, RwLock};
25use tower_http::cors::CorsLayer;
26
27pub use auth::{new_lookup_cache, AppState, AuthCredentials, CachedTreeRootEntry};
28
29static VIRTUAL_TREE_HOSTS: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
30
31fn virtual_tree_hosts() -> &'static RwLock<HashMap<String, String>> {
32    VIRTUAL_TREE_HOSTS.get_or_init(|| RwLock::new(HashMap::new()))
33}
34
35fn normalize_virtual_tree_host(host: &str) -> Option<String> {
36    let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
37    if trimmed.is_empty() {
38        return None;
39    }
40
41    if let Some(stripped) = trimmed
42        .strip_prefix('[')
43        .and_then(|value| value.split_once(']'))
44    {
45        let host_only = stripped.0.trim();
46        if host_only.is_empty() {
47            return None;
48        }
49        return Some(host_only.to_string());
50    }
51
52    if let Some((host_only, port)) = trimmed.rsplit_once(':') {
53        if !host_only.is_empty() && !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) {
54            return Some(host_only.to_string());
55        }
56    }
57
58    Some(trimmed)
59}
60
61pub fn register_virtual_tree_host(host: &str, internal_root: &str) {
62    let Some(normalized_host) = normalize_virtual_tree_host(host) else {
63        return;
64    };
65
66    let normalized_root = internal_root.trim().trim_end_matches('/');
67    if normalized_root.is_empty() {
68        return;
69    }
70
71    if let Ok(mut hosts) = virtual_tree_hosts().write() {
72        hosts.insert(normalized_host, normalized_root.to_string());
73    }
74}
75
76pub fn resolve_virtual_tree_host(host: &str) -> Option<String> {
77    let normalized_host = normalize_virtual_tree_host(host)?;
78    virtual_tree_hosts()
79        .read()
80        .ok()
81        .and_then(|hosts| hosts.get(&normalized_host).cloned())
82}
83
84#[cfg(test)]
85pub fn clear_virtual_tree_hosts_for_test() {
86    if let Ok(mut hosts) = virtual_tree_hosts().write() {
87        hosts.clear();
88    }
89}
90
91pub struct HashtreeServer {
92    state: AppState,
93    addr: String,
94    extra_routes: Option<Router<AppState>>,
95    cors: Option<CorsLayer>,
96}
97
98impl HashtreeServer {
99    pub fn new(store: Arc<HashtreeStore>, addr: String) -> Self {
100        Self {
101            state: AppState {
102                store,
103                auth: None,
104                webrtc_peers: None,
105                ws_relay: Arc::new(auth::WsRelayState::new()),
106                max_upload_bytes: 5 * 1024 * 1024, // 5 MB default
107                public_writes: true,               // Allow anyone with valid Nostr auth by default
108                allowed_pubkeys: HashSet::new(), // No pubkeys allowed by default (use public_writes)
109                upstream_blossom: Vec::new(),
110                social_graph: None,
111                social_graph_store: None,
112                social_graph_root: None,
113                socialgraph_snapshot_public: false,
114                nostr_relay: None,
115                nostr_relay_urls: Vec::new(),
116                tree_root_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
117                inflight_blob_fetches: Arc::new(tokio::sync::Mutex::new(
118                    std::collections::HashMap::new(),
119                )),
120                directory_listing_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
121                resolved_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
122                thumbnail_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
123                cid_size_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
124            },
125            addr,
126            extra_routes: None,
127            cors: None,
128        }
129    }
130
131    /// Set maximum upload size for Blossom uploads
132    pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
133        self.state.max_upload_bytes = bytes;
134        self
135    }
136
137    /// Set whether to allow public writes (anyone with valid Nostr auth)
138    /// When false, only social graph members can write
139    pub fn with_public_writes(mut self, public: bool) -> Self {
140        self.state.public_writes = public;
141        self
142    }
143
144    /// Set WebRTC state for P2P peer queries
145    pub fn with_webrtc_peers(mut self, webrtc_state: Arc<WebRTCState>) -> Self {
146        self.state.webrtc_peers = Some(webrtc_state);
147        self
148    }
149
150    pub fn with_auth(mut self, username: String, password: String) -> Self {
151        self.state.auth = Some(AuthCredentials { username, password });
152        self
153    }
154
155    /// Set allowed pubkeys for blossom write access (hex format)
156    pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
157        self.state.allowed_pubkeys = pubkeys;
158        self
159    }
160
161    /// Set upstream Blossom servers for cascade fetching
162    pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
163        self.state.upstream_blossom = servers;
164        self
165    }
166
167    /// Set social graph access control
168    pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
169        self.state.social_graph = Some(sg);
170        self
171    }
172
173    /// Configure social graph snapshot export (store handle + root)
174    pub fn with_socialgraph_snapshot(
175        mut self,
176        store: Arc<dyn socialgraph::SocialGraphBackend>,
177        root: [u8; 32],
178        public: bool,
179    ) -> Self {
180        self.state.social_graph_store = Some(store);
181        self.state.social_graph_root = Some(root);
182        self.state.socialgraph_snapshot_public = public;
183        self
184    }
185
186    /// Set Nostr relay state (shared for /ws and WebRTC)
187    pub fn with_nostr_relay(mut self, relay: Arc<NostrRelay>) -> Self {
188        self.state.nostr_relay = Some(relay);
189        self
190    }
191
192    /// Set active upstream Nostr relays for HTTP resolver operations.
193    pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
194        self.state.nostr_relay_urls = relays;
195        self
196    }
197
198    /// Merge extra routes into the daemon router (e.g. Tauri embeds /nip07).
199    pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
200        self.extra_routes = Some(routes);
201        self
202    }
203
204    /// Apply a CORS layer to all routes (used by embedded clients like Tauri).
205    pub fn with_cors(mut self, cors: CorsLayer) -> Self {
206        self.cors = Some(cors);
207        self
208    }
209
210    pub async fn run(self) -> Result<()> {
211        let listener = tokio::net::TcpListener::bind(&self.addr).await?;
212        let _ = self.run_with_listener(listener).await?;
213        Ok(())
214    }
215
216    pub async fn run_with_listener(self, listener: tokio::net::TcpListener) -> Result<u16> {
217        let local_addr = listener.local_addr()?;
218
219        // Public endpoints (no auth required)
220        // Note: /:id serves both CID and blossom SHA256 hash lookups
221        // The handler differentiates based on hash format (64 char hex = blossom)
222        let state = self.state.clone();
223        let public_routes = Router::new()
224            .route("/", get(handlers::serve_root_or_virtual_host))
225            .route("/ws", get(ws_relay::ws_data))
226            .route("/ws/", get(ws_relay::ws_data))
227            .route(
228                "/htree/test",
229                get(handlers::htree_test).head(handlers::htree_test),
230            )
231            // /htree/nhash1...[/path] - content-addressed (immutable)
232            .route("/htree/nhash1:nhash", get(handlers::htree_nhash))
233            .route("/htree/nhash1:nhash/", get(handlers::htree_nhash))
234            .route("/htree/nhash1:nhash/*path", get(handlers::htree_nhash_path))
235            // /htree/npub1.../tree[/path] - mutable (resolver-backed)
236            .route("/htree/npub1:npub/:treename", get(handlers::htree_npub))
237            .route("/htree/npub1:npub/:treename/", get(handlers::htree_npub))
238            .route(
239                "/htree/npub1:npub/:treename/*path",
240                get(handlers::htree_npub_path),
241            )
242            // Nostr resolver endpoints - resolve npub/treename to content
243            .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
244            // Direct npub route (clients should parse nhash and request by hex hash)
245            .route("/npub1:rest", get(handlers::serve_npub))
246            // Blossom endpoints (BUD-01, BUD-02)
247            .route(
248                "/:id",
249                get(handlers::serve_content_or_blob)
250                    .head(blossom::head_blob)
251                    .delete(blossom::delete_blob)
252                    .options(blossom::cors_preflight),
253            )
254            .route(
255                "/upload",
256                put(blossom::upload_blob).options(blossom::cors_preflight),
257            )
258            .route(
259                "/list/:pubkey",
260                get(blossom::list_blobs).options(blossom::cors_preflight),
261            )
262            // Hashtree API endpoints
263            .route("/health", get(handlers::health_check))
264            .route("/api/pins", get(handlers::list_pins))
265            .route("/api/stats", get(handlers::storage_stats))
266            .route("/api/peers", get(handlers::webrtc_peers))
267            .route("/api/status", get(handlers::daemon_status))
268            .route("/api/socialgraph", get(handlers::socialgraph_stats))
269            .route(
270                "/api/socialgraph/snapshot",
271                get(handlers::socialgraph_snapshot),
272            )
273            .route(
274                "/api/socialgraph/distance/:pubkey",
275                get(handlers::follow_distance),
276            )
277            // Resolver API endpoints
278            .route(
279                "/api/resolve/:pubkey/:treename",
280                get(handlers::resolve_to_hash),
281            )
282            .route(
283                "/api/nostr/resolve/:pubkey/:treename",
284                get(handlers::resolve_to_hash),
285            )
286            .route("/api/cache-tree-root", post(handlers::cache_tree_root))
287            .route(
288                "/api/clear-tree-root-cache",
289                post(handlers::clear_tree_root_cache),
290            )
291            .route("/api/trees/:pubkey", get(handlers::list_trees))
292            .fallback(get(handlers::serve_virtual_host_fallback))
293            .with_state(state.clone());
294
295        // Protected endpoints (require auth if enabled)
296        let protected_routes = Router::new()
297            .route("/upload", post(handlers::upload_file))
298            .route("/api/pin/:cid", post(handlers::pin_cid))
299            .route("/api/unpin/:cid", post(handlers::unpin_cid))
300            .route("/api/gc", post(handlers::garbage_collect))
301            .layer(middleware::from_fn_with_state(
302                state.clone(),
303                auth::auth_middleware,
304            ))
305            .with_state(state.clone());
306
307        let mut app = public_routes
308            .merge(protected_routes)
309            .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); // 10GB limit
310
311        if let Some(extra) = self.extra_routes {
312            app = app.merge(extra.with_state(state));
313        }
314
315        if let Some(cors) = self.cors {
316            app = app.layer(cors);
317        }
318
319        axum::serve(
320            listener,
321            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
322        )
323        .await?;
324
325        Ok(local_addr.port())
326    }
327
328    pub fn addr(&self) -> &str {
329        &self.addr
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::storage::HashtreeStore;
337    use hashtree_core::{from_hex, nhash_encode, DirEntry, HashTree, HashTreeConfig, LinkType};
338    use tempfile::TempDir;
339
340    #[tokio::test]
341    async fn test_server_serve_file() -> Result<()> {
342        let temp_dir = TempDir::new()?;
343        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
344
345        // Create and upload a test file
346        let test_file = temp_dir.path().join("test.txt");
347        std::fs::write(&test_file, b"Hello, Hashtree!")?;
348
349        let cid = store.upload_file(&test_file)?;
350        let hash = from_hex(&cid)?;
351
352        // Verify we can get it
353        let content = store.get_file(&hash)?;
354        assert!(content.is_some());
355        assert_eq!(content.unwrap(), b"Hello, Hashtree!");
356
357        Ok(())
358    }
359
360    #[tokio::test]
361    async fn test_server_list_pins() -> Result<()> {
362        let temp_dir = TempDir::new()?;
363        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
364
365        let test_file = temp_dir.path().join("test.txt");
366        std::fs::write(&test_file, b"Test")?;
367
368        let cid = store.upload_file(&test_file)?;
369        let hash = from_hex(&cid)?;
370
371        let pins = store.list_pins_raw()?;
372        assert_eq!(pins.len(), 1);
373        assert_eq!(pins[0], hash);
374
375        Ok(())
376    }
377
378    async fn spawn_test_server(
379        store: Arc<HashtreeStore>,
380    ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
381        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
382        let port = listener.local_addr()?.port();
383        let server = HashtreeServer::new(store, "127.0.0.1:0".to_string());
384        let handle =
385            tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
386        Ok((port, handle))
387    }
388
389    #[tokio::test]
390    async fn virtual_tree_hosts_serve_root_assets_and_spa_fallbacks() -> Result<()> {
391        clear_virtual_tree_hosts_for_test();
392
393        let temp_dir = TempDir::new()?;
394        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
395        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
396
397        let (index_cid, _) = tree
398            .put(b"<!doctype html><title>Virtual host ok</title>")
399            .await?;
400        let (favicon_cid, _) = tree.put(b"ico").await?;
401        let (main_js_cid, _) = tree.put(b"console.log('ok');").await?;
402        let assets_dir = tree
403            .put_directory(vec![
404                DirEntry::from_cid("main.js", &main_js_cid).with_link_type(LinkType::File)
405            ])
406            .await?;
407        let root_cid = tree
408            .put_directory(vec![
409                DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File),
410                DirEntry::from_cid("favicon.ico", &favicon_cid).with_link_type(LinkType::File),
411                DirEntry::from_cid("assets", &assets_dir).with_link_type(LinkType::Dir),
412            ])
413            .await?;
414        let nhash = nhash_encode(&root_cid.hash)?;
415        let host = "tree-test.htree.localhost";
416        register_virtual_tree_host(host, &format!("/htree/{nhash}"));
417
418        let (port, handle) = spawn_test_server(store).await?;
419        let base_url = format!("http://127.0.0.1:{port}");
420        let host_header = format!("{host}:{port}");
421        let client = reqwest::Client::new();
422
423        let root_response = client
424            .get(format!("{base_url}/"))
425            .header("Host", &host_header)
426            .header("Accept", "text/html")
427            .send()
428            .await?;
429        assert_eq!(root_response.status(), reqwest::StatusCode::OK);
430        assert_eq!(
431            root_response.bytes().await?.as_ref(),
432            b"<!doctype html><title>Virtual host ok</title>"
433        );
434
435        let favicon_response = client
436            .get(format!("{base_url}/favicon.ico"))
437            .header("Host", &host_header)
438            .send()
439            .await?;
440        assert_eq!(favicon_response.status(), reqwest::StatusCode::OK);
441        assert_eq!(favicon_response.bytes().await?.as_ref(), b"ico");
442
443        let js_response = client
444            .get(format!("{base_url}/assets/main.js"))
445            .header("Host", &host_header)
446            .send()
447            .await?;
448        assert_eq!(js_response.status(), reqwest::StatusCode::OK);
449        assert_eq!(js_response.bytes().await?.as_ref(), b"console.log('ok');");
450
451        let profile_response = client
452            .get(format!("{base_url}/users/npub1example"))
453            .header("Host", &host_header)
454            .header("Accept", "text/html")
455            .send()
456            .await?;
457        assert_eq!(profile_response.status(), reqwest::StatusCode::OK);
458        assert_eq!(
459            profile_response.bytes().await?.as_ref(),
460            b"<!doctype html><title>Virtual host ok</title>"
461        );
462
463        handle.abort();
464        clear_virtual_tree_hosts_for_test();
465
466        Ok(())
467    }
468}