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
8pub 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 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
26 self.auth_token = Some(token.into());
27 self
28 }
29
30 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 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 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 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 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 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}