hashtree_cli/server/
mod.rs

1mod auth;
2pub mod blossom;
3mod handlers;
4mod mime;
5pub mod stun;
6mod ui;
7
8use anyhow::Result;
9use axum::{
10    extract::DefaultBodyLimit,
11    middleware,
12    routing::{get, post, put},
13    Router,
14};
15use crate::storage::HashtreeStore;
16use crate::webrtc::WebRTCState;
17use std::collections::HashSet;
18use std::sync::Arc;
19
20pub use auth::{AppState, AuthCredentials};
21
22pub struct HashtreeServer {
23    state: AppState,
24    addr: String,
25}
26
27impl HashtreeServer {
28    pub fn new(store: Arc<HashtreeStore>, addr: String) -> Self {
29        Self {
30            state: AppState {
31                store,
32                auth: None,
33                webrtc_peers: None,
34                max_upload_bytes: 5 * 1024 * 1024, // 5 MB default
35                public_writes: true, // Allow anyone with valid Nostr auth by default
36                allowed_pubkeys: HashSet::new(), // No pubkeys allowed by default (use public_writes)
37            },
38            addr,
39        }
40    }
41
42    /// Set maximum upload size for Blossom uploads
43    pub fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
44        self.state.max_upload_bytes = bytes;
45        self
46    }
47
48    /// Set whether to allow public writes (anyone with valid Nostr auth)
49    /// When false, only social graph members can write
50    pub fn with_public_writes(mut self, public: bool) -> Self {
51        self.state.public_writes = public;
52        self
53    }
54
55    /// Set WebRTC state for P2P peer queries
56    pub fn with_webrtc_peers(mut self, webrtc_state: Arc<WebRTCState>) -> Self {
57        self.state.webrtc_peers = Some(webrtc_state);
58        self
59    }
60
61    pub fn with_auth(mut self, username: String, password: String) -> Self {
62        self.state.auth = Some(AuthCredentials { username, password });
63        self
64    }
65
66    /// Set allowed pubkeys for blossom write access (hex format)
67    pub fn with_allowed_pubkeys(mut self, pubkeys: HashSet<String>) -> Self {
68        self.state.allowed_pubkeys = pubkeys;
69        self
70    }
71
72    pub async fn run(self) -> Result<()> {
73        // Public endpoints (no auth required)
74        // Note: /:id serves both CID and blossom SHA256 hash lookups
75        // The handler differentiates based on hash format (64 char hex = blossom)
76        let public_routes = Router::new()
77            .route("/", get(handlers::serve_root))
78            // Nostr resolver endpoints - resolve npub/treename to content
79            .route("/n/:pubkey/:treename", get(handlers::resolve_and_serve))
80            // Direct npub route (clients should parse nhash and request by hex hash)
81            .route("/npub1:rest", get(handlers::serve_npub))
82            // Blossom endpoints (BUD-01, BUD-02)
83            .route("/:id", get(handlers::serve_content_or_blob)
84                .head(blossom::head_blob)
85                .delete(blossom::delete_blob)
86                .options(blossom::cors_preflight))
87            .route("/upload", put(blossom::upload_blob)
88                .options(blossom::cors_preflight))
89            .route("/list/:pubkey", get(blossom::list_blobs)
90                .options(blossom::cors_preflight))
91            // Hashtree API endpoints
92            .route("/api/pins", get(handlers::list_pins))
93            .route("/api/stats", get(handlers::storage_stats))
94            .route("/api/peers", get(handlers::webrtc_peers))
95            .route("/api/socialgraph", get(handlers::socialgraph_stats))
96            // Resolver API endpoints
97            .route("/api/resolve/:pubkey/:treename", get(handlers::resolve_to_hash))
98            .route("/api/trees/:pubkey", get(handlers::list_trees))
99            .with_state(self.state.clone());
100
101        // Protected endpoints (require auth if enabled)
102        let protected_routes = Router::new()
103            .route("/upload", post(handlers::upload_file))
104            .route("/api/pin/:cid", post(handlers::pin_cid))
105            .route("/api/unpin/:cid", post(handlers::unpin_cid))
106            .route("/api/gc", post(handlers::garbage_collect))
107            .layer(middleware::from_fn_with_state(
108                self.state.clone(),
109                auth::auth_middleware,
110            ))
111            .with_state(self.state);
112
113        let app = public_routes
114            .merge(protected_routes)
115            .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); // 10GB limit
116
117        let listener = tokio::net::TcpListener::bind(&self.addr).await?;
118        axum::serve(
119            listener,
120            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
121        ).await?;
122
123        Ok(())
124    }
125
126    pub fn addr(&self) -> &str {
127        &self.addr
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::storage::HashtreeStore;
135    use tempfile::TempDir;
136    use std::path::Path;
137    use hashtree_core::from_hex;
138
139    #[tokio::test]
140    async fn test_server_serve_file() -> Result<()> {
141        let temp_dir = TempDir::new()?;
142        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
143
144        // Create and upload a test file
145        let test_file = temp_dir.path().join("test.txt");
146        std::fs::write(&test_file, b"Hello, Hashtree!")?;
147
148        let cid = store.upload_file(&test_file)?;
149        let hash = from_hex(&cid)?;
150
151        // Verify we can get it
152        let content = store.get_file(&hash)?;
153        assert!(content.is_some());
154        assert_eq!(content.unwrap(), b"Hello, Hashtree!");
155
156        Ok(())
157    }
158
159    #[tokio::test]
160    async fn test_server_list_pins() -> Result<()> {
161        let temp_dir = TempDir::new()?;
162        let store = Arc::new(HashtreeStore::new(temp_dir.path().join("db"))?);
163
164        let test_file = temp_dir.path().join("test.txt");
165        std::fs::write(&test_file, b"Test")?;
166
167        let cid = store.upload_file(&test_file)?;
168        let hash = from_hex(&cid)?;
169
170        let pins = store.list_pins_raw()?;
171        assert_eq!(pins.len(), 1);
172        assert_eq!(pins[0], hash);
173
174        Ok(())
175    }
176}