Skip to main content

hashtree_cli/server/
mod.rs

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