Skip to main content

hashtree_cli/server/
mod.rs

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, // 5 MB default
204                public_writes: true,               // Allow anyone with valid Nostr auth by default
205                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(), // No pubkeys allowed by default (use public_writes)
213                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    /// Set maximum upload size for Blossom uploads
255    pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
256        self.state.max_upload_bytes = bytes;
257        self
258    }
259
260    /// Set whether to allow public writes (anyone with valid Nostr auth)
261    /// When false, only social graph members can write
262    pub fn with_public_writes(mut self, public: bool) -> Self {
263        self.state.public_writes = public;
264        self
265    }
266
267    /// Set whether mutable npub routes serve plaintext for unapproved pubkeys
268    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    /// Set allowed pubkeys for blossom write access (hex format)
320    pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
321        self.state.allowed_pubkeys = pubkeys;
322        self
323    }
324
325    /// Set upstream Blossom servers for cascade fetching
326    pub fn with_upstream_blossom(mut self, servers: Vec<String>) -> Self {
327        self.state.upstream_blossom = servers;
328        self
329    }
330
331    /// Set write-behind Blossom servers for blobs accepted by this server.
332    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    /// Set social graph access control
356    pub fn with_social_graph(mut self, sg: Arc<socialgraph::SocialGraphAccessControl>) -> Self {
357        self.state.social_graph = Some(sg);
358        self
359    }
360
361    /// Configure social graph snapshot export (store handle + root)
362    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    /// Set Nostr relay state (shared for /ws and WebRTC)
375    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    /// Set active upstream Nostr relays for HTTP resolver operations.
386    pub fn with_nostr_relay_urls(mut self, relays: Vec<String>) -> Self {
387        self.state.nostr_relay_urls = relays;
388        self
389    }
390
391    /// Seed mutable root cache entries before the server starts.
392    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    /// Merge extra routes into the daemon router (e.g. Tauri embeds /nip07).
412    pub fn with_extra_routes(mut self, routes: Router<AppState>) -> Self {
413        self.extra_routes = Some(routes);
414        self
415    }
416
417    /// Apply a CORS layer to all routes (used by embedded clients like Tauri).
418    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        // `tokio::net::TcpListener::bind` inherits the platform's small
443        // default listen backlog (128 on Linux). A daemon restart can attract
444        // more reconnecting CDN/tunnel sockets than that before the first
445        // requests are accepted, leaving otherwise healthy clients stalled.
446        // Re-listening updates the queue limit without replacing the bound
447        // socket; the kernel still applies its configured `somaxconn` cap.
448        SockRef::from(&listener).listen(TCP_LISTEN_BACKLOG)?;
449        let local_addr = listener.local_addr()?;
450
451        // Public endpoints (no auth required)
452        // Note: /:id serves raw SHA256 blobs only. Logical tree/file assembly
453        // stays on explicitly configured mutable routes such as approved npub.
454        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            // /htree/nhash1...[/path] - content-addressed (immutable)
468            .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            // /htree/npub1.../tree[/path] - mutable (resolver-backed)
472            .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            // Nostr resolver endpoints - resolve npub/treename to content
479            .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
480            // Direct npub route (clients should parse nhash and request by hex hash)
481            .route("/npub1:rest", get(handlers::serve_npub))
482            .route("/npub1:rest/*path", get(handlers::serve_npub))
483            // Blossom endpoints (BUD-01, BUD-02)
484            .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            // Hashtree API endpoints
536            .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            // Resolver API endpoints
550            .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        // Protected endpoints (require auth if enabled)
569        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        // Internal mutating endpoints require configured Basic auth. These
581        // routes stay closed even when optional API auth is disabled.
582        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)); // 10GB limit
614
615        if let Some(extra) = self.extra_routes {
616            app = app.merge(extra.with_state(state.clone()));
617        }
618
619        // This gate is deliberately outside every route-level auth/body
620        // layer, including caller-supplied routes. Audit mode rejects
621        // mutation ingress before any handler can alter blob or metadata
622        // state. Status metrics remain outermost so maintenance responses are
623        // still observable.
624        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        // Create and upload a test file
1205        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        // Verify we can get it
1212        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        // Send headers only: receiving a response proves authentication ran
1587        // before either buffering the declared body or enforcing its limit.
1588        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}