Skip to main content

ebook_rs/
server.rs

1use crate::annotations::Annotation;
2use crate::book::Book;
3use crate::web_ui::READER_HTML;
4use std::sync::{Arc, RwLock};
5use tiny_http::{Header, Response, Server, StatusCode};
6use url::Url;
7
8/// Embedded HTTP Reader Server.
9pub struct ReaderServer {
10    book: Arc<RwLock<Book>>,
11    port: u16,
12    auth_token: Option<String>,
13}
14
15impl ReaderServer {
16    pub fn new(book: Book, port: u16) -> Self {
17        Self {
18            book: Arc::new(RwLock::new(book)),
19            port,
20            auth_token: None,
21        }
22    }
23
24    /// Set an optional bearer authentication token for `/api/mcp` and API endpoints.
25    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
26        self.auth_token = Some(token.into());
27        self
28    }
29
30    /// Start listening and serving incoming HTTP requests.
31    /// P6 & B7 Fix: Thread-pool request dispatching with rwlock poison protection.
32    pub fn listen(&self) -> Result<(), String> {
33        let addr = format!("127.0.0.1:{}", self.port);
34        let server = Arc::new(
35            Server::http(&addr)
36                .map_err(|e| format!("Failed to start server on {}: {}", addr, e))?,
37        );
38        println!(
39            "🚀 EBook-RS Reader Server listening on http://localhost:{}",
40            self.port
41        );
42        if let Some(_ref) = &self.auth_token {
43            println!("🔒 MCP Endpoint Authenticated (Bearer token configured).");
44        }
45        println!("Press Ctrl+C to exit.");
46
47        let active_threads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
48        let server_auth_token = self.auth_token.clone();
49
50        for mut request in server.incoming_requests() {
51            let book_arc = Arc::clone(&self.book);
52            let active_cnt = Arc::clone(&active_threads);
53            let auth_tok = server_auth_token.clone();
54
55            if active_cnt.load(std::sync::atomic::Ordering::Relaxed) >= 64 {
56                let res = Response::from_string("503 Service Unavailable: Server busy")
57                    .with_status_code(StatusCode(503));
58                let _ = request.respond(res);
59                continue;
60            }
61
62            active_cnt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
63            std::thread::spawn(move || {
64                let _guard = ThreadGuard(active_cnt);
65
66                // DNS Rebinding Protection: Verify Host header if present
67                let host_header = request
68                    .headers()
69                    .iter()
70                    .find(|h| h.field.equiv("Host"))
71                    .map(|h| h.value.as_str());
72                if let Some(host) = host_header {
73                    let host_clean = host.split(':').next().unwrap_or(host);
74                    if !host_clean.eq_ignore_ascii_case("localhost")
75                        && host_clean != "127.0.0.1"
76                        && host_clean != "[::1]"
77                        && host_clean != "::1"
78                    {
79                        let header =
80                            Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap();
81                        send_response(
82                            request,
83                            Response::from_string("403 Forbidden: Invalid Host Header")
84                                .with_status_code(StatusCode(403))
85                                .with_header(header),
86                        );
87                        return;
88                    }
89                }
90
91                let url_str = format!("http://localhost{}", request.url());
92                let parsed_url = Url::parse(&url_str)
93                    .unwrap_or_else(|_| Url::parse("http://localhost/").unwrap());
94                let path = parsed_url.path();
95
96                match path {
97                    "/" | "/index.html" => {
98                        let header = Header::from_bytes(
99                            &b"Content-Type"[..],
100                            &b"text/html; charset=utf-8"[..],
101                        )
102                        .unwrap();
103                        send_response(
104                            request,
105                            Response::from_string(READER_HTML).with_header(header),
106                        );
107                    }
108                    "/api/mcp" => {
109                        let header =
110                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
111                                .unwrap();
112
113                        // Validate Authentication Token if configured
114                        if let Some(ref required_token) = auth_tok {
115                            let auth_hdr = request
116                                .headers()
117                                .iter()
118                                .find(|h| {
119                                    h.field.equiv("Authorization") || h.field.equiv("X-MCP-Token")
120                                })
121                                .map(|h| h.value.as_str());
122                            let query_tok = parsed_url
123                                .query_pairs()
124                                .find(|(k, _)| k == "token")
125                                .map(|(_, v)| v.to_string());
126
127                            let is_authed = match auth_hdr {
128                                Some(hdr) if hdr.starts_with("Bearer ") => {
129                                    &hdr[7..] == required_token
130                                }
131                                Some(hdr) => hdr == required_token,
132                                None => query_tok.as_deref() == Some(required_token.as_str()),
133                            };
134
135                            if !is_authed {
136                                send_response(
137                                    request,
138                                    Response::from_string("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"Unauthorized: Invalid or missing authentication token\"},\"id\":null}")
139                                        .with_status_code(StatusCode(401))
140                                        .with_header(header),
141                                );
142                                return;
143                            }
144                        }
145
146                        if request.method() != &tiny_http::Method::Post {
147                            send_response(
148                                request,
149                                Response::from_string("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Method not allowed: use POST\"},\"id\":null}")
150                                    .with_status_code(StatusCode(405))
151                                    .with_header(header),
152                            );
153                            return;
154                        }
155
156                        // Validate Content-Type: application/json
157                        let content_type = request
158                            .headers()
159                            .iter()
160                            .find(|h| h.field.equiv("Content-Type"))
161                            .map(|h| h.value.as_str());
162                        let is_json =
163                            content_type.is_some_and(|ct| ct.starts_with("application/json"));
164                        if !is_json {
165                            send_response(
166                                request,
167                                Response::from_string("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Unsupported Media Type: Content-Type must be application/json\"},\"id\":null}")
168                                    .with_status_code(StatusCode(415))
169                                    .with_header(header),
170                            );
171                            return;
172                        }
173
174                        // Anti-CSRF Protection: Reject requests with non-local Origin or Referer
175                        let origin = request
176                            .headers()
177                            .iter()
178                            .find(|h| h.field.equiv("Origin"))
179                            .map(|h| h.value.as_str());
180                        let referer = request
181                            .headers()
182                            .iter()
183                            .find(|h| h.field.equiv("Referer"))
184                            .map(|h| h.value.as_str());
185
186                        let is_safe_host = |val: &str| -> bool {
187                            val == "http://localhost"
188                                || val.starts_with("http://localhost:")
189                                || val.starts_with("http://localhost/")
190                                || val == "https://localhost"
191                                || val.starts_with("https://localhost:")
192                                || val.starts_with("https://localhost/")
193                                || val == "http://127.0.0.1"
194                                || val.starts_with("http://127.0.0.1:")
195                                || val.starts_with("http://127.0.0.1/")
196                                || val == "https://127.0.0.1"
197                                || val.starts_with("https://127.0.0.1:")
198                                || val.starts_with("https://127.0.0.1/")
199                                || val == "http://[::1]"
200                                || val.starts_with("http://[::1]:")
201                                || val.starts_with("http://[::1]/")
202                        };
203
204                        if let Some(orig) = origin {
205                            if !is_safe_host(orig) {
206                                send_response(
207                                    request,
208                                    Response::from_string("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"CSRF protection: cross-origin request rejected\"},\"id\":null}")
209                                        .with_status_code(StatusCode(403))
210                                        .with_header(header),
211                                );
212                                return;
213                            }
214                        }
215                        if let Some(ref_val) = referer {
216                            if !is_safe_host(ref_val) {
217                                send_response(
218                                    request,
219                                    Response::from_string("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"CSRF protection: cross-origin referer rejected\"},\"id\":null}")
220                                        .with_status_code(StatusCode(403))
221                                        .with_header(header),
222                                );
223                                return;
224                            }
225                        }
226
227                        let mut body = String::new();
228                        use std::io::Read;
229                        let _ = request
230                            .as_reader()
231                            .take(4 * 1024 * 1024)
232                            .read_to_string(&mut body);
233                        match serde_json::from_str::<crate::mcp::JsonRpcRequest>(&body) {
234                            Ok(mcp_req) => {
235                                if let Some(resp_val) = crate::mcp::process_mcp_request(&mcp_req) {
236                                    let json_resp =
237                                        serde_json::to_string(&resp_val).unwrap_or_default();
238                                    send_response(
239                                        request,
240                                        Response::from_string(json_resp).with_header(header),
241                                    );
242                                    return;
243                                }
244                                send_response(
245                                    request,
246                                    Response::from_string(
247                                        "{\"jsonrpc\":\"2.0\",\"result\":null,\"id\":null}",
248                                    )
249                                    .with_header(header),
250                                );
251                            }
252                            Err(e) => {
253                                let err_resp = format!(
254                                    "{{\"jsonrpc\":\"2.0\",\"error\":{{\"code\":-32700,\"message\":\"Parse error: {}\"}},\"id\":null}}",
255                                    e
256                                );
257                                send_response(
258                                    request,
259                                    Response::from_string(err_resp)
260                                        .with_status_code(StatusCode(400))
261                                        .with_header(header),
262                                );
263                            }
264                        }
265                    }
266                    _ if path.starts_with("/api/book/section/") => {
267                        let sec_idx_str = path.trim_start_matches("/api/book/section/");
268                        let sec_idx: usize = sec_idx_str.parse().unwrap_or(0);
269                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
270
271                        match book.get_section(sec_idx) {
272                            Ok(sec) => {
273                                let header = Header::from_bytes(
274                                    &b"Content-Type"[..],
275                                    &b"text/html; charset=utf-8"[..],
276                                )
277                                .unwrap();
278                                send_response(
279                                    request,
280                                    Response::from_string(&sec.processed_html).with_header(header),
281                                );
282                            }
283                            Err(err) => {
284                                send_response(
285                                    request,
286                                    Response::from_string(err).with_status_code(StatusCode(404)),
287                                );
288                            }
289                        }
290                    }
291                    _ if path.starts_with("/resource/") || path.starts_with("/api/resource/") => {
292                        let clean_path = path
293                            .strip_prefix("/api/resource/")
294                            .or_else(|| path.strip_prefix("/resource/"))
295                            .unwrap_or(path);
296                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
297
298                        match book.get_resource_bytes(clean_path) {
299                            Ok((bytes, mime)) => {
300                                let header =
301                                    Header::from_bytes(&b"Content-Type"[..], mime.as_bytes())
302                                        .unwrap();
303                                send_response(
304                                    request,
305                                    Response::from_data(bytes).with_header(header),
306                                );
307                            }
308                            Err(err) => {
309                                send_response(
310                                    request,
311                                    Response::from_string(err).with_status_code(StatusCode(404)),
312                                );
313                            }
314                        }
315                    }
316                    "/api/book/search" => {
317                        let query = parsed_url
318                            .query_pairs()
319                            .find(|(k, _)| k == "q")
320                            .map(|(_, v)| v.to_string())
321                            .unwrap_or_default();
322
323                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
324                        let mut results =
325                            crate::search::SearchEngine::search(&book.sections, &query, false);
326                        if results.len() > 500 {
327                            results.truncate(500);
328                        }
329
330                        let json = serde_json::to_string(&results).unwrap_or_default();
331                        let header =
332                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
333                                .unwrap();
334                        send_response(request, Response::from_string(json).with_header(header));
335                    }
336                    "/api/book/locations" => {
337                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
338                        let json = serde_json::to_string(&book.locations).unwrap_or_default();
339                        let header =
340                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
341                                .unwrap();
342                        send_response(request, Response::from_string(json).with_header(header));
343                    }
344                    "/api/book/cover" => {
345                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
346                        if let Some((bytes, mime)) = book.cover_image() {
347                            let header =
348                                Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).unwrap();
349                            send_response(request, Response::from_data(bytes).with_header(header));
350                        } else {
351                            send_response(
352                                request,
353                                Response::from_string("No cover found")
354                                    .with_status_code(StatusCode(404)),
355                            );
356                        }
357                    }
358                    "/api/annotations" => {
359                        if request.method() == &tiny_http::Method::Post {
360                            if !is_valid_origin(&request) {
361                                send_response(
362                                    request,
363                                    Response::from_string(
364                                        "Forbidden (Cross-Origin Request Blocked)",
365                                    )
366                                    .with_status_code(StatusCode(403)),
367                                );
368                                return;
369                            }
370                            let mut body_str = String::new();
371                            use std::io::Read;
372                            let _ = request
373                                .as_reader()
374                                .take(2 * 1024 * 1024)
375                                .read_to_string(&mut body_str);
376                            if let Ok(mut ann) = serde_json::from_str::<Annotation>(&body_str) {
377                                let mut book = book_arc.write().unwrap_or_else(|e| e.into_inner());
378                                if book.annotations.list().len() >= 5000 {
379                                    send_response(
380                                        request,
381                                        Response::from_string("400 Bad Request: Maximum annotation capacity reached (5000)")
382                                            .with_status_code(StatusCode(400)),
383                                    );
384                                    return;
385                                }
386                                // Assign server-managed ID to prevent client ID collisions or injection
387                                if ann.id.is_empty()
388                                    || ann.id.len() > 64
389                                    || !ann.id.chars().all(|c| c.is_alphanumeric() || c == '-')
390                                {
391                                    static ANN_COUNTER: std::sync::atomic::AtomicU64 =
392                                        std::sync::atomic::AtomicU64::new(1);
393                                    let c = ANN_COUNTER
394                                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
395                                    let now = std::time::SystemTime::now()
396                                        .duration_since(std::time::UNIX_EPOCH)
397                                        .map(|d| d.as_nanos())
398                                        .unwrap_or(0);
399                                    ann.id = format!("ann-{:x}-{:x}", now, c);
400                                }
401                                let ann_id = ann.id.clone();
402                                book.annotations.add(ann);
403                                let header = Header::from_bytes(
404                                    &b"Content-Type"[..],
405                                    &b"application/json"[..],
406                                )
407                                .unwrap();
408                                let resp_json =
409                                    format!("{{\"status\":\"success\",\"id\":\"{}\"}}", ann_id);
410                                send_response(
411                                    request,
412                                    Response::from_string(resp_json).with_header(header),
413                                );
414                            } else {
415                                send_response(
416                                    request,
417                                    Response::from_string("Invalid annotation payload")
418                                        .with_status_code(StatusCode(400)),
419                                );
420                            }
421                        } else if request.method() == &tiny_http::Method::Get {
422                            let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
423                            let json =
424                                serde_json::to_string(&book.annotations.list()).unwrap_or_default();
425                            let header =
426                                Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
427                                    .unwrap();
428                            send_response(request, Response::from_string(json).with_header(header));
429                        } else {
430                            send_response(
431                                request,
432                                Response::from_string("Method not allowed")
433                                    .with_status_code(StatusCode(405)),
434                            );
435                        }
436                    }
437                    "/api/book/metadata" => {
438                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
439                        let json = serde_json::to_string(&book.metadata()).unwrap_or_default();
440                        let header =
441                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
442                                .unwrap();
443                        send_response(request, Response::from_string(json).with_header(header));
444                    }
445                    "/api/book/spine" => {
446                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
447                        let json = serde_json::to_string(&book.spine()).unwrap_or_default();
448                        let header =
449                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
450                                .unwrap();
451                        send_response(request, Response::from_string(json).with_header(header));
452                    }
453                    "/api/book/toc" => {
454                        let book = book_arc.read().unwrap_or_else(|e| e.into_inner());
455                        let json = serde_json::to_string(&book.toc()).unwrap_or_default();
456                        let header =
457                            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
458                                .unwrap();
459                        send_response(request, Response::from_string(json).with_header(header));
460                    }
461                    _ => {
462                        send_response(
463                            request,
464                            Response::from_string("404 Not Found")
465                                .with_status_code(StatusCode(404)),
466                        );
467                    }
468                }
469            });
470        }
471
472        Ok(())
473    }
474}
475
476fn send_response<R: std::io::Read>(request: tiny_http::Request, response: Response<R>) {
477    let res = response
478        .with_header(Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..]).unwrap())
479        .with_header(Header::from_bytes(&b"X-Frame-Options"[..], &b"SAMEORIGIN"[..]).unwrap())
480        .with_header(
481            Header::from_bytes(
482                &b"Content-Security-Policy"[..],
483                &b"default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-src 'self' blob: data:; object-src 'none'; base-uri 'self';"[..],
484            )
485            .unwrap(),
486        );
487    let _ = request.respond(res);
488}
489
490struct ThreadGuard(Arc<std::sync::atomic::AtomicUsize>);
491
492impl Drop for ThreadGuard {
493    fn drop(&mut self) {
494        self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
495    }
496}
497
498fn is_valid_origin(request: &tiny_http::Request) -> bool {
499    let origin = request
500        .headers()
501        .iter()
502        .find(|h| h.field.equiv("Origin"))
503        .map(|h| h.value.as_str());
504    let referer = request
505        .headers()
506        .iter()
507        .find(|h| h.field.equiv("Referer"))
508        .map(|h| h.value.as_str());
509
510    let is_safe_host = |val: &str| -> bool {
511        val == "http://localhost"
512            || val.starts_with("http://localhost:")
513            || val.starts_with("http://localhost/")
514            || val == "https://localhost"
515            || val.starts_with("https://localhost:")
516            || val.starts_with("https://localhost/")
517            || val == "http://127.0.0.1"
518            || val.starts_with("http://127.0.0.1:")
519            || val.starts_with("http://127.0.0.1/")
520            || val == "https://127.0.0.1"
521            || val.starts_with("https://127.0.0.1:")
522            || val.starts_with("https://127.0.0.1/")
523            || val == "http://[::1]"
524            || val.starts_with("http://[::1]:")
525            || val.starts_with("http://[::1]/")
526            || val == "https://[::1]"
527            || val.starts_with("https://[::1]:")
528            || val.starts_with("https://[::1]/")
529    };
530
531    if let Some(orig) = origin {
532        if !is_safe_host(orig) {
533            return false;
534        }
535    }
536    if let Some(ref_val) = referer {
537        if !is_safe_host(ref_val) {
538            return false;
539        }
540    }
541    true
542}