1use axum::{
2 extract::{
3 ws::{Message, WebSocket},
4 Path as AxumPath, State, WebSocketUpgrade,
5 },
6 http::{header, StatusCode},
7 response::{Html, IntoResponse, Redirect, Response},
8 routing::{delete, get, post},
9 Json, Router,
10};
11use futures_util::{stream::StreamExt, SinkExt};
12use qrcode::render::unicode::Dense1x2;
13use qrcode::{EcLevel, QrCode};
14use rusqlite::Connection;
15use serde::{Deserialize, Serialize};
16use std::fs;
17use std::net::SocketAddr;
18use std::sync::{Arc, Mutex};
19use tera::Tera;
20use tokio::net::TcpListener;
21use tokio::sync::{broadcast, mpsc};
22
23use crate::assets::{CssAssets, IconAssets, JsAssets, Templates};
24use crate::i18n;
25use crate::markdown::MarkdownRenderer;
26use crate::search::{SearchQuery, SearchResult};
27use crate::workspace::{
28 expand_and_canonicalize, generate_token, ServerLock, WorkspaceConfig, WorkspaceEntry,
29 WorkspaceFlags, WorkspaceRegistry,
30};
31
32pub mod api {
40 pub use crate::workspace::WorkspaceInfo;
41}
42
43pub struct WorkspaceInit {
45 pub path: std::path::PathBuf,
46 pub flags: WorkspaceFlags,
47 pub initial_path: Option<String>,
49}
50
51pub struct ServerConfig {
53 pub host: String,
54 pub port: u16,
55 pub theme: String,
56 pub qr: Option<String>,
57 pub open_browser: Option<String>,
58 pub shared_annotation: bool,
59 pub salt: Option<String>,
61 pub initial_workspaces: Vec<WorkspaceInit>,
62 pub bound_listener: Option<std::net::TcpListener>,
65 pub registry: Option<Arc<WorkspaceRegistry>>,
68 pub management_token: Option<String>,
70 pub language: Option<String>,
72 pub shortcuts_json: Option<String>,
74 pub styles_css: Option<String>,
80 pub default_chat_mode: String,
83 pub print_collapsed_content: bool,
87}
88
89#[derive(Clone)]
90pub(crate) struct AppState {
91 pub theme: Arc<String>,
92 pub tera: Arc<Tera>,
93 #[allow(dead_code)]
94 pub shared_annotation: bool,
95 pub db: Option<Arc<Mutex<Connection>>>,
96 pub tx: Option<broadcast::Sender<String>>,
97 pub workspace_registry: Arc<WorkspaceRegistry>,
98 pub management_token: Arc<String>,
99 pub i18n_json: Arc<String>,
101 pub i18n_lang: Arc<String>,
103 pub shortcuts_json: Arc<String>,
105 pub styles_css: Arc<String>,
107 pub default_chat_mode: Arc<String>,
109 pub print_collapsed_content: bool,
112 pub shutdown_tx: mpsc::Sender<()>,
114 #[cfg(debug_assertions)]
119 pub dev_reload_tx: Arc<broadcast::Sender<()>>,
120}
121
122async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
123 let _ = state.shutdown_tx.send(()).await;
124 StatusCode::OK
125}
126
127fn detect_lang(override_lang: &Option<String>) -> String {
128 match override_lang {
129 Some(lang) => i18n::resolve_lang(lang).to_string(),
130 None => i18n::resolve_lang("auto").to_string(),
131 }
132}
133
134pub fn workspace_url_path(workspace_id: &str, initial_path: Option<&str>) -> String {
135 match initial_path {
136 Some(path) => format!("/{workspace_id}/{}", path.trim_start_matches('/')),
137 None => format!("/{workspace_id}/"),
138 }
139}
140
141pub fn browser_base_url(bind_host: &str, port: u16) -> String {
142 let trimmed = bind_host.trim();
143 let host = match trimmed {
144 "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
145 other if other.starts_with('[') && other.ends_with(']') => other.to_string(),
146 other => match other.parse::<std::net::IpAddr>() {
147 Ok(std::net::IpAddr::V6(addr)) => format!("[{addr}]"),
148 _ => other.to_string(),
149 },
150 };
151 format!("http://{host}:{port}")
152}
153
154pub fn build_workspace_url(base: &str, workspace_path: &str) -> String {
155 let suffix = if workspace_path.starts_with('/') {
156 workspace_path.to_string()
157 } else {
158 format!("/{workspace_path}")
159 };
160 format!("{}{}", base.trim_end_matches('/'), suffix)
161}
162
163pub fn print_compact_qr(data: &str) -> Result<(), Box<dyn std::error::Error>> {
164 let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::L)?;
166
167 let string = code
173 .render::<Dense1x2>()
174 .quiet_zone(false) .build();
176
177 for line in string.lines() {
179 println!(" {line}"); }
181 println!(); Ok(())
184}
185
186#[derive(Serialize, Deserialize, Debug)]
187#[serde(tag = "type")]
188enum WebSocketMessage {
189 #[serde(rename = "all_annotations")]
190 AllAnnotations { annotations: Vec<serde_json::Value> },
191 #[serde(rename = "new_annotation")]
197 NewAnnotation {
198 annotation: serde_json::Value,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 op_id: Option<String>,
201 },
202 #[serde(rename = "delete_annotation")]
203 DeleteAnnotation {
204 id: String,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 op_id: Option<String>,
207 },
208 #[serde(rename = "clear_annotations")]
209 ClearAnnotations {
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 op_id: Option<String>,
212 },
213 #[serde(rename = "viewed_state")]
214 ViewedState {
215 state: serde_json::Value,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 op_id: Option<String>,
218 },
219 #[serde(rename = "update_viewed_state")]
220 UpdateViewedState {
221 state: serde_json::Value,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 op_id: Option<String>,
224 },
225 #[serde(rename = "live_action")]
226 LiveAction { data: serde_json::Value },
227 #[serde(rename = "file_changed")]
231 FileChanged { workspace_id: String, path: String },
232}
233
234pub async fn start(config: ServerConfig) -> Result<(), String> {
235 let ServerConfig {
236 host,
237 port,
238 theme,
239 qr,
240 open_browser,
241 shared_annotation,
242 salt,
243 initial_workspaces,
244 bound_listener,
245 registry,
246 management_token,
247 language,
248 shortcuts_json,
249 styles_css,
250 default_chat_mode,
251 print_collapsed_content,
252 } = config;
253
254 let mut tera = Tera::default();
256 for file_name in Templates::iter() {
257 if let Some(file) = Templates::get(&file_name) {
258 match std::str::from_utf8(&file.data) {
259 Ok(content) => {
260 if let Err(e) = tera.add_raw_template(&file_name, content) {
261 return Err(format!("Failed to add template '{file_name}': {e}"));
262 }
263 }
264 Err(e) => {
265 return Err(format!("Failed to read template '{file_name}': {e}"));
266 }
267 }
268 }
269 }
270
271 let is_gui_mode = registry.is_some();
282 let has_live = initial_workspaces.iter().any(|w| w.flags.enable_live);
283 let has_chat = initial_workspaces.iter().any(|w| w.flags.enable_chat);
284 let needs_ws = is_gui_mode || shared_annotation || has_live;
285 let needs_db = is_gui_mode || shared_annotation || has_chat;
286 let db = if needs_db {
287 let db_path = std::env::var("MARKON_SQLITE_PATH").unwrap_or_else(|_| {
288 let home = dirs::home_dir().expect("Cannot find home directory");
289 home.join(".markon/annotation.sqlite")
290 .to_string_lossy()
291 .to_string()
292 });
293 let parent_dir = std::path::Path::new(&db_path).parent().unwrap();
294 fs::create_dir_all(parent_dir).expect("Failed to create database directory");
295 let conn = Connection::open(&db_path).expect("Failed to open database");
296 conn.execute(
297 "CREATE TABLE IF NOT EXISTS annotations (
298 id TEXT PRIMARY KEY,
299 file_path TEXT NOT NULL,
300 data TEXT NOT NULL
301 )",
302 [],
303 )
304 .expect("Failed to create annotations table");
305 conn.execute(
306 "CREATE TABLE IF NOT EXISTS viewed_state (
307 file_path TEXT PRIMARY KEY,
308 state TEXT NOT NULL,
309 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
310 )",
311 [],
312 )
313 .expect("Failed to create viewed_state table");
314 crate::chat::storage::ChatStorage::init(&conn).expect("Failed to create chat tables");
315 Some(Arc::new(Mutex::new(conn)))
316 } else {
317 None
318 };
319 let tx = needs_ws.then(|| broadcast::channel(100).0);
320
321 let effective_salt = salt.unwrap_or_else(|| format!("markon:{port}"));
323 let registry = registry.unwrap_or_else(|| Arc::new(WorkspaceRegistry::new(effective_salt)));
324 registry.set_live_broadcaster(tx.clone());
329
330 let mut first_workspace_url_path: Option<String> = None;
332
333 for ws_init in initial_workspaces {
334 let path = expand_and_canonicalize(&ws_init.path.to_string_lossy())
335 .unwrap_or_else(|_| ws_init.path.clone());
336 let id = registry.add(WorkspaceConfig {
337 path,
338 flags: ws_init.flags,
339 single_file: None,
340 });
341 if first_workspace_url_path.is_none() {
342 let url_path = workspace_url_path(&id, ws_init.initial_path.as_deref());
343 first_workspace_url_path = Some(url_path);
344 }
345 }
346
347 let token = Arc::new(management_token.unwrap_or_else(generate_token));
348
349 let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
350
351 let state = AppState {
352 theme: Arc::new(theme),
353 tera: Arc::new(tera),
354 shared_annotation,
355 db,
356 tx,
357 workspace_registry: registry,
358 management_token: token.clone(),
359 i18n_json: Arc::new(i18n::load_i18n()),
360 i18n_lang: Arc::new(detect_lang(&language)),
361 shortcuts_json: Arc::new(shortcuts_json.unwrap_or_else(|| "null".to_string())),
365 styles_css: Arc::new(styles_css.unwrap_or_default()),
366 default_chat_mode: Arc::new(default_chat_mode),
367 print_collapsed_content,
368 shutdown_tx,
369 #[cfg(debug_assertions)]
370 dev_reload_tx: Arc::new(broadcast::channel::<()>(16).0),
371 };
372
373 let mgmt = Router::new()
375 .route("/api/workspace", post(add_workspace_handler))
376 .route(
377 "/api/workspace/{id}",
378 delete(remove_workspace_handler).put(update_workspace_handler),
379 )
380 .route("/api/workspaces", get(list_workspaces_handler))
381 .route("/api/save", post(save_file_handler))
382 .route("/api/shutdown", post(shutdown_handler))
383 .layer(axum::middleware::from_fn_with_state(
384 state.clone(),
385 require_local_and_token,
386 ));
387
388 let mut app = Router::new()
389 .route("/favicon.ico", get(serve_favicon))
391 .route("/_/favicon.ico", get(serve_favicon))
392 .route("/_/favicon.svg", get(serve_favicon_svg))
393 .route("/_/css/{filename}", get(serve_css))
394 .route("/_/js/{*path}", get(serve_js))
395 .route("/_/ws/{workspace_id}", get(config_ws_handler))
396 .route("/search", get(search_handler))
398 .route("/api/preview", post(preview_handler))
399 .route("/{workspace_id}/_/chat", get(handle_chat_popout))
404 .route("/{workspace_id}/", get(handle_workspace_root))
405 .route("/{workspace_id}/{*path}", get(handle_workspace_path))
406 .fallback(|| async { StatusCode::NOT_FOUND })
408 .merge(mgmt);
409
410 if needs_ws {
411 app = app.route("/_/ws", get(ws_handler));
412 }
413
414 #[cfg(debug_assertions)]
419 {
420 app = app
421 .route("/_/dev/reload-stream", get(dev_reload_stream))
422 .route("/_/dev/reload-trigger", post(dev_reload_trigger));
423 }
424
425 let app = app.merge(crate::chat::routes::router());
429
430 let app = app.with_state(state);
431
432 let listener = if let Some(std_listener) = bound_listener {
433 std_listener
434 .set_nonblocking(true)
435 .map_err(|e| format!("Failed to set non-blocking: {e}"))?;
436 TcpListener::from_std(std_listener)
437 .map_err(|e| format!("Failed to convert listener: {e}"))?
438 } else {
439 let addr = format!("{}:{}", host, port)
440 .parse::<SocketAddr>()
441 .map_err(|e| format!("Invalid host address '{}': {}", host, e))?;
442 TcpListener::bind(&addr)
443 .await
444 .map_err(|e| format!("Failed to bind to {addr}: {e}"))?
445 };
446 let addr = listener
447 .local_addr()
448 .map_err(|e| format!("Failed to get local address: {e}"))?;
449 println!("listening on http://{addr}");
450 if let Some(ref p) = first_workspace_url_path {
451 println!("workspace: http://{addr}{p}");
452 }
453
454 let _lock_guard = {
456 if let Err(e) = (ServerLock {
457 port: addr.port(),
458 token: token.as_ref().clone(),
459 })
460 .write()
461 {
462 tracing::warn!("failed to write lock file: {e}");
463 }
464 struct LockGuard;
465 impl Drop for LockGuard {
466 fn drop(&mut self) {
467 ServerLock::remove();
468 }
469 }
470 LockGuard
471 };
472
473 let make_url = |base_option: &str, ws_path: &Option<String>| -> String {
475 let base = if base_option == "local" {
476 format!("http://{addr}")
477 } else {
478 base_option.to_string()
479 };
480 match ws_path {
481 Some(p) => build_workspace_url(&base, p),
482 None => format!("{}/", base.trim_end_matches('/')),
483 }
484 };
485
486 let custom_base = qr
487 .as_ref()
488 .filter(|u| u.as_str() != "missing")
489 .or_else(|| open_browser.as_ref().filter(|u| u.as_str() != "local"));
490 if let Some(base) = custom_base {
491 println!(
492 "accessible at {}",
493 make_url(base, &first_workspace_url_path)
494 );
495 }
496
497 if let Some(ref qr_option) = qr {
498 println!();
499 let qr_url = if qr_option == "missing" {
500 format!("http://{addr}")
501 } else {
502 make_url(qr_option, &first_workspace_url_path)
503 };
504 if let Err(e) = print_compact_qr(&qr_url) {
505 eprintln!("Failed to generate QR code: {e}");
506 }
507 }
508
509 if let Some(ref base_opt) = open_browser {
510 let url = make_url(base_opt, &first_workspace_url_path);
511 if let Err(e) = open::that(&url) {
512 tracing::warn!("best-effort browser open failed: {e}");
513 }
514 }
515
516 axum::serve(
517 listener,
518 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
519 )
520 .with_graceful_shutdown(async move {
521 shutdown_rx.recv().await;
522 println!("Shutting down...");
523 })
524 .await
525 .map_err(|e| format!("Server error: {e}"))?;
526 Ok(())
527}
528
529async fn config_ws_handler(
534 ws: WebSocketUpgrade,
535 State(state): State<AppState>,
536 AxumPath(workspace_id): AxumPath<String>,
537 axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
538 headers: axum::http::HeaderMap,
539) -> impl IntoResponse {
540 if !check_ws_origin(&headers, &addr) {
541 return StatusCode::FORBIDDEN.into_response();
542 }
543 let Some(ws_entry) = state.workspace_registry.get(&workspace_id) else {
544 return StatusCode::NOT_FOUND.into_response();
545 };
546 let mut rx = ws_entry.config_tx.subscribe();
547 ws.on_upgrade(move |mut socket| async move {
548 while let Ok(()) = rx.recv().await {
549 if socket
550 .send(axum::extract::ws::Message::Text("reload".into()))
551 .await
552 .is_err()
553 {
554 break;
555 }
556 }
557 })
558}
559
560fn check_ws_origin(headers: &axum::http::HeaderMap, peer: &std::net::SocketAddr) -> bool {
569 let origin = headers
570 .get(axum::http::header::ORIGIN)
571 .and_then(|v| v.to_str().ok());
572 match origin {
573 None => peer.ip().is_loopback(),
574 Some(o) if o.trim().eq_ignore_ascii_case("null") => false,
577 Some(o) => {
578 let host = headers
579 .get(axum::http::header::HOST)
580 .and_then(|v| v.to_str().ok());
581 origin_matches_host(o, host)
582 }
583 }
584}
585
586fn is_valid_ws_file_path(path: &str) -> bool {
599 if path.is_empty() || path.len() > 1024 || path.contains('\0') {
600 return false;
601 }
602 let p = std::path::Path::new(path);
603 for comp in p.components() {
604 match comp {
605 std::path::Component::ParentDir
606 | std::path::Component::RootDir
607 | std::path::Component::Prefix(_) => return false,
608 _ => {}
609 }
610 }
611 true
612}
613
614fn origin_matches_host(origin: &str, host: Option<&str>) -> bool {
619 let Some(host) = host else { return false };
620 let Some(rest) = origin.split_once("://").map(|(_, r)| r) else {
621 return false;
622 };
623 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
625 authority.eq_ignore_ascii_case(host)
626}
627
628async fn require_local_and_token(
630 State(state): State<AppState>,
631 axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
632 req: axum::extract::Request,
633 next: axum::middleware::Next,
634) -> Response {
635 if !addr.ip().is_loopback() {
636 return StatusCode::FORBIDDEN.into_response();
637 }
638 let ok = req
639 .headers()
640 .get("X-Markon-Token")
641 .and_then(|v| v.to_str().ok())
642 .map(|t| t == state.management_token.as_str())
643 .unwrap_or(false);
644 if !ok {
645 return StatusCode::UNAUTHORIZED.into_response();
646 }
647 next.run(req).await
648}
649
650async fn ws_handler(
651 ws: WebSocketUpgrade,
652 State(state): State<AppState>,
653 axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
654 headers: axum::http::HeaderMap,
655) -> impl IntoResponse {
656 if !check_ws_origin(&headers, &addr) {
657 return StatusCode::FORBIDDEN.into_response();
658 }
659 ws.on_upgrade(move |socket| handle_socket(socket, state))
660 .into_response()
661}
662
663#[cfg(debug_assertions)]
664async fn dev_reload_stream(State(state): State<AppState>) -> impl IntoResponse {
665 use axum::response::sse::{Event, KeepAlive, Sse};
666 use std::convert::Infallible;
667 let rx = state.dev_reload_tx.subscribe();
668 let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|item| async move {
669 item.ok()
671 .map(|()| Ok::<Event, Infallible>(Event::default().event("reload")))
672 });
673 Sse::new(stream).keep_alive(KeepAlive::default())
674}
675
676#[cfg(debug_assertions)]
677async fn dev_reload_trigger(State(state): State<AppState>) -> impl IntoResponse {
678 let _ = state.dev_reload_tx.send(());
681 StatusCode::NO_CONTENT
682}
683
684async fn load_annotations(db: Arc<Mutex<Connection>>, file_path: String) -> Vec<serde_json::Value> {
685 tokio::task::spawn_blocking(move || {
686 let db = db.lock().unwrap();
687 let mut stmt = match db.prepare("SELECT data FROM annotations WHERE file_path = ?1") {
688 Ok(s) => s,
689 Err(e) => {
690 tracing::error!(file_path = %file_path, "load_annotations: prepare failed: {e}");
691 return Vec::new();
692 }
693 };
694 let rows = match stmt.query_map([file_path.as_str()], |row| row.get::<_, String>(0)) {
695 Ok(r) => r,
696 Err(e) => {
697 tracing::error!(file_path = %file_path, "load_annotations: query_map failed: {e}");
698 return Vec::new();
699 }
700 };
701 rows.filter_map(Result::ok)
702 .filter_map(|s| serde_json::from_str(&s).ok())
703 .collect()
704 })
705 .await
706 .unwrap_or_else(|e| {
707 tracing::error!("load_annotations join error: {e}");
708 Vec::new()
709 })
710}
711
712async fn load_viewed_state(db: Arc<Mutex<Connection>>, file_path: String) -> serde_json::Value {
713 tokio::task::spawn_blocking(move || {
714 let db = db.lock().unwrap();
715 let state_json = db
716 .query_row(
717 "SELECT state FROM viewed_state WHERE file_path = ?1",
718 [file_path.as_str()],
719 |row| row.get::<_, String>(0),
720 )
721 .unwrap_or_else(|_| "{}".to_string());
722 serde_json::from_str(&state_json).unwrap_or_else(|_| serde_json::json!({}))
723 })
724 .await
725 .unwrap_or_else(|e| {
726 tracing::error!("load_viewed_state join error: {e}");
727 serde_json::json!({})
728 })
729}
730
731async fn send_json(
732 sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
733 msg: &WebSocketMessage,
734) -> Result<(), ()> {
735 let Ok(encoded) = serde_json::to_string(msg) else {
736 return Err(());
737 };
738 sender
739 .send(Message::Text(encoded.into()))
740 .await
741 .map_err(|_| ())
742}
743
744fn broadcast_msg(tx: &broadcast::Sender<String>, msg: &WebSocketMessage) {
745 if let Ok(encoded) = serde_json::to_string(msg) {
746 let _ = tx.send(encoded);
747 }
748}
749
750enum DbResult {
754 Broadcast(WebSocketMessage),
755 BroadcastClear {
759 op_id: Option<String>,
760 },
761 None,
762}
763
764async fn handle_client_msg(
765 db: Option<Arc<Mutex<Connection>>>,
766 tx: broadcast::Sender<String>,
767 file_path: String,
768 msg: WebSocketMessage,
769) {
770 if let WebSocketMessage::LiveAction { data } = msg {
773 broadcast_msg(&tx, &WebSocketMessage::LiveAction { data });
774 return;
775 }
776 let Some(db) = db else { return };
777
778 let result = tokio::task::spawn_blocking(move || {
782 let conn = db.lock().unwrap();
783 match msg {
784 WebSocketMessage::NewAnnotation { annotation, op_id } => {
785 let Some(id) = annotation["id"].as_str().map(str::to_owned) else {
786 return DbResult::None;
787 };
788 let Ok(data) = serde_json::to_string(&annotation) else {
789 return DbResult::None;
790 };
791 if let Err(e) = conn.execute(
792 "INSERT OR REPLACE INTO annotations (id, file_path, data) VALUES (?1, ?2, ?3)",
793 [id.as_str(), file_path.as_str(), data.as_str()],
794 ) {
795 tracing::error!(file_path = %file_path, "insert annotation failed: {e}");
796 return DbResult::None;
797 }
798 DbResult::Broadcast(WebSocketMessage::NewAnnotation { annotation, op_id })
799 }
800 WebSocketMessage::DeleteAnnotation { id, op_id } => {
801 if let Err(e) = conn.execute(
802 "DELETE FROM annotations WHERE id = ?1 AND file_path = ?2",
803 [id.as_str(), file_path.as_str()],
804 ) {
805 tracing::error!(file_path = %file_path, "delete annotation failed: {e}");
806 return DbResult::None;
807 }
808 DbResult::Broadcast(WebSocketMessage::DeleteAnnotation { id, op_id })
809 }
810 WebSocketMessage::ClearAnnotations { op_id } => {
811 tracing::info!(file_path = %file_path, "clearing annotations");
812 if let Err(e) = conn.execute(
813 "DELETE FROM annotations WHERE file_path = ?1",
814 [file_path.as_str()],
815 ) {
816 tracing::error!(file_path = %file_path, "clear annotations failed: {e}");
817 }
818 if let Err(e) = conn.execute(
819 "DELETE FROM viewed_state WHERE file_path = ?1",
820 [file_path.as_str()],
821 ) {
822 tracing::error!(file_path = %file_path, "clear viewed_state failed: {e}");
823 }
824 DbResult::BroadcastClear { op_id }
825 }
826 WebSocketMessage::UpdateViewedState {
827 state: viewed,
828 op_id,
829 } => {
830 let Ok(state_json) = serde_json::to_string(&viewed) else {
831 return DbResult::None;
832 };
833 if let Err(e) = conn.execute(
834 "INSERT OR REPLACE INTO viewed_state (file_path, state, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
835 [file_path.as_str(), state_json.as_str()],
836 ) {
837 tracing::error!(file_path = %file_path, "update viewed_state failed: {e}");
838 return DbResult::None;
839 }
840 DbResult::Broadcast(WebSocketMessage::ViewedState {
841 state: viewed,
842 op_id,
843 })
844 }
845 _ => DbResult::None,
846 }
847 })
848 .await;
849
850 let result = match result {
851 Ok(r) => r,
852 Err(e) => {
853 tracing::error!("handle_client_msg join error: {e}");
854 return;
855 }
856 };
857
858 match result {
859 DbResult::Broadcast(out) => broadcast_msg(&tx, &out),
860 DbResult::BroadcastClear { op_id } => {
861 broadcast_msg(
862 &tx,
863 &WebSocketMessage::ClearAnnotations {
864 op_id: op_id.clone(),
865 },
866 );
867 broadcast_msg(
868 &tx,
869 &WebSocketMessage::ViewedState {
870 state: serde_json::Value::Object(serde_json::Map::new()),
871 op_id,
872 },
873 );
874 }
875 DbResult::None => {}
876 }
877}
878
879async fn handle_socket(socket: WebSocket, state: AppState) {
880 let (mut sender, mut receiver) = socket.split();
881
882 let Some(tx) = state.tx.clone() else {
885 return;
886 };
887 let db = state.db.clone();
888 let mut rx = tx.subscribe();
889
890 let file_path = match receiver.next().await {
891 Some(Ok(Message::Text(text))) => text.to_string(),
892 _ => {
893 tracing::warn!("failed to receive file path from client");
894 return;
895 }
896 };
897 if !is_valid_ws_file_path(&file_path) {
898 tracing::warn!(file_path = %file_path, "rejecting suspicious file_path from client");
899 return;
900 }
901
902 if let Some(db) = db.as_ref() {
904 let annotations = load_annotations(db.clone(), file_path.clone()).await;
905 tracing::debug!(
906 file_path = %file_path,
907 count = annotations.len(),
908 "sending initial annotations to client",
909 );
910 if send_json(
911 &mut sender,
912 &WebSocketMessage::AllAnnotations { annotations },
913 )
914 .await
915 .is_err()
916 {
917 return;
918 }
919 let viewed = load_viewed_state(db.clone(), file_path.clone()).await;
920 if send_json(
921 &mut sender,
922 &WebSocketMessage::ViewedState {
923 state: viewed,
924 op_id: None,
925 },
926 )
927 .await
928 .is_err()
929 {
930 return;
931 }
932 }
933
934 let mut send_task = tokio::spawn(async move {
935 while let Ok(msg) = rx.recv().await {
936 if sender.send(Message::Text(msg.into())).await.is_err() {
937 break;
938 }
939 }
940 });
941
942 let mut recv_task = tokio::spawn(async move {
943 while let Some(Ok(Message::Text(text))) = receiver.next().await {
944 let Ok(msg) = serde_json::from_str::<WebSocketMessage>(&text) else {
945 continue;
946 };
947 handle_client_msg(db.clone(), tx.clone(), file_path.clone(), msg).await;
948 }
949 });
950
951 tokio::select! {
952 _ = (&mut send_task) => recv_task.abort(),
953 _ = (&mut recv_task) => send_task.abort(),
954 };
955}
956
957async fn handle_chat_popout(
965 State(state): State<AppState>,
966 AxumPath(workspace_id): AxumPath<String>,
967) -> impl IntoResponse {
968 let Some(ws) = state.workspace_registry.get(&workspace_id) else {
969 return StatusCode::NOT_FOUND.into_response();
970 };
971 if !ws.flags().enable_chat {
975 return StatusCode::NOT_FOUND.into_response();
976 }
977 let mut context = tera::Context::new();
978 context.insert("workspace_id", &workspace_id);
979 context.insert("theme", state.theme.as_str());
980 context.insert("title", &"Markon Chat".to_string());
981 context.insert("i18n_json", state.i18n_json.as_str());
982 context.insert("i18n_lang", state.i18n_lang.as_str());
983 context.insert("styles_css", state.styles_css.as_str());
984 context.insert("default_chat_mode", state.default_chat_mode.as_str());
985 match state.tera.render("chat.html", &context) {
986 Ok(html) => Html(html).into_response(),
987 Err(e) => (
988 StatusCode::INTERNAL_SERVER_ERROR,
989 format!("Template error: {e}"),
990 )
991 .into_response(),
992 }
993}
994
995async fn handle_workspace_root(
996 State(state): State<AppState>,
997 AxumPath(workspace_id): AxumPath<String>,
998) -> impl IntoResponse {
999 let Some(ws) = state.workspace_registry.get(&workspace_id) else {
1000 return StatusCode::NOT_FOUND.into_response();
1001 };
1002 if let Some(only) = &ws.single_file {
1005 return Redirect::to(&format!("/{workspace_id}/{only}")).into_response();
1006 }
1007 render_directory_listing(&workspace_id, &ws, None, &state)
1008}
1009
1010async fn handle_workspace_path(
1011 State(state): State<AppState>,
1012 AxumPath((workspace_id, path)): AxumPath<(String, String)>,
1013) -> impl IntoResponse {
1014 let Some(ws) = state.workspace_registry.get(&workspace_id) else {
1015 return StatusCode::NOT_FOUND.into_response();
1016 };
1017
1018 let decoded = urlencoding::decode(&path).unwrap_or_else(|_| path.clone().into());
1019 let rel = decoded.trim_start_matches('/');
1020 if ws.is_ephemeral() && !ws.allows(rel) {
1024 return (StatusCode::NOT_FOUND, "Path not found").into_response();
1025 }
1026 let full_path = ws.root.join(rel);
1027
1028 let canonical = match full_path.canonicalize() {
1029 Ok(p) => p,
1030 Err(_) => {
1031 return (StatusCode::NOT_FOUND, format!("Path not found: {decoded}")).into_response()
1032 }
1033 };
1034
1035 if !canonical.starts_with(&ws.root) {
1036 return (StatusCode::FORBIDDEN, "Access denied").into_response();
1037 }
1038
1039 if canonical.is_file() {
1040 if canonical
1041 .extension()
1042 .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md")
1043 {
1044 render_markdown_file(&canonical.to_string_lossy(), &workspace_id, &ws, &state)
1045 } else {
1046 serve_file(&canonical)
1047 }
1048 } else if canonical.is_dir() {
1049 if ws.is_ephemeral() {
1050 return (StatusCode::NOT_FOUND, "Path not found").into_response();
1054 }
1055 render_directory_listing(&workspace_id, &ws, Some(&decoded), &state)
1056 } else {
1057 (StatusCode::NOT_FOUND, "Path not found").into_response()
1058 }
1059}
1060
1061#[derive(Deserialize)]
1064struct AddWorkspaceRequest {
1065 path: String,
1066 #[serde(flatten)]
1067 flags: WorkspaceFlags,
1068}
1069
1070#[derive(Serialize)]
1071struct AddWorkspaceResponse {
1072 id: String,
1073}
1074
1075async fn add_workspace_handler(
1076 State(state): State<AppState>,
1077 Json(req): Json<AddWorkspaceRequest>,
1078) -> impl IntoResponse {
1079 let path = match expand_and_canonicalize(&req.path) {
1080 Ok(p) => p,
1081 Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
1082 };
1083 let id = state.workspace_registry.add(WorkspaceConfig {
1084 path,
1085 flags: req.flags,
1086 single_file: None,
1087 });
1088 Json(AddWorkspaceResponse { id }).into_response()
1089}
1090
1091async fn remove_workspace_handler(
1092 State(state): State<AppState>,
1093 AxumPath(id): AxumPath<String>,
1094) -> impl IntoResponse {
1095 if state.workspace_registry.remove(&id) {
1096 StatusCode::OK
1097 } else {
1098 StatusCode::NOT_FOUND
1099 }
1100}
1101
1102async fn update_workspace_handler(
1103 State(state): State<AppState>,
1104 AxumPath(id): AxumPath<String>,
1105 Json(flags): Json<WorkspaceFlags>,
1106) -> impl IntoResponse {
1107 if state.workspace_registry.update_flags(&id, flags) {
1108 StatusCode::OK
1109 } else {
1110 StatusCode::NOT_FOUND
1111 }
1112}
1113
1114async fn list_workspaces_handler(State(state): State<AppState>) -> impl IntoResponse {
1115 Json(state.workspace_registry.info_list())
1116}
1117
1118#[derive(Deserialize)]
1121struct WorkspaceSearchQuery {
1122 ws: String,
1123 #[serde(flatten)]
1124 q: SearchQuery,
1125}
1126
1127async fn search_handler(
1128 State(state): State<AppState>,
1129 axum::extract::Query(query): axum::extract::Query<WorkspaceSearchQuery>,
1130) -> impl IntoResponse {
1131 if query.q.q.is_empty() {
1132 return Json(Vec::<SearchResult>::new());
1133 }
1134 let Some(ws) = state.workspace_registry.get(&query.ws) else {
1135 return Json(Vec::new());
1136 };
1137 if !ws.enable_search.load(std::sync::atomic::Ordering::Relaxed) {
1138 return Json(Vec::new());
1139 }
1140 let Some(idx) = ws.search_index.load_full() else {
1141 return Json(Vec::new()); };
1143 let results = idx.search(&query.q.q, 20).unwrap_or_else(|e| {
1144 tracing::warn!("search error: {e}");
1145 Vec::new()
1146 });
1147 Json(results)
1148}
1149
1150fn render_markdown_file(
1151 file_path: &str,
1152 workspace_id: &str,
1153 ws: &WorkspaceEntry,
1154 state: &AppState,
1155) -> Response {
1156 match fs::read_to_string(file_path) {
1157 Ok(markdown_input) => {
1158 let renderer = MarkdownRenderer::new(&state.theme);
1159 let (html_content, has_mermaid, toc) = renderer.render(&markdown_input);
1160
1161 let title = std::path::Path::new(file_path)
1162 .file_name()
1163 .map(|n| n.to_string_lossy().to_string())
1164 .unwrap_or_else(|| file_path.to_string());
1165
1166 let mut context = tera::Context::new();
1167 context.insert("title", &format!("markon - {title}"));
1168 context.insert("file_path", file_path);
1169 context.insert("workspace_id", workspace_id);
1170 context.insert("theme", state.theme.as_str());
1171 context.insert("content", &html_content);
1172 let back_link = std::path::Path::new(file_path)
1177 .parent()
1178 .and_then(|p| p.strip_prefix(&ws.root).ok())
1179 .map(|rel| {
1180 let rel_str = rel.to_string_lossy();
1181 if rel_str.is_empty() {
1182 format!("/{workspace_id}/")
1183 } else {
1184 format!("/{workspace_id}/{}/", rel_str)
1185 }
1186 })
1187 .unwrap_or_else(|| format!("/{workspace_id}/"));
1188 context.insert("back_link", &back_link);
1189 context.insert("show_back_link", &!ws.is_ephemeral());
1190 context.insert("has_mermaid", &has_mermaid);
1191 context.insert("toc", &toc);
1192 let flags = ws.flags();
1193 context.insert("shared_annotation", &flags.shared_annotation);
1194 context.insert("enable_viewed", &flags.enable_viewed);
1195 context.insert("enable_search", &flags.enable_search);
1196 context.insert("enable_edit", &flags.enable_edit);
1197 context.insert("enable_live", &flags.enable_live);
1198 context.insert("enable_chat", &flags.enable_chat);
1199
1200 if flags.enable_edit {
1201 let json = serde_json::to_string(&markdown_input)
1203 .unwrap_or_default()
1204 .replace('<', "\\u003c")
1205 .replace('>', "\\u003e")
1206 .replace('&', "\\u0026");
1207 context.insert("markdown_content_json", &json);
1208 context.insert("management_token", state.management_token.as_str());
1209 }
1210
1211 context.insert("i18n_json", state.i18n_json.as_str());
1212 context.insert("i18n_lang", state.i18n_lang.as_str());
1213 context.insert("shortcuts_json", state.shortcuts_json.as_str());
1214 context.insert("styles_css", state.styles_css.as_str());
1215 context.insert("default_chat_mode", state.default_chat_mode.as_str());
1216 context.insert("print_collapsed_content", &state.print_collapsed_content);
1217
1218 match state.tera.render("layout.html", &context) {
1219 Ok(html) => Html(html).into_response(),
1220 Err(e) => (
1221 StatusCode::INTERNAL_SERVER_ERROR,
1222 format!("Template error: {e}"),
1223 )
1224 .into_response(),
1225 }
1226 }
1227 Err(e) => {
1228 let mut context = tera::Context::new();
1229 context.insert("title", "Error");
1230 context.insert("theme", state.theme.as_str());
1231 context.insert(
1232 "content",
1233 &format!(
1234 r#"<p style="color: red;">Error reading file '{file_path}': {e}</p>
1235 <a href="/">← Back to file list</a>"#
1236 ),
1237 );
1238 context.insert("show_back_link", &false);
1239 context.insert("has_mermaid", &false);
1240 context.insert("i18n_json", state.i18n_json.as_str());
1241 context.insert("i18n_lang", state.i18n_lang.as_str());
1242 context.insert("shortcuts_json", state.shortcuts_json.as_str());
1243 context.insert("styles_css", state.styles_css.as_str());
1244 context.insert("default_chat_mode", state.default_chat_mode.as_str());
1245 context.insert("print_collapsed_content", &state.print_collapsed_content);
1246
1247 match state.tera.render("layout.html", &context) {
1248 Ok(html) => Html(html).into_response(),
1249 Err(e) => (
1250 StatusCode::INTERNAL_SERVER_ERROR,
1251 format!("Template error: {e}"),
1252 )
1253 .into_response(),
1254 }
1255 }
1256 }
1257}
1258
1259fn render_directory_listing(
1260 workspace_id: &str,
1261 ws: &WorkspaceEntry,
1262 dir_param: Option<&str>,
1263 state: &AppState,
1264) -> Response {
1265 use std::path::PathBuf;
1266
1267 let current_dir = if let Some(dir_str) = dir_param {
1268 let p = PathBuf::from(dir_str);
1269 if p.is_absolute() {
1270 p
1271 } else {
1272 ws.root.join(&p)
1273 }
1274 } else {
1275 ws.root.clone()
1276 };
1277
1278 let current_dir = match current_dir.canonicalize() {
1279 Ok(p) => p,
1280 Err(e) => {
1281 return (StatusCode::BAD_REQUEST, format!("Invalid directory: {e}")).into_response()
1282 }
1283 };
1284
1285 #[derive(serde::Serialize)]
1286 struct Entry {
1287 name: String,
1288 is_dir: bool,
1289 link: String,
1290 }
1291
1292 let mut entries: Vec<Entry> = match fs::read_dir(¤t_dir) {
1293 Ok(dir_entries) => dir_entries
1294 .filter_map(|e| e.ok())
1295 .filter_map(|entry| {
1296 let path = entry.path();
1297 let name = entry.file_name().to_string_lossy().to_string();
1298 if name.starts_with('.') {
1299 return None;
1300 }
1301 let file_type = match entry.file_type() {
1303 Ok(ft) => ft,
1304 Err(_) => return None,
1305 };
1306 let is_dir = file_type.is_dir();
1307 let rel = path
1308 .strip_prefix(&ws.root)
1309 .unwrap_or(&path)
1310 .to_string_lossy()
1311 .to_string();
1312 if is_dir {
1313 Some(Entry {
1314 name,
1315 is_dir: true,
1316 link: format!("/{workspace_id}/{rel}/"),
1317 })
1318 } else {
1319 let is_md = path
1320 .extension()
1321 .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md");
1322 if is_md {
1323 Some(Entry {
1324 name,
1325 is_dir: false,
1326 link: format!("/{workspace_id}/{rel}"),
1327 })
1328 } else {
1329 None
1330 }
1331 }
1332 })
1333 .collect(),
1334 Err(e) => {
1335 return (
1336 StatusCode::INTERNAL_SERVER_ERROR,
1337 format!("Error reading directory: {e}"),
1338 )
1339 .into_response()
1340 }
1341 };
1342
1343 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
1344 (true, false) => std::cmp::Ordering::Less,
1345 (false, true) => std::cmp::Ordering::Greater,
1346 _ => a.name.cmp(&b.name),
1347 });
1348
1349 let show_parent = current_dir != ws.root;
1350 let parent_link: Option<String> = if show_parent {
1351 current_dir.parent().map(|parent| {
1352 let rel = parent
1353 .strip_prefix(&ws.root)
1354 .map(|p| p.to_string_lossy().to_string())
1355 .unwrap_or_default();
1356 if rel.is_empty() {
1357 format!("/{workspace_id}/")
1358 } else {
1359 format!("/{workspace_id}/{rel}/")
1360 }
1361 })
1362 } else {
1363 None
1364 };
1365
1366 let mut context = tera::Context::new();
1367 context.insert("theme", state.theme.as_str());
1368 context.insert("workspace_id", workspace_id);
1369 context.insert("current_dir", ¤t_dir.display().to_string());
1370 context.insert("entries", &entries);
1371 context.insert("show_parent", &show_parent);
1372 context.insert("parent_link", &parent_link);
1373 context.insert(
1374 "enable_search",
1375 &ws.enable_search.load(std::sync::atomic::Ordering::Relaxed),
1376 );
1377 context.insert("i18n_json", state.i18n_json.as_str());
1378 context.insert("i18n_lang", state.i18n_lang.as_str());
1379 context.insert("shortcuts_json", state.shortcuts_json.as_str());
1380 context.insert("styles_css", state.styles_css.as_str());
1381
1382 match state.tera.render("directory.html", &context) {
1383 Ok(html) => Html(html).into_response(),
1384 Err(e) => (
1385 StatusCode::INTERNAL_SERVER_ERROR,
1386 format!("Template error: {e}"),
1387 )
1388 .into_response(),
1389 }
1390}
1391
1392async fn serve_favicon() -> impl IntoResponse {
1393 (
1395 StatusCode::MOVED_PERMANENTLY,
1396 [(header::LOCATION, "/_/favicon.svg")],
1397 )
1398 .into_response()
1399}
1400
1401async fn serve_favicon_svg() -> impl IntoResponse {
1402 serve_static_file("favicon.svg", IconAssets::get, "image/svg+xml")
1403}
1404
1405async fn serve_css(AxumPath(filename): AxumPath<String>) -> impl IntoResponse {
1406 serve_static_file(&filename, CssAssets::get, "text/css")
1407}
1408
1409async fn serve_js(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
1410 serve_static_file(&path, JsAssets::get, "application/javascript")
1411}
1412
1413fn serve_static_file<F>(filename: &str, getter: F, content_type: &str) -> Response
1414where
1415 F: FnOnce(&str) -> Option<rust_embed::EmbeddedFile>,
1416{
1417 match getter(filename) {
1418 Some(file) => (
1419 StatusCode::OK,
1420 [(header::CONTENT_TYPE, content_type)],
1421 file.data.into_owned(),
1422 )
1423 .into_response(),
1424 None => (StatusCode::NOT_FOUND, "File not found").into_response(),
1425 }
1426}
1427
1428fn serve_file(path: &std::path::Path) -> Response {
1429 match fs::read(path) {
1430 Ok(content) => {
1431 let mime_type = mime_guess::from_path(path)
1432 .first_or_octet_stream()
1433 .essence_str()
1434 .to_string();
1435 (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], content).into_response()
1436 }
1437 Err(e) => (
1438 StatusCode::INTERNAL_SERVER_ERROR,
1439 format!("Error reading file: {e}"),
1440 )
1441 .into_response(),
1442 }
1443}
1444
1445#[derive(Deserialize)]
1448struct SaveFileRequest {
1449 workspace_id: String,
1450 file_path: String,
1451 content: String,
1452}
1453
1454#[derive(Serialize)]
1455struct SaveFileResponse {
1456 success: bool,
1457 message: String,
1458}
1459
1460async fn save_file_handler(
1461 State(state): State<AppState>,
1462 Json(payload): Json<SaveFileRequest>,
1463) -> impl IntoResponse {
1464 let ws = match state.workspace_registry.get(&payload.workspace_id) {
1465 Some(w) => w,
1466 None => {
1467 return Json(SaveFileResponse {
1468 success: false,
1469 message: "Workspace not found".into(),
1470 })
1471 .into_response()
1472 }
1473 };
1474
1475 if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
1476 return Json(SaveFileResponse {
1477 success: false,
1478 message: "Edit feature is not enabled".into(),
1479 })
1480 .into_response();
1481 }
1482
1483 let decoded = match urlencoding::decode(&payload.file_path) {
1484 Ok(p) => p,
1485 Err(_) => {
1486 return Json(SaveFileResponse {
1487 success: false,
1488 message: "Invalid file path encoding".into(),
1489 })
1490 .into_response()
1491 }
1492 };
1493
1494 let decoded_path = std::path::Path::new(decoded.as_ref());
1495 let full_path = if decoded_path.is_absolute() {
1496 decoded_path.to_path_buf()
1497 } else {
1498 ws.root.join(decoded.trim_start_matches('/'))
1499 };
1500 let canonical = match full_path.canonicalize() {
1501 Ok(p) => p,
1502 Err(_) => {
1503 return Json(SaveFileResponse {
1504 success: false,
1505 message: format!("File not found: {decoded}"),
1506 })
1507 .into_response()
1508 }
1509 };
1510
1511 if !canonical.starts_with(&ws.root) {
1512 return Json(SaveFileResponse {
1513 success: false,
1514 message: "Access denied".into(),
1515 })
1516 .into_response();
1517 }
1518 if ws.is_ephemeral() {
1522 let rel = canonical
1523 .strip_prefix(&ws.root)
1524 .map(|p| p.to_string_lossy().to_string())
1525 .unwrap_or_default();
1526 if !ws.allows(&rel) {
1527 return Json(SaveFileResponse {
1528 success: false,
1529 message: "Access denied".into(),
1530 })
1531 .into_response();
1532 }
1533 }
1534 if !canonical.is_file() {
1535 return Json(SaveFileResponse {
1536 success: false,
1537 message: "Path is not a file".into(),
1538 })
1539 .into_response();
1540 }
1541 if canonical
1542 .extension()
1543 .is_none_or(|e| e.to_string_lossy().to_lowercase() != "md")
1544 {
1545 return Json(SaveFileResponse {
1546 success: false,
1547 message: "Only .md files can be edited".into(),
1548 })
1549 .into_response();
1550 }
1551 match fs::write(&canonical, &payload.content) {
1552 Ok(_) => Json(SaveFileResponse {
1553 success: true,
1554 message: "File saved successfully".into(),
1555 })
1556 .into_response(),
1557 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Json(SaveFileResponse {
1558 success: false,
1559 message: "File is read-only".into(),
1560 })
1561 .into_response(),
1562 Err(e) => Json(SaveFileResponse {
1563 success: false,
1564 message: format!("Failed to save: {e}"),
1565 })
1566 .into_response(),
1567 }
1568}
1569
1570#[derive(Deserialize)]
1573struct PreviewRequest {
1574 content: String,
1575}
1576
1577#[derive(Serialize)]
1578struct PreviewResponse {
1579 html: String,
1580 has_mermaid: bool,
1581}
1582
1583async fn preview_handler(
1584 State(state): State<AppState>,
1585 Json(payload): Json<PreviewRequest>,
1586) -> impl IntoResponse {
1587 let renderer = MarkdownRenderer::new(&state.theme);
1588 let (html, has_mermaid, _toc) = renderer.render(&payload.content);
1589 Json(PreviewResponse { html, has_mermaid }).into_response()
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594 use super::*;
1595 use serde_json::json;
1596
1597 use axum::http::HeaderMap;
1598 use std::net::{IpAddr, Ipv4Addr};
1599
1600 fn headers_with(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
1601 let mut h = HeaderMap::new();
1602 if let Some(o) = origin {
1603 h.insert("origin", o.parse().unwrap());
1604 }
1605 if let Some(host) = host {
1606 h.insert("host", host.parse().unwrap());
1607 }
1608 h
1609 }
1610
1611 fn loopback() -> SocketAddr {
1612 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1618)
1613 }
1614
1615 fn lan_peer() -> SocketAddr {
1616 SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 51234)
1617 }
1618
1619 #[test]
1620 fn ws_origin_accepts_matching_authority() {
1621 let h = headers_with(Some("http://192.168.1.10:1618"), Some("192.168.1.10:1618"));
1622 assert!(check_ws_origin(&h, &lan_peer()));
1623 }
1624
1625 #[test]
1626 fn ws_origin_rejects_cross_origin() {
1627 let h = headers_with(Some("http://evil.example.com"), Some("192.168.1.10:1618"));
1628 assert!(!check_ws_origin(&h, &lan_peer()));
1629 }
1630
1631 #[test]
1632 fn ws_origin_rejects_port_mismatch() {
1633 let h = headers_with(Some("http://127.0.0.1:9000"), Some("127.0.0.1:1618"));
1634 assert!(!check_ws_origin(&h, &loopback()));
1635 }
1636
1637 #[test]
1638 fn ws_origin_rejects_null_origin() {
1639 let h = headers_with(Some("null"), Some("127.0.0.1:1618"));
1640 assert!(!check_ws_origin(&h, &loopback()));
1641 }
1642
1643 #[test]
1644 fn ws_missing_origin_allowed_only_from_loopback() {
1645 let h = headers_with(None, Some("127.0.0.1:1618"));
1646 assert!(check_ws_origin(&h, &loopback()));
1647 assert!(!check_ws_origin(&h, &lan_peer()));
1648 }
1649
1650 #[test]
1651 fn ws_origin_case_insensitive_host_match() {
1652 let h = headers_with(
1653 Some("http://Example.Local:1618"),
1654 Some("example.local:1618"),
1655 );
1656 assert!(check_ws_origin(&h, &loopback()));
1657 }
1658
1659 #[test]
1660 fn ws_origin_with_trailing_path_still_matches_authority() {
1661 let h = headers_with(Some("http://127.0.0.1:1618/"), Some("127.0.0.1:1618"));
1663 assert!(check_ws_origin(&h, &loopback()));
1664 }
1665
1666 #[test]
1667 fn ws_file_path_accepts_normal_paths() {
1668 assert!(is_valid_ws_file_path("notes/intro.md"));
1669 assert!(is_valid_ws_file_path("README.md"));
1670 assert!(is_valid_ws_file_path("docs/api/index.html"));
1671 }
1672
1673 #[test]
1674 fn ws_file_path_rejects_parent_traversal() {
1675 assert!(!is_valid_ws_file_path("../etc/passwd"));
1676 assert!(!is_valid_ws_file_path("notes/../../etc/passwd"));
1677 }
1678
1679 #[test]
1680 fn ws_file_path_rejects_absolute_path() {
1681 assert!(!is_valid_ws_file_path("/etc/passwd"));
1682 }
1683
1684 #[test]
1685 fn ws_file_path_rejects_nul_byte_and_empty() {
1686 assert!(!is_valid_ws_file_path(""));
1687 assert!(!is_valid_ws_file_path("a\0b"));
1688 }
1689
1690 #[test]
1691 fn ws_file_path_rejects_overlong() {
1692 let big = "a".repeat(1025);
1693 assert!(!is_valid_ws_file_path(&big));
1694 }
1695
1696 #[test]
1697 fn test_websocket_message_serialization() {
1698 let msg = WebSocketMessage::LiveAction {
1699 data: json!({
1700 "clientId": "test-id",
1701 "action": "scroll_to",
1702 "xpath": "/p[1]",
1703 "offset": 0.5
1704 }),
1705 };
1706 let serialized = serde_json::to_string(&msg).unwrap();
1707 assert!(serialized.contains("\"type\":\"live_action\""));
1708 assert!(serialized.contains("\"clientId\":\"test-id\""));
1709 }
1710
1711 #[test]
1715 fn test_new_annotation_op_id_round_trip() {
1716 let with = WebSocketMessage::NewAnnotation {
1718 annotation: json!({ "id": "anno-1", "text": "hi" }),
1719 op_id: Some("op-abc".into()),
1720 };
1721 let json_with = serde_json::to_string(&with).unwrap();
1722 assert!(
1723 json_with.contains("\"op_id\":\"op-abc\""),
1724 "wire form should include op_id: {json_with}"
1725 );
1726 let parsed: WebSocketMessage = serde_json::from_str(&json_with).unwrap();
1727 match parsed {
1728 WebSocketMessage::NewAnnotation { op_id, .. } => {
1729 assert_eq!(op_id.as_deref(), Some("op-abc"));
1730 }
1731 _ => panic!("expected NewAnnotation"),
1732 }
1733
1734 let without = WebSocketMessage::NewAnnotation {
1736 annotation: json!({ "id": "anno-2" }),
1737 op_id: None,
1738 };
1739 let json_without = serde_json::to_string(&without).unwrap();
1740 assert!(
1741 !json_without.contains("op_id"),
1742 "wire form should omit op_id when None: {json_without}"
1743 );
1744
1745 let legacy = r#"{"type":"new_annotation","annotation":{"id":"x"}}"#;
1747 let parsed_legacy: WebSocketMessage = serde_json::from_str(legacy).unwrap();
1748 match parsed_legacy {
1749 WebSocketMessage::NewAnnotation { op_id, .. } => assert!(op_id.is_none()),
1750 _ => panic!("expected NewAnnotation"),
1751 }
1752 }
1753
1754 #[test]
1755 fn test_app_state_identity() {
1756 let (tx, _) = tokio::sync::mpsc::channel(1);
1757 let registry = Arc::new(crate::workspace::WorkspaceRegistry::new("salt".into()));
1758 let state = AppState {
1759 theme: Arc::new("dark".into()),
1760 tera: Arc::new(Tera::default()),
1761 shared_annotation: true,
1762 db: None,
1763 tx: None,
1764 workspace_registry: registry,
1765 management_token: Arc::new("token".into()),
1766 i18n_json: Arc::new("{}".into()),
1767 i18n_lang: Arc::new("zh".into()),
1768 shortcuts_json: Arc::new("{}".into()),
1769 styles_css: Arc::new("".into()),
1770 default_chat_mode: Arc::new("in_page".into()),
1771 print_collapsed_content: false,
1772 shutdown_tx: tx,
1773 #[cfg(debug_assertions)]
1774 dev_reload_tx: Arc::new(broadcast::channel::<()>(1).0),
1775 };
1776 assert_eq!(state.management_token.as_str(), "token");
1777 }
1778
1779 #[test]
1780 fn browser_base_url_uses_localhost_for_wildcard_binds() {
1781 assert_eq!(browser_base_url("0.0.0.0", 6419), "http://127.0.0.1:6419");
1782 assert_eq!(browser_base_url("::", 6419), "http://127.0.0.1:6419");
1783 }
1784
1785 #[test]
1786 fn browser_base_url_preserves_specific_hosts() {
1787 assert_eq!(
1788 browser_base_url("192.168.1.20", 6419),
1789 "http://192.168.1.20:6419"
1790 );
1791 assert_eq!(browser_base_url("::1", 6419), "http://[::1]:6419");
1792 }
1793}