Skip to main content

hashtree_cli/server/
mod.rs

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