Skip to main content

hashtree_cli/server/
mod.rs

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