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, public_writes: true, allowed_pubkeys: HashSet::new(), 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 pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
131 self.state.max_upload_bytes = bytes;
132 self
133 }
134
135 pub fn with_public_writes(mut self, public: bool) -> Self {
138 self.state.public_writes = public;
139 self
140 }
141
142 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 pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
155 self.state.allowed_pubkeys = pubkeys;
156 self
157 }
158
159 pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
161 self.state.upstream_blossom = servers;
162 self
163 }
164
165 pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
167 self.state.social_graph = Some(sg);
168 self
169 }
170
171 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 pub fn with_nostr_relay(mut self, relay: Arc<NostrRelay>) -> Self {
186 self.state.nostr_relay = Some(relay);
187 self
188 }
189
190 pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
192 self.state.nostr_relay_urls = relays;
193 self
194 }
195
196 pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
198 self.extra_routes = Some(routes);
199 self
200 }
201
202 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 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 .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 .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 .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
242 .route("/npub1:rest", get(handlers::serve_npub))
244 .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 .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 .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 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)); 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 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 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}