1mod auth;
2mod blob_read;
3pub(crate) use blob_read::run_blob_read;
4pub mod blossom;
5mod handlers;
6mod ingest_filter;
7mod mime;
8mod nostr_query;
9mod peer_status;
10mod request_paths;
11mod status_metrics;
12mod ui;
13pub mod ws_relay;
14
15use crate::nostr_relay::NostrRelay;
16use crate::socialgraph;
17use crate::storage::HashtreeStore;
18use anyhow::Result;
19use axum::{
20 body::Body,
21 extract::{DefaultBodyLimit, State},
22 http::{header, HeaderValue, Method, Request, StatusCode},
23 middleware,
24 middleware::Next,
25 response::{IntoResponse, Json, Response},
26 routing::{get, post, put},
27 Router,
28};
29use futures::{future::poll_fn, pin_mut, FutureExt};
30use hashtree_core::Cid;
31use hyper::body::Incoming;
32use hyper_util::{
33 rt::{TokioExecutor, TokioIo, TokioTimer},
34 server::conn::auto::Builder as HyperBuilder,
35 service::TowerToHyperService,
36};
37use socket2::{SockRef, TcpKeepalive};
38use std::collections::{HashMap, HashSet};
39use std::convert::Infallible;
40use std::future;
41use std::io;
42use std::net::SocketAddr;
43use std::sync::{Arc, OnceLock, RwLock};
44use std::time::{Duration, Instant};
45use tokio::sync::watch;
46use tower::{Service, ServiceExt as _};
47use tower_http::cors::CorsLayer;
48use tracing::{debug, error, trace};
49
50pub use auth::{
51 new_lookup_cache, new_upstream_http_client, AppState, AuthCredentials, CachedTreeRootEntry,
52};
53
54static VIRTUAL_TREE_HOSTS: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
55const DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES: usize = 256 * 1024 * 1024;
56const DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES: usize = 256 * 1024 * 1024;
57const INTERNAL_JSON_BODY_LIMIT_BYTES: usize = 64 * 1024;
58const POOL_AUDIT_READ_ONLY_HTTP_ERROR: &str = "PoolStore is in audit-serving read-only mode";
59const POOL_AUDIT_READ_ONLY_REASON: &str = "pool-audit-read-only";
60const POOL_AUDIT_READ_ONLY_REASON_HEADER: &str = "x-hashtree-maintenance-reason";
61
62#[cfg(not(test))]
63const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(30);
64#[cfg(test)]
65const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_millis(200);
66const HTTP2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
67const HTTP2_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
68const TCP_LISTEN_BACKLOG: i32 = 1_024;
69const TCP_KEEPALIVE_TIME: Duration = Duration::from_secs(60);
70const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
71
72pub fn bounded_upload_queue_bytes(bytes: u64) -> usize {
73 usize::try_from(bytes)
74 .unwrap_or(usize::MAX)
75 .clamp(1, tokio::sync::Semaphore::MAX_PERMITS)
76}
77
78fn virtual_tree_hosts() -> &'static RwLock<HashMap<String, String>> {
79 VIRTUAL_TREE_HOSTS.get_or_init(|| RwLock::new(HashMap::new()))
80}
81
82fn pool_audit_request_is_allowed(method: &Method, path: &str) -> bool {
83 if path == "/ws" || path == "/ws/" {
84 return false;
85 }
86 matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
87 || (*method == Method::POST && matches!(path, "/blob/batch" | "/upload/check"))
88}
89
90async fn pool_audit_read_only_middleware(
91 State(state): State<AppState>,
92 request: Request<Body>,
93 next: Next,
94) -> Response {
95 if !state.store.is_pool_audit_read_only()
96 || pool_audit_request_is_allowed(request.method(), request.uri().path())
97 {
98 return next.run(request).await;
99 }
100
101 let mut response = (
102 StatusCode::SERVICE_UNAVAILABLE,
103 Json(serde_json::json!({
104 "error": POOL_AUDIT_READ_ONLY_HTTP_ERROR,
105 })),
106 )
107 .into_response();
108 response.headers_mut().insert(
109 header::HeaderName::from_static(POOL_AUDIT_READ_ONLY_REASON_HEADER),
110 HeaderValue::from_static(POOL_AUDIT_READ_ONLY_REASON),
111 );
112 response
113}
114
115fn normalize_virtual_tree_host(host: &str) -> Option<String> {
116 let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
117 if trimmed.is_empty() {
118 return None;
119 }
120
121 if let Some(stripped) = trimmed
122 .strip_prefix('[')
123 .and_then(|value| value.split_once(']'))
124 {
125 let host_only = stripped.0.trim();
126 if host_only.is_empty() {
127 return None;
128 }
129 return Some(host_only.to_string());
130 }
131
132 if let Some((host_only, port)) = trimmed.rsplit_once(':') {
133 if !host_only.is_empty() && !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) {
134 return Some(host_only.to_string());
135 }
136 }
137
138 Some(trimmed)
139}
140
141pub fn register_virtual_tree_host(host: &str, internal_root: &str) {
142 let Some(normalized_host) = normalize_virtual_tree_host(host) else {
143 return;
144 };
145
146 let normalized_root = internal_root.trim().trim_end_matches('/');
147 if normalized_root.is_empty() {
148 return;
149 }
150
151 if let Ok(mut hosts) = virtual_tree_hosts().write() {
152 hosts.insert(normalized_host, normalized_root.to_string());
153 }
154}
155
156pub fn resolve_virtual_tree_host(host: &str) -> Option<String> {
157 let normalized_host = normalize_virtual_tree_host(host)?;
158 let configured = virtual_tree_hosts()
159 .read()
160 .ok()
161 .and_then(|hosts| hosts.get(&normalized_host).cloned());
162 configured.or_else(|| resolve_iris_localhost_tree_root(&normalized_host))
163}
164
165fn resolve_iris_localhost_tree_root(host: &str) -> Option<String> {
166 let labels: Vec<&str> = host.strip_suffix(".iris.localhost")?.split('.').collect();
167 match labels.as_slice() {
168 [nhash] if nhash.starts_with("nhash1") => Some(format!("/htree/{nhash}")),
169 [site, npub] if !site.is_empty() && npub.starts_with("npub1") => {
170 Some(format!("/htree/{npub}/{site}"))
171 }
172 _ => None,
173 }
174}
175
176#[cfg(test)]
177pub fn clear_virtual_tree_hosts_for_test() {
178 if let Ok(mut hosts) = virtual_tree_hosts().write() {
179 hosts.clear();
180 }
181}
182
183pub struct HashtreeServer {
184 state: AppState,
185 addr: String,
186 extra_routes: Option<Router<AppState>>,
187 cors: Option<CorsLayer>,
188}
189
190impl HashtreeServer {
191 pub fn new(store: Arc<HashtreeStore>, addr: String) -> Self {
192 Self {
193 state: AppState {
194 store,
195 auth: None,
196 daemon_started_at: current_unix_secs(),
197 peer_mode: crate::config::ServerMode::Normal,
198 hash_get_enabled: true,
199 fips_endpoint: None,
200 fips_blob_resolver: None,
201 fetch_from_fips_peers: true,
202 ws_relay: Arc::new(auth::WsRelayState::new()),
203 max_upload_bytes: 5 * 1024 * 1024, public_writes: true, public_plaintext_reads: false,
206 require_random_untrusted_ingest: true,
207 optimistic_blossom_uploads: false,
208 optimistic_upload_queue_bytes: DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES,
209 optimistic_upload_queue: Arc::new(tokio::sync::Semaphore::new(
210 DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_BYTES,
211 )),
212 allowed_pubkeys: HashSet::new(), upstream_blossom: Vec::new(),
214 upstream_http_client: new_upstream_http_client(),
215 upstream_blossom_miss_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
216 upstream_blossom_fetch_metrics: Arc::new(
217 auth::UpstreamBlossomFetchMetrics::default(),
218 ),
219 blossom_upload_replicas: Vec::new(),
220 blossom_upload_replica_queue_bytes: DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES,
221 blossom_upload_replica_queue: Arc::new(tokio::sync::Semaphore::new(
222 DEFAULT_BLOSSOM_UPLOAD_REPLICA_QUEUE_BYTES,
223 )),
224 blossom_upload_replica_keys: None,
225 blossom_upload_replica_scheduler: Arc::new(
226 blossom::BlossomUploadReplicaScheduler::new(),
227 ),
228 social_graph: None,
229 social_graph_store: None,
230 social_graph_root: None,
231 socialgraph_snapshot_public: false,
232 nostr_relay: None,
233 nostr_provider: None,
234 nostr_relay_urls: Vec::new(),
235 tree_root_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
236 inflight_blob_fetches: Arc::new(tokio::sync::Mutex::new(
237 std::collections::HashMap::new(),
238 )),
239 inflight_blob_reads: Arc::new(tokio::sync::Mutex::new(
240 std::collections::HashMap::new(),
241 )),
242 blob_cache: Arc::new(crate::blob_cache::BlobCache::from_env()),
243 directory_listing_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
244 resolved_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
245 thumbnail_path_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
246 cid_size_cache: Arc::new(std::sync::Mutex::new(new_lookup_cache())),
247 },
248 addr,
249 extra_routes: None,
250 cors: None,
251 }
252 }
253
254 pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
256 self.state.max_upload_bytes = bytes;
257 self
258 }
259
260 pub fn with_public_writes(mut self, public: bool) -> Self {
263 self.state.public_writes = public;
264 self
265 }
266
267 pub fn with_public_plaintext_reads(mut self, public: bool) -> Self {
269 self.state.public_plaintext_reads = public;
270 self
271 }
272
273 pub fn with_require_random_untrusted_ingest(mut self, require: bool) -> Self {
274 self.state.require_random_untrusted_ingest = require;
275 self
276 }
277
278 pub fn with_optimistic_blossom_uploads(mut self, enabled: bool) -> Self {
279 self.state.optimistic_blossom_uploads = enabled;
280 self
281 }
282
283 pub fn with_server_mode(mut self, mode: crate::config::ServerMode) -> Self {
284 self.state.peer_mode = mode;
285 self
286 }
287
288 pub fn with_hash_get_enabled(mut self, enabled: bool) -> Self {
289 self.state.hash_get_enabled = enabled;
290 self
291 }
292
293 pub fn with_fetch_from_fips_peers(mut self, enabled: bool) -> Self {
294 self.state.fetch_from_fips_peers = enabled;
295 self
296 }
297
298 pub fn with_fips_endpoint(
299 mut self,
300 endpoint: Arc<hashtree_fips_transport::FipsEndpoint>,
301 ) -> Self {
302 self.state.fips_endpoint = Some(endpoint);
303 self
304 }
305
306 pub fn with_fips_blob_resolver(
307 mut self,
308 resolver: Arc<crate::fips_transport::DaemonBlobResolver>,
309 ) -> Self {
310 self.state.fips_blob_resolver = Some(resolver);
311 self
312 }
313
314 pub fn with_auth(mut self, username: String, password: String) -> Self {
315 self.state.auth = Some(AuthCredentials { username, password });
316 self
317 }
318
319 pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
321 self.state.allowed_pubkeys = pubkeys;
322 self
323 }
324
325 pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
327 self.state.upstream_blossom = servers;
328 self
329 }
330
331 pub fn with_blossom_upload_replicas(
333 mut self,
334 servers: Vec<String>,
335 queue_bytes: usize,
336 keys: nostr::Keys,
337 ) -> Self {
338 let queue_bytes = queue_bytes.clamp(1, tokio::sync::Semaphore::MAX_PERMITS);
339 let mut servers: Vec<String> = servers
340 .into_iter()
341 .map(|server| server.trim().trim_end_matches('/').to_string())
342 .filter(|server| !server.is_empty())
343 .collect();
344 servers.sort();
345 servers.dedup();
346 let replica_keys = (!servers.is_empty()).then(|| Arc::new(keys));
347 self.state.blossom_upload_replicas = servers;
348 self.state.blossom_upload_replica_queue_bytes = queue_bytes;
349 self.state.blossom_upload_replica_queue =
350 Arc::new(tokio::sync::Semaphore::new(queue_bytes));
351 self.state.blossom_upload_replica_keys = replica_keys;
352 self
353 }
354
355 pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
357 self.state.social_graph = Some(sg);
358 self
359 }
360
361 pub fn with_socialgraph_snapshot(
363 mut self,
364 store: Arc<dyn socialgraph::SocialGraphBackend>,
365 root: [u8; 32],
366 public: bool,
367 ) -> Self {
368 self.state.social_graph_store = Some(store);
369 self.state.social_graph_root = Some(root);
370 self.state.socialgraph_snapshot_public = public;
371 self
372 }
373
374 pub fn with_nostr_relay(mut self, relay: Arc<NostrRelay>) -> Self {
376 self.state.nostr_relay = Some(relay);
377 self
378 }
379
380 pub fn with_nostr_provider(mut self, provider: Arc<dyn nostr_pubsub::PubsubProvider>) -> Self {
381 self.state.nostr_provider = Some(provider);
382 self
383 }
384
385 pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
387 self.state.nostr_relay_urls = relays;
388 self
389 }
390
391 pub fn with_cached_tree_roots(self, roots: Vec<(String, Cid)>) -> Self {
393 if let Ok(mut cache) = self.state.tree_root_cache.lock() {
394 let now = Instant::now();
395 for (key, cid) in roots {
396 cache.insert(
397 key,
398 CachedTreeRootEntry {
399 cid,
400 source: "embedded-bootstrap",
401 root_event: None,
402 event: None,
403 cached_at: now,
404 },
405 );
406 }
407 }
408 self
409 }
410
411 pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
413 self.extra_routes = Some(routes);
414 self
415 }
416
417 pub fn with_cors(mut self, cors: CorsLayer) -> Self {
419 self.cors = Some(cors);
420 self
421 }
422
423 pub async fn run(self) -> Result<()> {
424 let listener = tokio::net::TcpListener::bind(&self.addr).await?;
425 let _ = self.run_with_listener(listener).await?;
426 Ok(())
427 }
428
429 pub async fn run_with_listener(self, listener: tokio::net::TcpListener) -> Result<u16> {
430 self.run_with_listener_until(listener, future::pending::<()>())
431 .await
432 }
433
434 pub async fn run_with_listener_until<F>(
435 self,
436 listener: tokio::net::TcpListener,
437 shutdown: F,
438 ) -> Result<u16>
439 where
440 F: std::future::Future<Output = ()> + Send + 'static,
441 {
442 SockRef::from(&listener).listen(TCP_LISTEN_BACKLOG)?;
449 let local_addr = listener.local_addr()?;
450
451 let state = self.state.clone();
455 let public_routes = Router::new()
456 .route("/", get(handlers::serve_root_or_virtual_host))
457 .route("/ws", get(ws_relay::ws_data))
458 .route("/ws/", get(ws_relay::ws_data))
459 .route(
460 "/__iris/store/:hash",
461 get(handlers::iris_store_get).head(handlers::iris_store_head),
462 )
463 .route(
464 "/htree/test",
465 get(handlers::htree_test).head(handlers::htree_test),
466 )
467 .route("/htree/nhash1:nhash", get(handlers::htree_nhash))
469 .route("/htree/nhash1:nhash/", get(handlers::htree_nhash))
470 .route("/htree/nhash1:nhash/*path", get(handlers::htree_nhash_path))
471 .route("/htree/npub1:npub/:treename", get(handlers::htree_npub))
473 .route("/htree/npub1:npub/:treename/", get(handlers::htree_npub))
474 .route(
475 "/htree/npub1:npub/:treename/*path",
476 get(handlers::htree_npub_path),
477 )
478 .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
480 .route("/npub1:rest", get(handlers::serve_npub))
482 .route("/npub1:rest/*path", get(handlers::serve_npub))
483 .route(
485 "/:id",
486 get(handlers::serve_content_or_blob)
487 .head(blossom::head_blob)
488 .delete(blossom::delete_blob)
489 .options(blossom::cors_preflight),
490 )
491 .route(
492 "/upload",
493 put(blossom::upload_blob)
494 .layer::<_, std::convert::Infallible>(middleware::from_fn(
495 blossom::require_upload_auth_middleware,
496 ))
497 .layer(DefaultBodyLimit::max(blossom::MAX_SINGLE_UPLOAD_BODY_BYTES))
498 .head(blossom::head_upload)
499 .options(blossom::cors_preflight),
500 )
501 .route(
502 "/upload/batch",
503 post(blossom::upload_blob_batch)
504 .layer::<_, std::convert::Infallible>(middleware::from_fn(
505 blossom::require_upload_auth_middleware,
506 ))
507 .options(blossom::cors_preflight)
508 .layer(DefaultBodyLimit::max(
509 blossom::MAX_BATCH_UPLOAD_JSON_BODY_BYTES,
510 )),
511 )
512 .route(
513 "/upload/batch-binary",
514 post(blossom::upload_blob_batch_binary)
515 .layer::<_, std::convert::Infallible>(middleware::from_fn(
516 blossom::require_upload_auth_middleware,
517 ))
518 .options(blossom::cors_preflight)
519 .layer(DefaultBodyLimit::max(
520 blossom::MAX_BATCH_UPLOAD_BINARY_BODY_BYTES,
521 )),
522 )
523 .route(
524 "/upload/check",
525 post(blossom::upload_check).options(blossom::cors_preflight),
526 )
527 .route(
528 "/blob/batch",
529 post(handlers::download_blob_batch).options(blossom::cors_preflight),
530 )
531 .route(
532 "/list/:pubkey",
533 get(blossom::list_blobs).options(blossom::cors_preflight),
534 )
535 .route("/health", get(handlers::health_check))
537 .route("/api/pins", get(handlers::list_pins))
538 .route("/api/stats", get(handlers::storage_stats))
539 .route("/api/status", get(handlers::daemon_status))
540 .route("/api/socialgraph", get(handlers::socialgraph_stats))
541 .route(
542 "/api/socialgraph/snapshot",
543 get(handlers::socialgraph_snapshot),
544 )
545 .route(
546 "/api/socialgraph/distance/:pubkey",
547 get(handlers::follow_distance),
548 )
549 .route(
551 "/api/resolve/:pubkey/:treename",
552 get(handlers::resolve_to_hash),
553 )
554 .route(
555 "/api/nostr/resolve/:pubkey/:treename",
556 get(handlers::resolve_to_hash),
557 )
558 .route("/api/nostr/profile/:pubkey", get(handlers::nostr_profile))
559 .route(
560 "/api/nostr/events",
561 post(handlers::publish_nostr_event)
562 .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
563 )
564 .route("/api/trees/:pubkey", get(handlers::list_trees))
565 .fallback(get(handlers::serve_virtual_host_fallback))
566 .with_state(state.clone());
567
568 let protected_routes = Router::new()
570 .route("/upload", post(handlers::upload_file))
571 .route("/api/pin/:cid", post(handlers::pin_cid))
572 .route("/api/unpin/:cid", post(handlers::unpin_cid))
573 .route("/api/gc", post(handlers::garbage_collect))
574 .layer(middleware::from_fn_with_state(
575 state.clone(),
576 auth::auth_middleware,
577 ))
578 .with_state(state.clone());
579
580 let internal_routes = Router::new()
583 .route(
584 "/__iris/store/:hash",
585 put(handlers::iris_store_put)
586 .delete(handlers::iris_store_delete)
587 .layer(DefaultBodyLimit::max(blossom::MAX_BATCH_UPLOAD_BYTES)),
588 )
589 .route(
590 "/api/pin-tree",
591 post(handlers::pin_tree)
592 .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
593 )
594 .route(
595 "/api/cache-tree-root",
596 post(handlers::cache_tree_root)
597 .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
598 )
599 .route(
600 "/api/clear-tree-root-cache",
601 post(handlers::clear_tree_root_cache)
602 .layer(DefaultBodyLimit::max(INTERNAL_JSON_BODY_LIMIT_BYTES)),
603 )
604 .layer(middleware::from_fn_with_state(
605 state.clone(),
606 auth::require_auth_middleware,
607 ))
608 .with_state(state.clone());
609
610 let mut app = public_routes
611 .merge(protected_routes)
612 .merge(internal_routes)
613 .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); if let Some(extra) = self.extra_routes {
616 app = app.merge(extra.with_state(state.clone()));
617 }
618
619 app = app
625 .layer(middleware::from_fn_with_state(
626 state,
627 pool_audit_read_only_middleware,
628 ))
629 .layer(middleware::from_fn(status_metrics::record_http_status));
630
631 if let Some(cors) = self.cors {
632 app = app.layer(cors);
633 }
634
635 let make_service = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
636 serve_with_connection_limits(listener, make_service, shutdown).await?;
637
638 Ok(local_addr.port())
639 }
640
641 pub fn addr(&self) -> &str {
642 &self.addr
643 }
644}
645
646async fn serve_with_connection_limits<M, S, F>(
647 listener: tokio::net::TcpListener,
648 mut make_service: M,
649 shutdown: F,
650) -> io::Result<()>
651where
652 M: Service<SocketAddr, Error = Infallible, Response = S> + Send + 'static,
653 M::Future: Send,
654 S: Service<Request<Body>, Response = Response, Error = Infallible> + Clone + Send + 'static,
655 S::Future: Send,
656 F: std::future::Future<Output = ()> + Send + 'static,
657{
658 let (signal_tx, signal_rx) = watch::channel(());
659 let signal_tx = Arc::new(signal_tx);
660 tokio::spawn(async move {
661 shutdown.await;
662 trace!("received graceful shutdown signal; stopping daemon listener");
663 drop(signal_rx);
664 });
665
666 let (close_tx, close_rx) = watch::channel(());
667
668 loop {
669 let (tcp_stream, remote_addr) = tokio::select! {
670 accepted = accept_tcp(&listener) => {
671 match accepted? {
672 Some(connection) => connection,
673 None => continue,
674 }
675 }
676 _ = signal_tx.closed() => {
677 trace!("shutdown signal received; no longer accepting daemon connections");
678 break;
679 }
680 };
681
682 configure_tcp_stream(&tcp_stream);
683 let tcp_stream = TokioIo::new(tcp_stream);
684
685 poll_fn(|cx| make_service.poll_ready(cx))
686 .await
687 .unwrap_or_else(|err| match err {});
688
689 let tower_service = make_service
690 .call(remote_addr)
691 .await
692 .unwrap_or_else(|err| match err {})
693 .map_request(|req: Request<Incoming>| req.map(Body::new));
694 let hyper_service = TowerToHyperService::new(tower_service);
695
696 let signal_tx = Arc::clone(&signal_tx);
697 let close_rx = close_rx.clone();
698
699 tokio::spawn(async move {
700 let mut builder = HyperBuilder::new(TokioExecutor::new());
701 builder
702 .http1()
703 .timer(TokioTimer::new())
704 .header_read_timeout(HTTP1_HEADER_READ_TIMEOUT);
705 builder
706 .http2()
707 .timer(TokioTimer::new())
708 .keep_alive_interval(Some(HTTP2_KEEPALIVE_INTERVAL))
709 .keep_alive_timeout(HTTP2_KEEPALIVE_TIMEOUT);
710
711 let conn = builder.serve_connection_with_upgrades(tcp_stream, hyper_service);
712 pin_mut!(conn);
713
714 let signal_closed = signal_tx.closed().fuse();
715 pin_mut!(signal_closed);
716
717 loop {
718 tokio::select! {
719 result = conn.as_mut() => {
720 if let Err(err) = result {
721 trace!("daemon connection closed with error: {err:#}");
722 }
723 break;
724 }
725 _ = &mut signal_closed => {
726 trace!("shutdown signal received by connection task");
727 conn.as_mut().graceful_shutdown();
728 }
729 }
730 }
731
732 drop(close_rx);
733 });
734 }
735
736 drop(close_rx);
737 drop(listener);
738 close_tx.closed().await;
739
740 Ok(())
741}
742
743fn configure_tcp_stream(tcp_stream: &tokio::net::TcpStream) {
744 if let Err(err) = tcp_stream.set_nodelay(true) {
745 debug!("failed to set TCP_NODELAY on daemon connection: {err:#}");
746 }
747
748 let socket = SockRef::from(tcp_stream);
749 if let Err(err) = socket.set_tcp_keepalive(
750 &TcpKeepalive::new()
751 .with_time(TCP_KEEPALIVE_TIME)
752 .with_interval(TCP_KEEPALIVE_INTERVAL),
753 ) {
754 debug!("failed to set TCP keepalive on daemon connection: {err:#}");
755 }
756}
757
758async fn accept_tcp(
759 listener: &tokio::net::TcpListener,
760) -> io::Result<Option<(tokio::net::TcpStream, SocketAddr)>> {
761 match listener.accept().await {
762 Ok(connection) => Ok(Some(connection)),
763 Err(err) => {
764 if is_connection_error(&err) {
765 return Ok(None);
766 }
767 if is_resource_exhaustion_error(&err) {
768 error!(
769 "daemon accept failed due to file descriptor exhaustion; exiting for supervisor restart: {err}"
770 );
771 return Err(err);
772 }
773 error!("daemon accept error: {err}");
774 tokio::time::sleep(Duration::from_secs(1)).await;
775 Ok(None)
776 }
777 }
778}
779
780fn is_resource_exhaustion_error(err: &io::Error) -> bool {
781 matches!(
782 err.raw_os_error(),
783 Some(code) if code == libc::EMFILE || code == libc::ENFILE
784 )
785}
786
787fn is_connection_error(err: &io::Error) -> bool {
788 matches!(
789 err.kind(),
790 io::ErrorKind::ConnectionRefused
791 | io::ErrorKind::ConnectionAborted
792 | io::ErrorKind::ConnectionReset
793 )
794}
795
796fn current_unix_secs() -> u64 {
797 std::time::SystemTime::now()
798 .duration_since(std::time::UNIX_EPOCH)
799 .unwrap_or(std::time::Duration::ZERO)
800 .as_secs()
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use crate::nostr_relay::{NostrRelay, NostrRelayConfig};
807 use crate::storage::HashtreeStore;
808 use async_trait::async_trait;
809 use hashtree_config::StorageBackend;
810 use hashtree_core::types::Hash;
811 use hashtree_core::{
812 from_hex, nhash_encode, nhash_encode_full, sha256, to_hex, DirEntry, HashTree,
813 HashTreeConfig, LinkType, NHashData,
814 };
815 use nostr::{nips::nip19::ToBech32, EventBuilder, Keys, Kind, Timestamp};
816 use nostr_pubsub::{
817 EventBus, EventSource, PublishReport, PubsubProvider, PubsubProviderMode, QueryEvent,
818 QueryOptions, QueryReport, VerifiedEvent,
819 };
820 use serde_json::json;
821 use std::path::{Path, PathBuf};
822 use std::process::Command;
823 use std::sync::atomic::{AtomicUsize, Ordering};
824 use tempfile::TempDir;
825 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
826 use walkdir::WalkDir;
827
828 const AUDIT_SERVER_CHILD_DATA_ENV: &str = "HASHTREE_AUDIT_SERVER_CHILD_DATA";
829 const AUDIT_SERVER_CHILD_HASH_ENV: &str = "HASHTREE_AUDIT_SERVER_CHILD_HASH";
830
831 fn pool_data_file_snapshot(root: &Path) -> Vec<(PathBuf, Hash, std::time::SystemTime)> {
832 let mut snapshot = WalkDir::new(root)
833 .into_iter()
834 .filter_map(Result::ok)
835 .filter(|entry| {
836 if !entry.file_type().is_file() || entry.file_name() != "data.mdb" {
837 return false;
838 }
839 matches!(
840 entry
841 .path()
842 .parent()
843 .and_then(Path::file_name)
844 .and_then(|name| name.to_str()),
845 Some(hashtree_lmdb::SHARED_BLOB_POOL_DIR_NAME) | Some("blobs")
846 )
847 })
848 .map(|entry| {
849 let path = entry.into_path();
850 let bytes = std::fs::read(&path).expect("read Pool LMDB data file");
851 let modified = std::fs::metadata(&path)
852 .expect("read Pool LMDB metadata")
853 .modified()
854 .expect("read Pool LMDB mtime");
855 (path, sha256(&bytes), modified)
856 })
857 .collect::<Vec<_>>();
858 snapshot.sort_unstable_by(|left, right| left.0.cmp(&right.0));
859 snapshot
860 }
861
862 async fn assert_pool_audit_maintenance_response(response: reqwest::Response) -> Result<()> {
863 assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
864 assert_eq!(
865 response
866 .headers()
867 .get(POOL_AUDIT_READ_ONLY_REASON_HEADER)
868 .and_then(|value| value.to_str().ok()),
869 Some(POOL_AUDIT_READ_ONLY_REASON)
870 );
871 let body: serde_json::Value = response.json().await?;
872 assert_eq!(
873 body,
874 json!({
875 "error": POOL_AUDIT_READ_ONLY_HTTP_ERROR,
876 })
877 );
878 Ok(())
879 }
880
881 struct StaticProvider {
882 event: Option<VerifiedEvent>,
883 queries: AtomicUsize,
884 publishes: AtomicUsize,
885 mode: PubsubProviderMode,
886 }
887
888 #[test]
889 fn pool_audit_request_gate_only_allows_read_ingress() {
890 for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
891 assert!(pool_audit_request_is_allowed(&method, "/health"));
892 }
893 assert!(pool_audit_request_is_allowed(&Method::POST, "/blob/batch"));
894 assert!(pool_audit_request_is_allowed(
895 &Method::POST,
896 "/upload/check"
897 ));
898 for (method, path) in [
899 (Method::GET, "/ws"),
900 (Method::GET, "/ws/"),
901 (Method::PUT, "/upload"),
902 (Method::POST, "/upload/batch"),
903 (Method::POST, "/upload/batch-binary"),
904 (Method::POST, "/api/pin/hash"),
905 (Method::POST, "/api/nostr/events"),
906 (Method::DELETE, "/hash"),
907 ] {
908 assert!(
909 !pool_audit_request_is_allowed(&method, path),
910 "{method} {path} must be blocked"
911 );
912 }
913 }
914
915 #[tokio::test]
916 #[ignore = "subprocess entry point for Pool audit-serving HTTP verification"]
917 async fn pool_audit_read_only_server_subprocess() -> Result<()> {
918 let Some(data_dir) = std::env::var_os(AUDIT_SERVER_CHILD_DATA_ENV) else {
919 return Ok(());
920 };
921 let hash_hex = std::env::var(AUDIT_SERVER_CHILD_HASH_ENV)?;
922 let store = Arc::new(HashtreeStore::new_with_backend(
923 data_dir,
924 StorageBackend::Lmdb,
925 16 * 1024 * 1024 * 1024,
926 )?);
927 assert!(store.is_pool_audit_read_only());
928
929 let owner = Keys::generate();
930 let tree_name = "pool-audit-external";
931 let root_event = EventBuilder::new(Kind::Custom(30064), "")
932 .tags(vec![
933 nostr::Tag::identifier(tree_name),
934 nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
935 nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![hash_hex.clone()]),
936 ])
937 .sign_with_keys(&owner)?;
938 let provider = Arc::new(StaticProvider {
939 event: Some(VerifiedEvent::try_from(root_event.clone())?),
940 queries: AtomicUsize::new(0),
941 publishes: AtomicUsize::new(0),
942 mode: PubsubProviderMode::LocalOnly,
943 });
944 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
945 let port = listener.local_addr()?.port();
946 let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
947 .with_nostr_provider(provider.clone());
948 let handle =
949 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
950 let client = reqwest::Client::new();
951 let base = format!("http://127.0.0.1:{port}");
952
953 let response = client.get(format!("{base}/{hash_hex}")).send().await?;
954 assert_eq!(response.status(), reqwest::StatusCode::OK);
955 assert_eq!(response.bytes().await?.as_ref(), b"audit-serving bytes");
956
957 let status: serde_json::Value = client
958 .get(format!("{base}/api/status"))
959 .send()
960 .await?
961 .error_for_status()?
962 .json()
963 .await?;
964 assert_eq!(status["pool_audit_read_only"], true);
965 assert_eq!(status["capabilities"]["writes"], false);
966
967 let npub = owner.public_key().to_bech32()?;
968 let resolved: serde_json::Value = client
969 .get(format!(
970 "{base}/api/nostr/resolve/{npub}/{tree_name}?refresh=1"
971 ))
972 .send()
973 .await?
974 .error_for_status()?
975 .json()
976 .await?;
977 assert_eq!(resolved["hash"], hash_hex);
978 assert_eq!(resolved["source"], "fips-pubsub");
979 assert_eq!(resolved["event_id"], root_event.id.to_hex());
980 assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
981 assert_eq!(provider.publishes.load(Ordering::Relaxed), 0);
982
983 assert_pool_audit_maintenance_response(
984 client
985 .post(format!("{base}/api/nostr/events"))
986 .json(&root_event)
987 .send()
988 .await?,
989 )
990 .await?;
991 assert_eq!(provider.publishes.load(Ordering::Relaxed), 0);
992
993 assert_pool_audit_maintenance_response(
994 client
995 .put(format!("{base}/upload"))
996 .body(b"blocked upload".to_vec())
997 .send()
998 .await?,
999 )
1000 .await?;
1001 assert_pool_audit_maintenance_response(
1002 client
1003 .post(format!("{base}/upload/batch"))
1004 .json(&json!({"blobs": []}))
1005 .send()
1006 .await?,
1007 )
1008 .await?;
1009 assert_pool_audit_maintenance_response(
1010 client.delete(format!("{base}/{hash_hex}")).send().await?,
1011 )
1012 .await?;
1013 assert_pool_audit_maintenance_response(client.get(format!("{base}/ws")).send().await?)
1014 .await?;
1015
1016 let response = client.get(format!("{base}/{hash_hex}")).send().await?;
1017 assert_eq!(response.status(), reqwest::StatusCode::OK);
1018 assert_eq!(response.bytes().await?.as_ref(), b"audit-serving bytes");
1019
1020 handle.abort();
1021 Ok(())
1022 }
1023
1024 #[test]
1025 fn pool_audit_read_only_server_resolves_external_nostr_without_mutation() -> Result<()> {
1026 let temp = TempDir::new()?;
1027 let data_dir = temp.path().join("data");
1028 let store = HashtreeStore::new_with_backend(
1029 &data_dir,
1030 StorageBackend::Lmdb,
1031 16 * 1024 * 1024 * 1024,
1032 )?;
1033 let hash_hex = store.put_blob(b"audit-serving bytes")?;
1034 store.force_sync()?;
1035 drop(store);
1036
1037 let before = pool_data_file_snapshot(&data_dir);
1038 assert!(
1039 before.len() >= 2,
1040 "expected Pool catalog and at least one member data file"
1041 );
1042
1043 let output = Command::new(std::env::current_exe()?)
1044 .arg("--ignored")
1045 .arg("--exact")
1046 .arg("server::tests::pool_audit_read_only_server_subprocess")
1047 .arg("--nocapture")
1048 .env(AUDIT_SERVER_CHILD_DATA_ENV, &data_dir)
1049 .env(AUDIT_SERVER_CHILD_HASH_ENV, &hash_hex)
1050 .env(hashtree_lmdb::POOL_AUDIT_READ_ONLY_ENV, "1")
1051 .env_remove("HTREE_LMDB_HOT_BLOB_DIR")
1052 .env_remove("HTREE_LMDB_HOT_BLOB_LEGACY_DIR")
1053 .env_remove("HTREE_LMDB_HOT_EXTERNAL_BLOB_DIR")
1054 .env_remove("HTREE_LMDB_LEGACY_EXTERNAL_BLOB_DIR")
1055 .env("RUST_TEST_THREADS", "1")
1056 .output()?;
1057 assert!(
1058 output.status.success(),
1059 "audit-serving subprocess failed: stdout={} stderr={}",
1060 String::from_utf8_lossy(&output.stdout),
1061 String::from_utf8_lossy(&output.stderr)
1062 );
1063
1064 let after = pool_data_file_snapshot(&data_dir);
1065 assert_eq!(
1066 after, before,
1067 "audit-serving process mutated Pool data files"
1068 );
1069 Ok(())
1070 }
1071
1072 #[async_trait]
1073 impl EventBus for StaticProvider {
1074 async fn publish(
1075 &self,
1076 _event: VerifiedEvent,
1077 _source: EventSource,
1078 ) -> nostr_pubsub::Result<PublishReport> {
1079 self.publishes.fetch_add(1, Ordering::Relaxed);
1080 Ok(PublishReport {
1081 accepted: true,
1082 priority: 0,
1083 reason: None,
1084 })
1085 }
1086
1087 async fn query(
1088 &self,
1089 _filters: Vec<nostr_pubsub::Filter>,
1090 _options: QueryOptions,
1091 ) -> nostr_pubsub::Result<QueryReport> {
1092 self.queries.fetch_add(1, Ordering::Relaxed);
1093 Ok(QueryReport {
1094 events: self
1095 .event
1096 .clone()
1097 .map(|event| QueryEvent {
1098 event,
1099 source: EventSource::fips_endpoint("browser-router"),
1100 priority: 0,
1101 })
1102 .into_iter()
1103 .collect(),
1104 })
1105 }
1106 }
1107
1108 impl PubsubProvider for StaticProvider {
1109 fn mode(&self) -> PubsubProviderMode {
1110 self.mode
1111 }
1112 }
1113
1114 #[test]
1115 fn resource_exhaustion_errors_are_fatal_accept_errors() {
1116 assert!(is_resource_exhaustion_error(&io::Error::from_raw_os_error(
1117 libc::EMFILE
1118 )));
1119 assert!(is_resource_exhaustion_error(&io::Error::from_raw_os_error(
1120 libc::ENFILE
1121 )));
1122 assert!(!is_resource_exhaustion_error(
1123 &io::Error::from_raw_os_error(libc::ECONNRESET)
1124 ));
1125 }
1126
1127 #[test]
1128 fn upload_queue_semaphores_fit_all_tokio_targets() -> Result<()> {
1129 let temp_dir = TempDir::new()?;
1130 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1131 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string());
1132
1133 assert_eq!(
1134 server.state.optimistic_upload_queue.available_permits(),
1135 256 * 1024 * 1024
1136 );
1137 assert_eq!(
1138 server
1139 .state
1140 .blossom_upload_replica_queue
1141 .available_permits(),
1142 256 * 1024 * 1024
1143 );
1144 assert_eq!(
1145 bounded_upload_queue_bytes(u64::MAX),
1146 tokio::sync::Semaphore::MAX_PERMITS
1147 );
1148 Ok(())
1149 }
1150
1151 #[test]
1152 fn server_builder_seeds_initial_tree_roots() -> Result<()> {
1153 let temp_dir = TempDir::new()?;
1154 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1155 let hash = from_hex("1111111111111111111111111111111111111111111111111111111111111111")?;
1156 let key = from_hex("2222222222222222222222222222222222222222222222222222222222222222")?;
1157 let cid = Cid {
1158 hash,
1159 key: Some(key),
1160 };
1161
1162 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1163 .with_cached_tree_roots(vec![("npub1example/sites".to_string(), cid.clone())]);
1164 let cached = server
1165 .state
1166 .tree_root_cache
1167 .lock()
1168 .unwrap()
1169 .get("npub1example/sites")
1170 .cloned()
1171 .expect("seeded root");
1172
1173 assert_eq!(cached.cid, cid);
1174 assert_eq!(cached.source, "embedded-bootstrap");
1175 assert!(cached.root_event.is_none());
1176 Ok(())
1177 }
1178
1179 #[test]
1180 fn iris_localhost_hosts_map_to_existing_tree_routes() {
1181 assert_eq!(
1182 resolve_virtual_tree_host("NHASH1EXAMPLE.iris.localhost:8080"),
1183 Some("/htree/nhash1example".to_string())
1184 );
1185 assert_eq!(
1186 resolve_virtual_tree_host("audio.NPUB1EXAMPLE.iris.localhost:8080"),
1187 Some("/htree/npub1example/audio".to_string())
1188 );
1189 assert_eq!(
1190 resolve_virtual_tree_host("audio.extra.npub1example.iris.localhost"),
1191 None
1192 );
1193 assert_eq!(
1194 resolve_virtual_tree_host("nhash1example.htree.localhost"),
1195 None
1196 );
1197 }
1198
1199 #[tokio::test]
1200 async fn test_server_serve_file() -> Result<()> {
1201 let temp_dir = TempDir::new()?;
1202 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1203
1204 let test_file = temp_dir.path().join("test.txt");
1206 std::fs::write(&test_file, b"Hello, Hashtree!")?;
1207
1208 let cid = store.upload_file(&test_file)?;
1209 let hash = from_hex(&cid)?;
1210
1211 let content = store.get_file(&hash)?;
1213 assert!(content.is_some());
1214 assert_eq!(content.unwrap(), b"Hello, Hashtree!");
1215
1216 Ok(())
1217 }
1218
1219 #[tokio::test]
1220 async fn test_server_list_pins() -> Result<()> {
1221 let temp_dir = TempDir::new()?;
1222 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1223
1224 let test_file = temp_dir.path().join("test.txt");
1225 std::fs::write(&test_file, b"Test")?;
1226
1227 let cid = store.upload_file(&test_file)?;
1228 let hash = from_hex(&cid)?;
1229
1230 let pins = store.list_pins_raw()?;
1231 assert_eq!(pins.len(), 1);
1232 assert_eq!(pins[0], hash);
1233
1234 Ok(())
1235 }
1236
1237 async fn spawn_test_server(
1238 store: Arc<HashtreeStore>,
1239 ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1240 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1241 let port = listener.local_addr()?.port();
1242 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string());
1243 let handle =
1244 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1245 Ok((port, handle))
1246 }
1247
1248 async fn spawn_test_server_with_auth(
1249 store: Arc<HashtreeStore>,
1250 ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1251 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1252 let port = listener.local_addr()?.port();
1253 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1254 .with_auth("test-user".to_string(), "test-password".to_string());
1255 let handle =
1256 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1257 Ok((port, handle))
1258 }
1259
1260 #[tokio::test]
1261 async fn multipart_upload_rejects_path_traversal_filenames() -> Result<()> {
1262 let temp_dir = TempDir::new()?;
1263 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1264 let target = temp_dir.path().join("must-not-overwrite.txt");
1265 std::fs::write(&target, b"original contents")?;
1266 let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1267 let client = reqwest::Client::new();
1268 let relative_target = format!(
1269 "../{}/must-not-overwrite.txt",
1270 temp_dir.path().file_name().unwrap().to_string_lossy()
1271 );
1272
1273 for filename in [
1274 target.to_string_lossy().as_ref(),
1275 &relative_target,
1276 r"..\must-not-overwrite.txt",
1277 r"C:\must-not-overwrite.txt",
1278 "nested/file.txt",
1279 "trailing-slash/",
1280 ".",
1281 "..",
1282 "",
1283 ] {
1284 let body = format!(
1285 "--upload-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\nattacker contents\r\n--upload-boundary--\r\n"
1286 );
1287 let response = client
1288 .post(format!("http://127.0.0.1:{port}/upload"))
1289 .header(
1290 reqwest::header::CONTENT_TYPE,
1291 "multipart/form-data; boundary=upload-boundary",
1292 )
1293 .body(body)
1294 .send()
1295 .await?;
1296
1297 assert_eq!(
1298 std::fs::read(&target)?,
1299 b"original contents",
1300 "multipart uploads must not overwrite files outside their temporary directory"
1301 );
1302 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1303 }
1304
1305 let response = client
1306 .post(format!("http://127.0.0.1:{port}/upload"))
1307 .header(
1308 reqwest::header::CONTENT_TYPE,
1309 "multipart/form-data; boundary=upload-boundary",
1310 )
1311 .body("--upload-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"safe.txt\"\r\nContent-Type: application/octet-stream\r\n\r\nvalid upload\r\n--upload-boundary--\r\n")
1312 .send()
1313 .await?;
1314 assert_eq!(response.status(), StatusCode::OK);
1315 let payload: serde_json::Value = response.json().await?;
1316 let hash = from_hex(payload["cid"].as_str().expect("uploaded CID"))?;
1317 assert_eq!(store.get_file(&hash)?, Some(b"valid upload".to_vec()));
1318
1319 handle.abort();
1320 Ok(())
1321 }
1322
1323 async fn encrypted_test_directory(store: &HashtreeStore) -> Result<(Cid, Cid, Vec<u8>)> {
1324 let tree = HashTree::new(
1325 HashTreeConfig::new(store.store_arc())
1326 .with_chunk_size(4)
1327 .with_max_links(2),
1328 );
1329 let content = b"encrypted descendant content spanning chunks".to_vec();
1330 let (file_cid, file_size) = tree.put(&content).await?;
1331 let nested_cid = tree
1332 .put_directory(vec![DirEntry::from_cid("post.json", &file_cid)
1333 .with_size(file_size)
1334 .with_link_type(LinkType::File)])
1335 .await?;
1336 let root_cid = tree
1337 .put_directory(vec![
1338 DirEntry::from_cid("events", &nested_cid).with_link_type(LinkType::Dir)
1339 ])
1340 .await?;
1341 Ok((root_cid, file_cid, content))
1342 }
1343
1344 async fn spawn_test_server_with_nostr_relay(
1345 store: Arc<HashtreeStore>,
1346 relay: Arc<NostrRelay>,
1347 ) -> Result<(u16, tokio::task::JoinHandle<Result<()>>)> {
1348 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1349 let port = listener.local_addr()?.port();
1350 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string()).with_nostr_relay(relay);
1351 let handle =
1352 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1353 Ok((port, handle))
1354 }
1355
1356 #[tokio::test]
1357 async fn unauthenticated_native_store_mutation_is_rejected() -> Result<()> {
1358 let temp_dir = TempDir::new()?;
1359 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1360 let body = b"unauthorized native store write";
1361 let hash = sha256(body);
1362 let hash_hex = to_hex(&hash);
1363 let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1364
1365 let response = reqwest::Client::new()
1366 .put(format!("http://127.0.0.1:{port}/__iris/store/{hash_hex}"))
1367 .body(body.to_vec())
1368 .send()
1369 .await?;
1370
1371 assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1372 assert!(store.get_blob(&hash)?.is_none());
1373 handle.abort();
1374 Ok(())
1375 }
1376
1377 #[tokio::test]
1378 async fn unauthenticated_cache_tree_root_is_rejected() -> Result<()> {
1379 let temp_dir = TempDir::new()?;
1380 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1381 let (port, handle) = spawn_test_server(store).await?;
1382
1383 let response = reqwest::Client::new()
1384 .post(format!("http://127.0.0.1:{port}/api/cache-tree-root"))
1385 .json(&json!({
1386 "npub": "npub1example",
1387 "treeName": "video",
1388 "hash": "988db3f24dc222715f1c1e1fa5876690d3147122243d72d85fd44283867cd61a",
1389 "visibility": "public"
1390 }))
1391 .send()
1392 .await?;
1393
1394 assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1395 handle.abort();
1396 Ok(())
1397 }
1398
1399 #[tokio::test]
1400 async fn pin_tree_requires_configured_valid_auth() -> Result<()> {
1401 let temp_dir = TempDir::new()?;
1402 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1403 let nhash = nhash_encode(&sha256(b"missing"))?;
1404 let client = reqwest::Client::new();
1405
1406 let (port, handle) = spawn_test_server(Arc::clone(&store)).await?;
1407 let response = client
1408 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1409 .json(&json!({"nhash": nhash}))
1410 .send()
1411 .await?;
1412 assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
1413 handle.abort();
1414
1415 let (port, handle) = spawn_test_server_with_auth(store).await?;
1416 let response = client
1417 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1418 .json(&json!({"nhash": nhash}))
1419 .send()
1420 .await?;
1421 assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
1422 handle.abort();
1423 Ok(())
1424 }
1425
1426 #[tokio::test]
1427 async fn pin_tree_rejects_malformed_noncanonical_and_missing_roots() -> Result<()> {
1428 let temp_dir = TempDir::new()?;
1429 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1430 let missing_hash = sha256(b"not stored");
1431 let canonical = nhash_encode(&missing_hash)?;
1432 let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1433 let client = reqwest::Client::new();
1434
1435 for nhash in ["not-an-nhash".to_string(), format!("hashtree:{canonical}")] {
1436 let response = client
1437 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1438 .basic_auth("test-user", Some("test-password"))
1439 .json(&json!({"nhash": nhash}))
1440 .send()
1441 .await?;
1442 assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
1443 }
1444
1445 let response = client
1446 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1447 .basic_auth("test-user", Some("test-password"))
1448 .json(&json!({"nhash": canonical}))
1449 .send()
1450 .await?;
1451 assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
1452 assert!(!store.is_pinned(&missing_hash)?);
1453 assert!(store.get_tree_meta(&missing_hash)?.is_none());
1454 handle.abort();
1455 Ok(())
1456 }
1457
1458 #[tokio::test]
1459 async fn pin_tree_indexes_encrypted_descendants_and_is_idempotent() -> Result<()> {
1460 let temp_dir = TempDir::new()?;
1461 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1462 let (root_cid, file_cid, expected_content) = encrypted_test_directory(&store).await?;
1463 let nhash = nhash_encode_full(&NHashData {
1464 hash: root_cid.hash,
1465 decrypt_key: root_cid.key,
1466 })?;
1467 let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1468 let client = reqwest::Client::new();
1469
1470 for expected_already_pinned in [false, true] {
1471 let response = client
1472 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1473 .basic_auth("test-user", Some("test-password"))
1474 .json(&json!({"nhash": nhash}))
1475 .send()
1476 .await?;
1477 assert_eq!(response.status(), reqwest::StatusCode::OK);
1478 let body: serde_json::Value = response.json().await?;
1479 assert_eq!(body["already_pinned"], expected_already_pinned);
1480 assert!(body["indexed_hashes"].as_u64().unwrap_or_default() > 2);
1481 }
1482
1483 assert!(store.is_pinned(&root_cid.hash)?);
1484 assert_eq!(store.list_pins_raw()?, vec![root_cid.hash]);
1485 assert_eq!(store.list_indexed_trees()?.len(), 1);
1486 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
1487 assert_eq!(
1488 tree.get(&file_cid, None).await?,
1489 Some(expected_content),
1490 "encrypted descendant remains readable"
1491 );
1492 handle.abort();
1493 Ok(())
1494 }
1495
1496 #[tokio::test]
1497 async fn pin_tree_missing_descendant_leaves_no_pin_or_index() -> Result<()> {
1498 let temp_dir = TempDir::new()?;
1499 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1500 let (root_cid, file_cid, _) = encrypted_test_directory(&store).await?;
1501 assert!(store.router().delete_sync(&file_cid.hash)?);
1502 let nhash = nhash_encode_full(&NHashData {
1503 hash: root_cid.hash,
1504 decrypt_key: root_cid.key,
1505 })?;
1506 let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1507
1508 let response = reqwest::Client::new()
1509 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1510 .basic_auth("test-user", Some("test-password"))
1511 .json(&json!({"nhash": nhash}))
1512 .send()
1513 .await?;
1514
1515 assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
1516 assert!(!store.is_pinned(&root_cid.hash)?);
1517 assert!(store.get_tree_meta(&root_cid.hash)?.is_none());
1518 handle.abort();
1519 Ok(())
1520 }
1521
1522 #[tokio::test]
1523 async fn pin_tree_resource_failure_leaves_no_pin_or_index() -> Result<()> {
1524 let temp_dir = TempDir::new()?;
1525 let store = Arc::new(HashtreeStore::with_options(
1526 temp_dir.path().join("db"),
1527 None,
1528 8,
1529 )?);
1530 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
1531 let (cid, _) = tree.put(b"larger than the pin tree byte budget").await?;
1532 let nhash = nhash_encode_full(&NHashData {
1533 hash: cid.hash,
1534 decrypt_key: cid.key,
1535 })?;
1536 let (port, handle) = spawn_test_server_with_auth(Arc::clone(&store)).await?;
1537
1538 let response = reqwest::Client::new()
1539 .post(format!("http://127.0.0.1:{port}/api/pin-tree"))
1540 .basic_auth("test-user", Some("test-password"))
1541 .json(&json!({"nhash": nhash}))
1542 .send()
1543 .await?;
1544
1545 assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
1546 assert!(!store.is_pinned(&cid.hash)?);
1547 assert!(store.get_tree_meta(&cid.hash)?.is_none());
1548 handle.abort();
1549 Ok(())
1550 }
1551
1552 #[tokio::test]
1553 async fn unauthenticated_upload_batch_is_rejected_before_json_extraction() -> Result<()> {
1554 let temp_dir = TempDir::new()?;
1555 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1556 let (port, handle) = spawn_test_server(store).await?;
1557
1558 let response = reqwest::Client::new()
1559 .post(format!("http://127.0.0.1:{port}/upload/batch"))
1560 .header(reqwest::header::CONTENT_TYPE, "application/json")
1561 .body("not valid json")
1562 .send()
1563 .await?;
1564
1565 assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
1566 handle.abort();
1567 Ok(())
1568 }
1569
1570 #[tokio::test]
1571 async fn unauthenticated_single_upload_is_rejected_before_body_limit() -> Result<()> {
1572 let temp_dir = TempDir::new()?;
1573 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1574 let (port, handle) = spawn_test_server(store).await?;
1575 let declared_bytes = blossom::MAX_SINGLE_UPLOAD_BODY_BYTES + 1;
1576 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
1577 stream
1578 .write_all(
1579 format!(
1580 "PUT /upload HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Length: {declared_bytes}\r\nConnection: close\r\n\r\n"
1581 )
1582 .as_bytes(),
1583 )
1584 .await?;
1585
1586 let mut status_line = String::new();
1589 BufReader::new(stream).read_line(&mut status_line).await?;
1590 assert_eq!(status_line.trim_end(), "HTTP/1.1 401 Unauthorized");
1591 handle.abort();
1592 Ok(())
1593 }
1594
1595 #[tokio::test]
1596 async fn upload_options_preflight_does_not_require_auth() -> Result<()> {
1597 let temp_dir = TempDir::new()?;
1598 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1599 let (port, handle) = spawn_test_server(store).await?;
1600
1601 let response = reqwest::Client::new()
1602 .request(
1603 reqwest::Method::OPTIONS,
1604 format!("http://127.0.0.1:{port}/upload"),
1605 )
1606 .send()
1607 .await?;
1608
1609 assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
1610 handle.abort();
1611 Ok(())
1612 }
1613
1614 #[tokio::test]
1615 async fn virtual_tree_hosts_serve_root_assets_and_spa_fallbacks() -> Result<()> {
1616 clear_virtual_tree_hosts_for_test();
1617
1618 let temp_dir = TempDir::new()?;
1619 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1620 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1621
1622 let (index_cid, _) = tree
1623 .put(b"<!doctype html><title>Virtual host ok</title>")
1624 .await?;
1625 let (favicon_cid, _) = tree.put(b"ico").await?;
1626 let (main_js_cid, _) = tree.put(b"console.log('ok');").await?;
1627 let assets_dir = tree
1628 .put_directory(vec![
1629 DirEntry::from_cid("main.js", &main_js_cid).with_link_type(LinkType::File)
1630 ])
1631 .await?;
1632 let root_cid = tree
1633 .put_directory(vec![
1634 DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File),
1635 DirEntry::from_cid("favicon.ico", &favicon_cid).with_link_type(LinkType::File),
1636 DirEntry::from_cid("assets", &assets_dir).with_link_type(LinkType::Dir),
1637 ])
1638 .await?;
1639 let nhash = nhash_encode(&root_cid.hash)?;
1640 let host = "tree-test.htree.localhost";
1641 register_virtual_tree_host(host, &format!("/htree/{nhash}"));
1642
1643 let (port, handle) = spawn_test_server(store).await?;
1644 let base_url = format!("http://127.0.0.1:{port}");
1645 let host_header = format!("{host}:{port}");
1646 let client = reqwest::Client::new();
1647
1648 let root_response = client
1649 .get(format!("{base_url}/"))
1650 .header("Host", &host_header)
1651 .header("Accept", "text/html")
1652 .send()
1653 .await?;
1654 assert_eq!(root_response.status(), reqwest::StatusCode::OK);
1655 assert_eq!(
1656 root_response.bytes().await?.as_ref(),
1657 b"<!doctype html><title>Virtual host ok</title>"
1658 );
1659
1660 let favicon_response = client
1661 .get(format!("{base_url}/favicon.ico"))
1662 .header("Host", &host_header)
1663 .send()
1664 .await?;
1665 assert_eq!(favicon_response.status(), reqwest::StatusCode::OK);
1666 assert_eq!(favicon_response.bytes().await?.as_ref(), b"ico");
1667
1668 let js_response = client
1669 .get(format!("{base_url}/assets/main.js"))
1670 .header("Host", &host_header)
1671 .send()
1672 .await?;
1673 assert_eq!(js_response.status(), reqwest::StatusCode::OK);
1674 assert_eq!(js_response.bytes().await?.as_ref(), b"console.log('ok');");
1675
1676 let profile_response = client
1677 .get(format!("{base_url}/users/npub1example"))
1678 .header("Host", &host_header)
1679 .header("Accept", "text/html")
1680 .send()
1681 .await?;
1682 assert_eq!(profile_response.status(), reqwest::StatusCode::OK);
1683 assert_eq!(
1684 profile_response.bytes().await?.as_ref(),
1685 b"<!doctype html><title>Virtual host ok</title>"
1686 );
1687
1688 handle.abort();
1689 clear_virtual_tree_hosts_for_test();
1690
1691 Ok(())
1692 }
1693
1694 #[tokio::test]
1695 async fn iris_localhost_hosts_serve_immutable_and_named_sites() -> Result<()> {
1696 let temp_dir = TempDir::new()?;
1697 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1698 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1699
1700 let (index_cid, _) = tree
1701 .put(b"<!doctype html><title>Iris localhost ok</title>")
1702 .await?;
1703 let (main_js_cid, _) = tree.put(b"console.log('iris localhost');").await?;
1704 let assets_dir = tree
1705 .put_directory(vec![
1706 DirEntry::from_cid("main.js", &main_js_cid).with_link_type(LinkType::File)
1707 ])
1708 .await?;
1709 let root_cid = tree
1710 .put_directory(vec![
1711 DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File),
1712 DirEntry::from_cid("assets", &assets_dir).with_link_type(LinkType::Dir),
1713 ])
1714 .await?;
1715
1716 let nhash = nhash_encode(&root_cid.hash)?;
1717 let npub = Keys::generate().public_key().to_bech32()?;
1718 let site = "audio";
1719 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1720 let port = listener.local_addr()?.port();
1721 let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
1722 .with_public_plaintext_reads(true)
1723 .with_cached_tree_roots(vec![(format!("{npub}/{site}"), root_cid)]);
1724 let handle =
1725 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1726 let client = reqwest::Client::new();
1727 let base_url = format!("http://127.0.0.1:{port}");
1728
1729 for host in [
1730 format!("{nhash}.iris.localhost:{port}"),
1731 format!("{site}.{npub}.iris.localhost:{port}"),
1732 ] {
1733 let root_response = client
1734 .get(format!("{base_url}/"))
1735 .header("Host", &host)
1736 .header("Accept", "text/html")
1737 .send()
1738 .await?;
1739 assert_eq!(root_response.status(), reqwest::StatusCode::OK, "{host}");
1740 assert_eq!(
1741 root_response.bytes().await?.as_ref(),
1742 b"<!doctype html><title>Iris localhost ok</title>"
1743 );
1744
1745 let asset_response = client
1746 .get(format!("{base_url}/assets/main.js"))
1747 .header("Host", &host)
1748 .send()
1749 .await?;
1750 assert_eq!(asset_response.status(), reqwest::StatusCode::OK, "{host}");
1751 assert_eq!(
1752 asset_response.bytes().await?.as_ref(),
1753 b"console.log('iris localhost');"
1754 );
1755 }
1756
1757 handle.abort();
1758 Ok(())
1759 }
1760
1761 #[tokio::test]
1762 async fn named_iris_site_resolves_from_fips_event_provider_without_relays() -> Result<()> {
1763 let temp_dir = TempDir::new()?;
1764 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1765 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
1766 let (index_cid, _) = tree.put(b"fips event provider site").await?;
1767 let root_cid = tree
1768 .put_directory(vec![
1769 DirEntry::from_cid("index.html", &index_cid).with_link_type(LinkType::File)
1770 ])
1771 .await?;
1772 let owner = Keys::generate();
1773 let site = "radio";
1774 let root_event = EventBuilder::new(Kind::Custom(30064), "")
1775 .tags(vec![
1776 nostr::Tag::identifier(site),
1777 nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
1778 nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![to_hex(&root_cid.hash)]),
1779 ])
1780 .sign_with_keys(&owner)?;
1781 let provider = Arc::new(StaticProvider {
1782 event: Some(VerifiedEvent::try_from(root_event)?),
1783 queries: AtomicUsize::new(0),
1784 publishes: AtomicUsize::new(0),
1785 mode: PubsubProviderMode::LocalOnly,
1786 });
1787
1788 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1789 let port = listener.local_addr()?.port();
1790 let server = HashtreeServer::new(Arc::clone(&store), "127.0.0.1:0".to_string())
1791 .with_public_plaintext_reads(true)
1792 .with_nostr_provider(provider.clone());
1793 assert!(server.state.nostr_relay_urls.is_empty());
1794 assert_eq!(
1795 server.state.nostr_provider.as_ref().unwrap().mode(),
1796 PubsubProviderMode::LocalOnly
1797 );
1798 let handle =
1799 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1800
1801 let npub = owner.public_key().to_bech32()?;
1802 let response = reqwest::Client::new()
1803 .get(format!("http://127.0.0.1:{port}/"))
1804 .header("Host", format!("{site}.{npub}.iris.localhost"))
1805 .header("Accept", "text/html")
1806 .send()
1807 .await?;
1808
1809 assert_eq!(response.status(), reqwest::StatusCode::OK);
1810 assert_eq!(
1811 response.bytes().await?.as_ref(),
1812 b"fips event provider site"
1813 );
1814 assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
1815 handle.abort();
1816 Ok(())
1817 }
1818
1819 #[tokio::test]
1820 async fn loopback_event_publication_uses_configured_provider() -> Result<()> {
1821 let temp_dir = TempDir::new()?;
1822 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1823 let keys = Keys::generate();
1824 let event = EventBuilder::new(Kind::TextNote, "provider test").sign_with_keys(&keys)?;
1825 let provider = Arc::new(StaticProvider {
1826 event: Some(VerifiedEvent::try_from(event.clone())?),
1827 queries: AtomicUsize::new(0),
1828 publishes: AtomicUsize::new(0),
1829 mode: PubsubProviderMode::LocalOnly,
1830 });
1831 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1832 let port = listener.local_addr()?.port();
1833 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1834 .with_nostr_provider(provider.clone());
1835 assert!(server.state.nostr_relay_urls.is_empty());
1836 let handle =
1837 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1838
1839 let response = reqwest::Client::new()
1840 .post(format!("http://127.0.0.1:{port}/api/nostr/events"))
1841 .json(&event)
1842 .send()
1843 .await?;
1844
1845 assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
1846 assert_eq!(provider.publishes.load(Ordering::Relaxed), 1);
1847 handle.abort();
1848 Ok(())
1849 }
1850
1851 #[tokio::test]
1852 async fn loopback_root_publication_is_immediately_resolvable_without_provider_replay(
1853 ) -> Result<()> {
1854 let temp_dir = TempDir::new()?;
1855 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1856 let keys = Keys::generate();
1857 let tree_name = "webvm-e2e";
1858 let root_hash = "11".repeat(32);
1859 let encryption_key = "22".repeat(32);
1860 let event = EventBuilder::new(Kind::Custom(30064), &root_hash)
1861 .tags(vec![
1862 nostr::Tag::identifier(tree_name),
1863 nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["hashtree".to_string()]),
1864 nostr::Tag::custom(nostr::TagKind::custom("l"), vec!["git".to_string()]),
1865 nostr::Tag::custom(nostr::TagKind::custom("hash"), vec![root_hash.clone()]),
1866 nostr::Tag::custom(nostr::TagKind::custom("key"), vec![encryption_key.clone()]),
1867 ])
1868 .sign_with_keys(&keys)?;
1869 let provider = Arc::new(StaticProvider {
1870 event: None,
1871 queries: AtomicUsize::new(0),
1872 publishes: AtomicUsize::new(0),
1873 mode: PubsubProviderMode::LocalOnly,
1874 });
1875 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1876 let port = listener.local_addr()?.port();
1877 let server = HashtreeServer::new(store, "127.0.0.1:0".to_string())
1878 .with_nostr_provider(provider.clone());
1879 assert!(server.state.nostr_relay_urls.is_empty());
1880 let handle =
1881 tokio::spawn(async move { server.run_with_listener(listener).await.map(|_| ()) });
1882 let client = reqwest::Client::new();
1883
1884 let publish = client
1885 .post(format!("http://127.0.0.1:{port}/api/nostr/events"))
1886 .json(&event)
1887 .send()
1888 .await?;
1889 assert_eq!(publish.status(), reqwest::StatusCode::ACCEPTED);
1890
1891 let npub = keys.public_key().to_bech32()?;
1892 let resolved = client
1893 .get(format!(
1894 "http://127.0.0.1:{port}/api/nostr/resolve/{npub}/{tree_name}?refresh=1"
1895 ))
1896 .send()
1897 .await?
1898 .json::<serde_json::Value>()
1899 .await?;
1900
1901 assert_eq!(resolved["hash"], root_hash);
1902 assert_eq!(resolved["key_tag"], encryption_key);
1903 assert_eq!(resolved["source"], "local-relay");
1904 assert_eq!(provider.publishes.load(Ordering::Relaxed), 1);
1905 assert_eq!(provider.queries.load(Ordering::Relaxed), 1);
1906 handle.abort();
1907 Ok(())
1908 }
1909
1910 #[tokio::test]
1911 async fn nostr_profile_route_returns_latest_metadata_event() -> Result<()> {
1912 let temp_dir = TempDir::new()?;
1913 let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
1914 let graph_store = {
1915 let _guard = crate::socialgraph::test_lock().await;
1916 crate::socialgraph::open_test_social_graph_store_with_mapsize(
1917 &temp_dir.path().join("relay-db"),
1918 Some(128 * 1024 * 1024),
1919 )?
1920 };
1921 let backend: Arc<dyn crate::socialgraph::SocialGraphBackend> = graph_store;
1922 let relay = Arc::new(NostrRelay::new(
1923 backend,
1924 temp_dir.path().to_path_buf(),
1925 HashSet::new(),
1926 None,
1927 NostrRelayConfig {
1928 spambox_db_max_bytes: 0,
1929 ..Default::default()
1930 },
1931 )?);
1932
1933 let author = Keys::generate();
1934 let older = EventBuilder::new(
1935 Kind::Metadata,
1936 json!({ "name": "older", "about": "before" }).to_string(),
1937 )
1938 .custom_created_at(Timestamp::from_secs(10))
1939 .sign_with_keys(&author)?;
1940 let newer = EventBuilder::new(
1941 Kind::Metadata,
1942 json!({ "name": "newer", "about": "after" }).to_string(),
1943 )
1944 .custom_created_at(Timestamp::from_secs(20))
1945 .sign_with_keys(&author)?;
1946
1947 relay.ingest_trusted_event(older).await?;
1948 relay.ingest_trusted_event(newer.clone()).await?;
1949
1950 let (port, handle) = spawn_test_server_with_nostr_relay(store, relay).await?;
1951 let response = reqwest::get(format!(
1952 "http://127.0.0.1:{port}/api/nostr/profile/{}",
1953 author.public_key().to_hex()
1954 ))
1955 .await?;
1956
1957 assert_eq!(response.status(), reqwest::StatusCode::OK);
1958 let payload: serde_json::Value = response.json().await?;
1959 assert_eq!(payload["profile"]["name"].as_str(), Some("newer"),);
1960 assert_eq!(payload["profile"]["about"].as_str(), Some("after"));
1961 assert_eq!(payload["created_at"].as_u64(), Some(20));
1962 let expected_event_id = newer.id.to_hex();
1963 assert_eq!(
1964 payload["event_id"].as_str(),
1965 Some(expected_event_id.as_str())
1966 );
1967
1968 handle.abort();
1969 Ok(())
1970 }
1971}