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