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, public_writes: true, allowed_pubkeys: HashSet::new(), 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 pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
133 self.state.max_upload_bytes = bytes;
134 self
135 }
136
137 pub fn with_public_writes(mut self, public: bool) -> Self {
140 self.state.public_writes = public;
141 self
142 }
143
144 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 pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
157 self.state.allowed_pubkeys = pubkeys;
158 self
159 }
160
161 pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
163 self.state.upstream_blossom = servers;
164 self
165 }
166
167 pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
169 self.state.social_graph = Some(sg);
170 self
171 }
172
173 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 pub fn with_nostr_relay(mut self, relay: Arc<NostrRelay>) -> Self {
188 self.state.nostr_relay = Some(relay);
189 self
190 }
191
192 pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
194 self.state.nostr_relay_urls = relays;
195 self
196 }
197
198 pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
200 self.extra_routes = Some(routes);
201 self
202 }
203
204 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 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 .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 .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 .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
244 .route("/npub1:rest", get(handlers::serve_npub))
246 .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 .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 .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 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)); 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 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 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}