Skip to main content

markon_core/
server.rs

1use axum::{
2    extract::{
3        ws::{Message, WebSocket},
4        Path as AxumPath, State, WebSocketUpgrade,
5    },
6    http::{header, StatusCode},
7    response::{Html, IntoResponse, 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
32/// Initial workspace for the server (one per CLI path / GUI workspace entry).
33pub struct WorkspaceInit {
34    pub path: std::path::PathBuf,
35    pub flags: WorkspaceFlags,
36    /// Path within this workspace to open in the browser (e.g. "notes/file.md").
37    pub initial_path: Option<String>,
38}
39
40/// Server configuration
41pub struct ServerConfig {
42    pub host: String,
43    pub port: u16,
44    pub theme: String,
45    pub qr: Option<String>,
46    pub open_browser: Option<String>,
47    pub shared_annotation: bool,
48    /// Random salt for workspace ID generation; None = auto-generate.
49    pub salt: Option<String>,
50    pub initial_workspaces: Vec<WorkspaceInit>,
51    /// Pre-bound listener (GUI mode): server adopts this instead of binding fresh,
52    /// eliminating the TOCTOU race between port discovery and actual bind.
53    pub bound_listener: Option<std::net::TcpListener>,
54    /// Externally-owned registry (GUI mode): share the same registry between
55    /// the Tauri commands and the HTTP server so additions are immediately visible.
56    pub registry: Option<Arc<WorkspaceRegistry>>,
57    /// Management API token. None = auto-generate and write to lock file.
58    pub management_token: Option<String>,
59    /// UI language override: "zh", "en", or None (auto-detect via sys_locale).
60    pub language: Option<String>,
61    /// Custom keyboard shortcut overrides (JSON object, injected into browser pages).
62    pub shortcuts_json: Option<String>,
63    /// Custom CSS variable overrides (rendered as :root { --markon-*: value }).
64    pub styles_css: Option<String>,
65}
66
67#[derive(Clone)]
68pub struct AppState {
69    pub theme: Arc<String>,
70    pub tera: Arc<Tera>,
71    pub shared_annotation: bool,
72    pub db: Option<Arc<Mutex<Connection>>>,
73    pub tx: Option<broadcast::Sender<String>>,
74    pub workspace_registry: Arc<WorkspaceRegistry>,
75    pub management_token: Arc<String>,
76    /// Pre-built i18n JSON string for injection into templates.
77    pub i18n_json: Arc<String>,
78    /// Resolved UI language ("zh" or "en").
79    pub i18n_lang: Arc<String>,
80    /// Keyboard shortcut overrides JSON (empty string if none).
81    pub shortcuts_json: Arc<String>,
82    /// CSS variable overrides string.
83    pub styles_css: Arc<String>,
84    /// Shutdown channel.
85    pub shutdown_tx: mpsc::Sender<()>,
86}
87
88async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
89    let _ = state.shutdown_tx.send(()).await;
90    StatusCode::OK
91}
92
93fn detect_lang(override_lang: &Option<String>) -> String {
94    match override_lang {
95        Some(lang) => i18n::resolve_lang(lang).to_string(),
96        None => i18n::resolve_lang("auto").to_string(),
97    }
98}
99
100fn print_compact_qr(data: &str) -> Result<(), Box<dyn std::error::Error>> {
101    // Use low error correction level for smaller QR codes
102    let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::L)?;
103
104    // Render using Dense1x2 (Unicode half-blocks: ▀▄█) for compact display
105    // Dense1x2 uses 2 vertical pixels per character (half-block characters)
106    // This naturally compensates for terminal fonts where character height > width
107    // Note: The aspect ratio depends on the terminal font - it won't be perfect square
108    // on all terminals, but Dense1x2 provides the best balance between size and readability
109    let string = code
110        .render::<Dense1x2>()
111        .quiet_zone(false) // No quiet zone to save space
112        .build();
113
114    // Add spacing: 4 spaces on the left, blank line below
115    for line in string.lines() {
116        println!("    {line}"); // 4 spaces on the left
117    }
118    println!(); // Blank line below
119
120    Ok(())
121}
122
123#[derive(Serialize, Deserialize, Debug)]
124#[serde(tag = "type")]
125enum WebSocketMessage {
126    #[serde(rename = "all_annotations")]
127    AllAnnotations { annotations: Vec<serde_json::Value> },
128    #[serde(rename = "new_annotation")]
129    NewAnnotation { annotation: serde_json::Value },
130    #[serde(rename = "delete_annotation")]
131    DeleteAnnotation { id: String },
132    #[serde(rename = "clear_annotations")]
133    ClearAnnotations,
134    #[serde(rename = "viewed_state")]
135    ViewedState { state: serde_json::Value },
136    #[serde(rename = "update_viewed_state")]
137    UpdateViewedState { state: serde_json::Value },
138    #[serde(rename = "live_action")]
139    LiveAction { data: serde_json::Value },
140}
141
142pub async fn start(config: ServerConfig) -> Result<(), String> {
143    let ServerConfig {
144        host,
145        port,
146        theme,
147        qr,
148        open_browser,
149        shared_annotation,
150        salt,
151        initial_workspaces,
152        bound_listener,
153        registry,
154        management_token,
155        language,
156        shortcuts_json,
157        styles_css,
158    } = config;
159
160    // Initialize Tera template engine from embedded resources.
161    let mut tera = Tera::default();
162    for file_name in Templates::iter() {
163        if let Some(file) = Templates::get(&file_name) {
164            match std::str::from_utf8(&file.data) {
165                Ok(content) => {
166                    if let Err(e) = tera.add_raw_template(&file_name, content) {
167                        return Err(format!("Failed to add template '{file_name}': {e}"));
168                    }
169                }
170                Err(e) => {
171                    return Err(format!("Failed to read template '{file_name}': {e}"));
172                }
173            }
174        }
175    }
176
177    // A broadcast channel (for WebSocket fan-out) is needed whenever either
178    // shared_annotation or Live is active. The SQLite-backed annotation DB is
179    // only required by shared_annotation; Live is fire-and-forget broadcast.
180    let has_live = initial_workspaces.iter().any(|w| w.flags.enable_live);
181    let needs_ws = shared_annotation || has_live;
182    let db = if shared_annotation {
183        let db_path = std::env::var("MARKON_SQLITE_PATH").unwrap_or_else(|_| {
184            let home = dirs::home_dir().expect("Cannot find home directory");
185            home.join(".markon/annotation.sqlite")
186                .to_string_lossy()
187                .to_string()
188        });
189        let parent_dir = std::path::Path::new(&db_path).parent().unwrap();
190        if !parent_dir.exists() {
191            fs::create_dir_all(parent_dir).expect("Failed to create database directory");
192        }
193        let conn = Connection::open(&db_path).expect("Failed to open database");
194        conn.execute(
195            "CREATE TABLE IF NOT EXISTS annotations (
196                id TEXT PRIMARY KEY,
197                file_path TEXT NOT NULL,
198                data TEXT NOT NULL
199            )",
200            [],
201        )
202        .expect("Failed to create annotations table");
203        conn.execute(
204            "CREATE TABLE IF NOT EXISTS viewed_state (
205                file_path TEXT PRIMARY KEY,
206                state TEXT NOT NULL,
207                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
208            )",
209            [],
210        )
211        .expect("Failed to create viewed_state table");
212        Some(Arc::new(Mutex::new(conn)))
213    } else {
214        None
215    };
216    let tx = needs_ws.then(|| broadcast::channel(100).0);
217
218    // Build workspace registry and register initial workspaces.
219    let effective_salt = salt.unwrap_or_else(|| format!("markon:{port}"));
220    let registry = registry.unwrap_or_else(|| Arc::new(WorkspaceRegistry::new(effective_salt)));
221
222    // Track first workspace's URL path for browser/QR.
223    let mut first_workspace_url_path: Option<String> = None;
224
225    for ws_init in initial_workspaces {
226        let path = expand_and_canonicalize(&ws_init.path.to_string_lossy())
227            .unwrap_or_else(|_| ws_init.path.clone());
228        let id = registry.add(WorkspaceConfig {
229            path,
230            flags: ws_init.flags,
231        });
232        if first_workspace_url_path.is_none() {
233            let url_path = match ws_init.initial_path {
234                Some(ref p) => format!("/{id}/{}", p.trim_start_matches('/')),
235                None => format!("/{id}/"),
236            };
237            first_workspace_url_path = Some(url_path);
238        }
239    }
240
241    let token = Arc::new(management_token.unwrap_or_else(generate_token));
242
243    let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
244
245    let state = AppState {
246        theme: Arc::new(theme),
247        tera: Arc::new(tera),
248        shared_annotation,
249        db,
250        tx,
251        workspace_registry: registry,
252        management_token: token.clone(),
253        i18n_json: Arc::new(i18n::load_i18n()),
254        i18n_lang: Arc::new(detect_lang(&language)),
255        // Default to "null" (valid JS literal) so `= {{ shortcuts_json | safe }};`
256        // renders as `= null;` when no overrides; an empty string would produce
257        // `= ;`, a syntax error that silently breaks i18n and shortcut runtime.
258        shortcuts_json: Arc::new(shortcuts_json.unwrap_or_else(|| "null".to_string())),
259        styles_css: Arc::new(styles_css.unwrap_or_default()),
260        shutdown_tx,
261    };
262
263    // Management API: requires loopback source IP + valid token header.
264    let mgmt = Router::new()
265        .route("/api/workspace", post(add_workspace_handler))
266        .route(
267            "/api/workspace/{id}",
268            delete(remove_workspace_handler).put(update_workspace_handler),
269        )
270        .route("/api/workspaces", get(list_workspaces_handler))
271        .route("/api/save", post(save_file_handler))
272        .route("/api/shutdown", post(shutdown_handler))
273        .layer(axum::middleware::from_fn_with_state(
274            state.clone(),
275            require_local_and_token,
276        ));
277
278    let mut app = Router::new()
279        // Static assets (literal prefix beats /{workspace_id}/ param)
280        .route("/favicon.ico", get(serve_favicon))
281        .route("/_/favicon.ico", get(serve_favicon))
282        .route("/_/favicon.svg", get(serve_favicon_svg))
283        .route("/_/css/{filename}", get(serve_css))
284        .route("/_/js/{*path}", get(serve_js))
285        .route("/_/ws/{workspace_id}", get(config_ws_handler))
286        // Search API (read-only, no auth needed)
287        .route("/search", get(search_handler))
288        // Workspace content routes
289        .route("/{workspace_id}/", get(handle_workspace_root))
290        .route("/{workspace_id}/{*path}", get(handle_workspace_path))
291        // Everything else → 404
292        .fallback(|| async { StatusCode::NOT_FOUND })
293        .merge(mgmt);
294
295    if needs_ws {
296        app = app.route("/_/ws", get(ws_handler));
297    }
298
299    let app = app.with_state(state);
300
301    let listener = if let Some(std_listener) = bound_listener {
302        std_listener
303            .set_nonblocking(true)
304            .map_err(|e| format!("Failed to set non-blocking: {e}"))?;
305        TcpListener::from_std(std_listener)
306            .map_err(|e| format!("Failed to convert listener: {e}"))?
307    } else {
308        let addr = format!("{}:{}", host, port)
309            .parse::<SocketAddr>()
310            .map_err(|e| format!("Invalid host address '{}': {}", host, e))?;
311        TcpListener::bind(&addr)
312            .await
313            .map_err(|e| format!("Failed to bind to {addr}: {e}"))?
314    };
315    let addr = listener
316        .local_addr()
317        .map_err(|e| format!("Failed to get local address: {e}"))?;
318    println!("listening on http://{addr}");
319    if let Some(ref p) = first_workspace_url_path {
320        println!("workspace: http://{addr}{p}");
321    }
322
323    // Write lock file so CLI can discover this server.
324    let _lock_guard = {
325        if let Err(e) = (ServerLock {
326            port: addr.port(),
327            token: token.as_ref().clone(),
328        })
329        .write()
330        {
331            eprintln!("[server] Failed to write lock file: {e}");
332        }
333        struct LockGuard;
334        impl Drop for LockGuard {
335            fn drop(&mut self) {
336                ServerLock::remove();
337            }
338        }
339        LockGuard
340    };
341
342    // Helper: build a full URL from a base option string.
343    let make_url = |base_option: &str, ws_path: &Option<String>| -> String {
344        let base = if base_option == "local" {
345            format!("http://{addr}")
346        } else {
347            base_option.to_string()
348        };
349        match ws_path {
350            Some(p) => format!("{}{}", base.trim_end_matches('/'), p),
351            None => format!("{}/", base.trim_end_matches('/')),
352        }
353    };
354
355    let custom_base = qr
356        .as_ref()
357        .filter(|u| u.as_str() != "missing")
358        .or_else(|| open_browser.as_ref().filter(|u| u.as_str() != "local"));
359    if let Some(base) = custom_base {
360        println!(
361            "accessible at {}",
362            make_url(base, &first_workspace_url_path)
363        );
364    }
365
366    if let Some(ref qr_option) = qr {
367        println!();
368        let qr_url = if qr_option == "missing" {
369            format!("http://{addr}")
370        } else {
371            make_url(qr_option, &first_workspace_url_path)
372        };
373        if let Err(e) = print_compact_qr(&qr_url) {
374            eprintln!("Failed to generate QR code: {e}");
375        }
376    }
377
378    if let Some(ref base_opt) = open_browser {
379        let url = make_url(base_opt, &first_workspace_url_path);
380        if let Err(e) = open::that(&url) {
381            eprintln!("[info] Best-effort browser open failed: {e}");
382        }
383    }
384
385    axum::serve(
386        listener,
387        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
388    )
389    .with_graceful_shutdown(async move {
390        shutdown_rx.recv().await;
391        println!("Shutting down...");
392    })
393    .await
394    .map_err(|e| format!("Server error: {e}"))?;
395    Ok(())
396}
397
398/// Lightweight always-on WebSocket per workspace — pushes a "reload" text frame
399/// whenever workspace flags change. No auth required (read-only notification).
400async fn config_ws_handler(
401    ws: WebSocketUpgrade,
402    State(state): State<AppState>,
403    AxumPath(workspace_id): AxumPath<String>,
404) -> impl IntoResponse {
405    let Some(ws_entry) = state.workspace_registry.get(&workspace_id) else {
406        return StatusCode::NOT_FOUND.into_response();
407    };
408    let mut rx = ws_entry.config_tx.subscribe();
409    ws.on_upgrade(move |mut socket| async move {
410        while let Ok(()) = rx.recv().await {
411            if socket
412                .send(axum::extract::ws::Message::Text("reload".into()))
413                .await
414                .is_err()
415            {
416                break;
417            }
418        }
419    })
420}
421
422/// Middleware: management API only accepts loopback source + valid token header.
423async fn require_local_and_token(
424    State(state): State<AppState>,
425    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
426    req: axum::extract::Request,
427    next: axum::middleware::Next,
428) -> Response {
429    if !addr.ip().is_loopback() {
430        return StatusCode::FORBIDDEN.into_response();
431    }
432    let ok = req
433        .headers()
434        .get("X-Markon-Token")
435        .and_then(|v| v.to_str().ok())
436        .map(|t| t == state.management_token.as_str())
437        .unwrap_or(false);
438    if !ok {
439        return StatusCode::UNAUTHORIZED.into_response();
440    }
441    next.run(req).await
442}
443
444async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
445    ws.on_upgrade(|socket| handle_socket(socket, state))
446}
447
448fn load_annotations(db: &Mutex<Connection>, file_path: &str) -> Vec<serde_json::Value> {
449    let db = db.lock().unwrap();
450    let mut stmt = match db.prepare("SELECT data FROM annotations WHERE file_path = ?1") {
451        Ok(s) => s,
452        Err(e) => {
453            eprintln!("[WebSocket] prepare failed: {e}");
454            return Vec::new();
455        }
456    };
457    let rows = match stmt.query_map([file_path], |row| row.get::<_, String>(0)) {
458        Ok(r) => r,
459        Err(e) => {
460            eprintln!("[WebSocket] query_map failed: {e}");
461            return Vec::new();
462        }
463    };
464    rows.filter_map(Result::ok)
465        .filter_map(|s| serde_json::from_str(&s).ok())
466        .collect()
467}
468
469fn load_viewed_state(db: &Mutex<Connection>, file_path: &str) -> serde_json::Value {
470    let db = db.lock().unwrap();
471    let state_json = db
472        .query_row(
473            "SELECT state FROM viewed_state WHERE file_path = ?1",
474            [file_path],
475            |row| row.get::<_, String>(0),
476        )
477        .unwrap_or_else(|_| "{}".to_string());
478    serde_json::from_str(&state_json).unwrap_or_else(|_| serde_json::json!({}))
479}
480
481async fn send_json(
482    sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
483    msg: &WebSocketMessage,
484) -> Result<(), ()> {
485    let Ok(encoded) = serde_json::to_string(msg) else {
486        return Err(());
487    };
488    sender
489        .send(Message::Text(encoded.into()))
490        .await
491        .map_err(|_| ())
492}
493
494fn broadcast_msg(tx: &broadcast::Sender<String>, msg: &WebSocketMessage) {
495    if let Ok(encoded) = serde_json::to_string(msg) {
496        let _ = tx.send(encoded);
497    }
498}
499
500fn handle_client_msg(
501    db: Option<&Mutex<Connection>>,
502    tx: &broadcast::Sender<String>,
503    file_path: &str,
504    msg: WebSocketMessage,
505) {
506    // LiveAction is pure broadcast — no DB needed. Handle it before the DB
507    // short-circuit so Live works in workspaces where shared_annotation is off.
508    if let WebSocketMessage::LiveAction { data } = msg {
509        broadcast_msg(tx, &WebSocketMessage::LiveAction { data });
510        return;
511    }
512    let Some(db) = db else { return };
513    let db = db.lock().unwrap();
514    match msg {
515        WebSocketMessage::NewAnnotation { annotation } => {
516            let Some(id) = annotation["id"].as_str().map(str::to_owned) else {
517                return;
518            };
519            let Ok(data) = serde_json::to_string(&annotation) else {
520                return;
521            };
522            if let Err(e) = db.execute(
523                "INSERT OR REPLACE INTO annotations (id, file_path, data) VALUES (?1, ?2, ?3)",
524                [id.as_str(), file_path, data.as_str()],
525            ) {
526                eprintln!("[WebSocket] insert annotation failed: {e}");
527                return;
528            }
529            broadcast_msg(tx, &WebSocketMessage::NewAnnotation { annotation });
530        }
531        WebSocketMessage::DeleteAnnotation { id } => {
532            if let Err(e) = db.execute(
533                "DELETE FROM annotations WHERE id = ?1 AND file_path = ?2",
534                [id.as_str(), file_path],
535            ) {
536                eprintln!("[WebSocket] delete annotation failed: {e}");
537                return;
538            }
539            broadcast_msg(tx, &WebSocketMessage::DeleteAnnotation { id });
540        }
541        WebSocketMessage::ClearAnnotations => {
542            eprintln!("[WebSocket] Clearing annotations for file_path: {file_path}");
543            if let Err(e) = db.execute("DELETE FROM annotations WHERE file_path = ?1", [file_path])
544            {
545                eprintln!("[WebSocket] clear annotations failed: {e}");
546            }
547            if let Err(e) = db.execute("DELETE FROM viewed_state WHERE file_path = ?1", [file_path])
548            {
549                eprintln!("[WebSocket] clear viewed_state failed: {e}");
550            }
551            broadcast_msg(tx, &WebSocketMessage::ClearAnnotations);
552            broadcast_msg(
553                tx,
554                &WebSocketMessage::ViewedState {
555                    state: serde_json::Value::Object(serde_json::Map::new()),
556                },
557            );
558        }
559        WebSocketMessage::UpdateViewedState { state: viewed } => {
560            let Ok(state_json) = serde_json::to_string(&viewed) else {
561                return;
562            };
563            if let Err(e) = db.execute(
564                "INSERT OR REPLACE INTO viewed_state (file_path, state, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
565                [file_path, state_json.as_str()],
566            ) {
567                eprintln!("[WebSocket] update viewed_state failed: {e}");
568                return;
569            }
570            broadcast_msg(tx, &WebSocketMessage::ViewedState { state: viewed });
571        }
572        _ => {}
573    }
574}
575
576async fn handle_socket(socket: WebSocket, state: AppState) {
577    let (mut sender, mut receiver) = socket.split();
578
579    // tx is required (broadcast fan-out). db is optional — only present when
580    // shared_annotation is on; absent when only Live is active.
581    let Some(tx) = state.tx.clone() else {
582        return;
583    };
584    let db = state.db.clone();
585    let mut rx = tx.subscribe();
586
587    let file_path = match receiver.next().await {
588        Some(Ok(Message::Text(text))) => text.to_string(),
589        _ => {
590            eprintln!("[WebSocket] Failed to receive file path from client");
591            return;
592        }
593    };
594
595    // Only send initial annotation/viewed state when a persistence layer exists.
596    if let Some(db) = db.as_ref() {
597        let annotations = load_annotations(db, &file_path);
598        eprintln!(
599            "[WebSocket] Sending {} annotations for file_path: {}",
600            annotations.len(),
601            file_path
602        );
603        if send_json(
604            &mut sender,
605            &WebSocketMessage::AllAnnotations { annotations },
606        )
607        .await
608        .is_err()
609        {
610            return;
611        }
612        let viewed = load_viewed_state(db, &file_path);
613        if send_json(
614            &mut sender,
615            &WebSocketMessage::ViewedState { state: viewed },
616        )
617        .await
618        .is_err()
619        {
620            return;
621        }
622    }
623
624    let mut send_task = tokio::spawn(async move {
625        while let Ok(msg) = rx.recv().await {
626            if sender.send(Message::Text(msg.into())).await.is_err() {
627                break;
628            }
629        }
630    });
631
632    let mut recv_task = tokio::spawn(async move {
633        while let Some(Ok(Message::Text(text))) = receiver.next().await {
634            let Ok(msg) = serde_json::from_str::<WebSocketMessage>(&text) else {
635                continue;
636            };
637            handle_client_msg(db.as_deref(), &tx, &file_path, msg);
638        }
639    });
640
641    tokio::select! {
642        _ = (&mut send_task) => recv_task.abort(),
643        _ = (&mut recv_task) => send_task.abort(),
644    };
645}
646
647// ── Workspace content handlers ────────────────────────────────────────────────
648
649async fn handle_workspace_root(
650    State(state): State<AppState>,
651    AxumPath(workspace_id): AxumPath<String>,
652) -> impl IntoResponse {
653    let Some(ws) = state.workspace_registry.get(&workspace_id) else {
654        return StatusCode::NOT_FOUND.into_response();
655    };
656    render_directory_listing(&workspace_id, &ws, None, &state)
657}
658
659async fn handle_workspace_path(
660    State(state): State<AppState>,
661    AxumPath((workspace_id, path)): AxumPath<(String, String)>,
662) -> impl IntoResponse {
663    let Some(ws) = state.workspace_registry.get(&workspace_id) else {
664        return StatusCode::NOT_FOUND.into_response();
665    };
666
667    let decoded = urlencoding::decode(&path).unwrap_or_else(|_| path.clone().into());
668    let full_path = ws.root.join(decoded.trim_start_matches('/'));
669
670    let canonical = match full_path.canonicalize() {
671        Ok(p) => p,
672        Err(_) => {
673            return (StatusCode::NOT_FOUND, format!("Path not found: {decoded}")).into_response()
674        }
675    };
676
677    if !canonical.starts_with(&ws.root) {
678        return (StatusCode::FORBIDDEN, "Access denied").into_response();
679    }
680
681    if canonical.is_file() {
682        if canonical
683            .extension()
684            .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md")
685        {
686            render_markdown_file(&canonical.to_string_lossy(), &workspace_id, &ws, &state)
687        } else {
688            serve_file(&canonical)
689        }
690    } else if canonical.is_dir() {
691        render_directory_listing(&workspace_id, &ws, Some(&decoded), &state)
692    } else {
693        (StatusCode::NOT_FOUND, "Path not found").into_response()
694    }
695}
696
697// ── Workspace management API ──────────────────────────────────────────────────
698
699#[derive(Deserialize)]
700struct AddWorkspaceRequest {
701    path: String,
702    #[serde(flatten)]
703    flags: WorkspaceFlags,
704}
705
706#[derive(Serialize)]
707struct AddWorkspaceResponse {
708    id: String,
709}
710
711async fn add_workspace_handler(
712    State(state): State<AppState>,
713    Json(req): Json<AddWorkspaceRequest>,
714) -> impl IntoResponse {
715    let path = match expand_and_canonicalize(&req.path) {
716        Ok(p) => p,
717        Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
718    };
719    let id = state.workspace_registry.add(WorkspaceConfig {
720        path,
721        flags: req.flags,
722    });
723    Json(AddWorkspaceResponse { id }).into_response()
724}
725
726async fn remove_workspace_handler(
727    State(state): State<AppState>,
728    AxumPath(id): AxumPath<String>,
729) -> impl IntoResponse {
730    if state.workspace_registry.remove(&id) {
731        StatusCode::OK
732    } else {
733        StatusCode::NOT_FOUND
734    }
735}
736
737async fn update_workspace_handler(
738    State(state): State<AppState>,
739    AxumPath(id): AxumPath<String>,
740    Json(flags): Json<WorkspaceFlags>,
741) -> impl IntoResponse {
742    if state.workspace_registry.update_flags(&id, flags) {
743        StatusCode::OK
744    } else {
745        StatusCode::NOT_FOUND
746    }
747}
748
749async fn list_workspaces_handler(State(state): State<AppState>) -> impl IntoResponse {
750    Json(state.workspace_registry.info_list())
751}
752
753// ── Search handler ────────────────────────────────────────────────────────────
754
755#[derive(Deserialize)]
756struct WorkspaceSearchQuery {
757    ws: String,
758    #[serde(flatten)]
759    q: SearchQuery,
760}
761
762async fn search_handler(
763    State(state): State<AppState>,
764    axum::extract::Query(query): axum::extract::Query<WorkspaceSearchQuery>,
765) -> impl IntoResponse {
766    if query.q.q.is_empty() {
767        return Json(Vec::<SearchResult>::new());
768    }
769    let Some(ws) = state.workspace_registry.get(&query.ws) else {
770        return Json(Vec::new());
771    };
772    if !ws.enable_search.load(std::sync::atomic::Ordering::Relaxed) {
773        return Json(Vec::new());
774    }
775    let Some(idx) = ws.search_index.load_full() else {
776        return Json(Vec::new()); // still indexing
777    };
778    let results = idx.search(&query.q.q, 20).unwrap_or_else(|e| {
779        eprintln!("[search] error: {e}");
780        Vec::new()
781    });
782    Json(results)
783}
784
785fn render_markdown_file(
786    file_path: &str,
787    workspace_id: &str,
788    ws: &WorkspaceEntry,
789    state: &AppState,
790) -> Response {
791    match fs::read_to_string(file_path) {
792        Ok(markdown_input) => {
793            let renderer = MarkdownRenderer::new(&state.theme);
794            let (html_content, has_mermaid, toc) = renderer.render(&markdown_input);
795
796            let title = std::path::Path::new(file_path)
797                .file_name()
798                .map(|n| n.to_string_lossy().to_string())
799                .unwrap_or_else(|| file_path.to_string());
800
801            let mut context = tera::Context::new();
802            context.insert("title", &format!("markon - {title}"));
803            context.insert("file_path", file_path);
804            context.insert("workspace_id", workspace_id);
805            context.insert("theme", state.theme.as_str());
806            context.insert("content", &html_content);
807            // Back link: parent dir of this file within the workspace.
808            let back_link = std::path::Path::new(file_path)
809                .parent()
810                .and_then(|p| p.strip_prefix(&ws.root).ok())
811                .map(|rel| {
812                    let rel_str = rel.to_string_lossy();
813                    if rel_str.is_empty() {
814                        format!("/{workspace_id}/")
815                    } else {
816                        format!("/{workspace_id}/{}/", rel_str)
817                    }
818                })
819                .unwrap_or_else(|| format!("/{workspace_id}/"));
820            context.insert("back_link", &back_link);
821            context.insert("show_back_link", &true);
822            context.insert("has_mermaid", &has_mermaid);
823            context.insert("toc", &toc);
824            let flags = ws.flags();
825            context.insert("shared_annotation", &flags.shared_annotation);
826            context.insert("enable_viewed", &flags.enable_viewed);
827            context.insert("enable_search", &flags.enable_search);
828            context.insert("enable_edit", &flags.enable_edit);
829            context.insert("enable_live", &flags.enable_live);
830
831            if flags.enable_edit {
832                // JSON-encode and HTML-escape so </script> in content can't break the page.
833                let json = serde_json::to_string(&markdown_input)
834                    .unwrap_or_default()
835                    .replace('<', "\\u003c")
836                    .replace('>', "\\u003e")
837                    .replace('&', "\\u0026");
838                context.insert("markdown_content_json", &json);
839                context.insert("management_token", state.management_token.as_str());
840            }
841
842            context.insert("i18n_json", state.i18n_json.as_str());
843            context.insert("i18n_lang", state.i18n_lang.as_str());
844            context.insert("shortcuts_json", state.shortcuts_json.as_str());
845            context.insert("styles_css", state.styles_css.as_str());
846
847            match state.tera.render("layout.html", &context) {
848                Ok(html) => Html(html).into_response(),
849                Err(e) => (
850                    StatusCode::INTERNAL_SERVER_ERROR,
851                    format!("Template error: {e}"),
852                )
853                    .into_response(),
854            }
855        }
856        Err(e) => {
857            let mut context = tera::Context::new();
858            context.insert("title", "Error");
859            context.insert("theme", state.theme.as_str());
860            context.insert(
861                "content",
862                &format!(
863                    r#"<p style="color: red;">Error reading file '{file_path}': {e}</p>
864                       <a href="/">← Back to file list</a>"#
865                ),
866            );
867            context.insert("show_back_link", &false);
868            context.insert("has_mermaid", &false);
869            context.insert("i18n_json", state.i18n_json.as_str());
870            context.insert("i18n_lang", state.i18n_lang.as_str());
871            context.insert("shortcuts_json", state.shortcuts_json.as_str());
872            context.insert("styles_css", state.styles_css.as_str());
873
874            match state.tera.render("layout.html", &context) {
875                Ok(html) => Html(html).into_response(),
876                Err(e) => (
877                    StatusCode::INTERNAL_SERVER_ERROR,
878                    format!("Template error: {e}"),
879                )
880                    .into_response(),
881            }
882        }
883    }
884}
885
886fn render_directory_listing(
887    workspace_id: &str,
888    ws: &WorkspaceEntry,
889    dir_param: Option<&str>,
890    state: &AppState,
891) -> Response {
892    use std::path::PathBuf;
893
894    let current_dir = if let Some(dir_str) = dir_param {
895        let p = PathBuf::from(dir_str);
896        if p.is_absolute() {
897            p
898        } else {
899            ws.root.join(&p)
900        }
901    } else {
902        ws.root.clone()
903    };
904
905    let current_dir = match current_dir.canonicalize() {
906        Ok(p) => p,
907        Err(e) => {
908            return (StatusCode::BAD_REQUEST, format!("Invalid directory: {e}")).into_response()
909        }
910    };
911
912    #[derive(serde::Serialize)]
913    struct Entry {
914        name: String,
915        is_dir: bool,
916        link: String,
917    }
918
919    let mut entries: Vec<Entry> = match fs::read_dir(&current_dir) {
920        Ok(dir_entries) => dir_entries
921            .filter_map(|e| e.ok())
922            .filter_map(|entry| {
923                let path = entry.path();
924                let name = entry.file_name().to_string_lossy().to_string();
925                if name.starts_with('.') {
926                    return None;
927                }
928                // Use file_type() — avoids stat() syscall that can block on AutoFS mount points.
929                let file_type = match entry.file_type() {
930                    Ok(ft) => ft,
931                    Err(_) => return None,
932                };
933                let is_dir = file_type.is_dir();
934                let rel = path
935                    .strip_prefix(&ws.root)
936                    .unwrap_or(&path)
937                    .to_string_lossy()
938                    .to_string();
939                if is_dir {
940                    Some(Entry {
941                        name,
942                        is_dir: true,
943                        link: format!("/{workspace_id}/{rel}/"),
944                    })
945                } else {
946                    let is_md = path
947                        .extension()
948                        .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md");
949                    if is_md {
950                        Some(Entry {
951                            name,
952                            is_dir: false,
953                            link: format!("/{workspace_id}/{rel}"),
954                        })
955                    } else {
956                        None
957                    }
958                }
959            })
960            .collect(),
961        Err(e) => {
962            return (
963                StatusCode::INTERNAL_SERVER_ERROR,
964                format!("Error reading directory: {e}"),
965            )
966                .into_response()
967        }
968    };
969
970    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
971        (true, false) => std::cmp::Ordering::Less,
972        (false, true) => std::cmp::Ordering::Greater,
973        _ => a.name.cmp(&b.name),
974    });
975
976    let show_parent = current_dir != ws.root;
977    let parent_link: Option<String> = if show_parent {
978        current_dir.parent().map(|parent| {
979            let rel = parent
980                .strip_prefix(&ws.root)
981                .map(|p| p.to_string_lossy().to_string())
982                .unwrap_or_default();
983            if rel.is_empty() {
984                format!("/{workspace_id}/")
985            } else {
986                format!("/{workspace_id}/{rel}/")
987            }
988        })
989    } else {
990        None
991    };
992
993    let mut context = tera::Context::new();
994    context.insert("theme", state.theme.as_str());
995    context.insert("workspace_id", workspace_id);
996    context.insert("current_dir", &current_dir.display().to_string());
997    context.insert("entries", &entries);
998    context.insert("show_parent", &show_parent);
999    context.insert("parent_link", &parent_link);
1000    context.insert(
1001        "enable_search",
1002        &ws.enable_search.load(std::sync::atomic::Ordering::Relaxed),
1003    );
1004    context.insert("i18n_json", state.i18n_json.as_str());
1005    context.insert("i18n_lang", state.i18n_lang.as_str());
1006    context.insert("shortcuts_json", state.shortcuts_json.as_str());
1007    context.insert("styles_css", state.styles_css.as_str());
1008
1009    match state.tera.render("directory.html", &context) {
1010        Ok(html) => Html(html).into_response(),
1011        Err(e) => (
1012            StatusCode::INTERNAL_SERVER_ERROR,
1013            format!("Template error: {e}"),
1014        )
1015            .into_response(),
1016    }
1017}
1018
1019async fn serve_favicon() -> impl IntoResponse {
1020    // Redirect /_/favicon.ico to /_/favicon.svg
1021    (
1022        StatusCode::MOVED_PERMANENTLY,
1023        [(header::LOCATION, "/_/favicon.svg")],
1024    )
1025        .into_response()
1026}
1027
1028async fn serve_favicon_svg() -> impl IntoResponse {
1029    serve_static_file("favicon.svg", IconAssets::get, "image/svg+xml")
1030}
1031
1032async fn serve_css(AxumPath(filename): AxumPath<String>) -> impl IntoResponse {
1033    serve_static_file(&filename, CssAssets::get, "text/css")
1034}
1035
1036async fn serve_js(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
1037    serve_static_file(&path, JsAssets::get, "application/javascript")
1038}
1039
1040fn serve_static_file<F>(filename: &str, getter: F, content_type: &str) -> Response
1041where
1042    F: FnOnce(&str) -> Option<rust_embed::EmbeddedFile>,
1043{
1044    match getter(filename) {
1045        Some(file) => (
1046            StatusCode::OK,
1047            [(header::CONTENT_TYPE, content_type)],
1048            file.data.into_owned(),
1049        )
1050            .into_response(),
1051        None => (StatusCode::NOT_FOUND, "File not found").into_response(),
1052    }
1053}
1054
1055fn serve_file(path: &std::path::Path) -> Response {
1056    match fs::read(path) {
1057        Ok(content) => {
1058            let mime_type = mime_guess::from_path(path)
1059                .first_or_octet_stream()
1060                .essence_str()
1061                .to_string();
1062            (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], content).into_response()
1063        }
1064        Err(e) => (
1065            StatusCode::INTERNAL_SERVER_ERROR,
1066            format!("Error reading file: {e}"),
1067        )
1068            .into_response(),
1069    }
1070}
1071
1072// ── File editing API ──────────────────────────────────────────────────────────
1073
1074#[derive(Deserialize)]
1075struct SaveFileRequest {
1076    workspace_id: String,
1077    file_path: String,
1078    content: String,
1079}
1080
1081#[derive(Serialize)]
1082struct SaveFileResponse {
1083    success: bool,
1084    message: String,
1085}
1086
1087async fn save_file_handler(
1088    State(state): State<AppState>,
1089    Json(payload): Json<SaveFileRequest>,
1090) -> impl IntoResponse {
1091    let ws = match state.workspace_registry.get(&payload.workspace_id) {
1092        Some(w) => w,
1093        None => {
1094            return Json(SaveFileResponse {
1095                success: false,
1096                message: "Workspace not found".into(),
1097            })
1098            .into_response()
1099        }
1100    };
1101
1102    if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
1103        return Json(SaveFileResponse {
1104            success: false,
1105            message: "Edit feature is not enabled".into(),
1106        })
1107        .into_response();
1108    }
1109
1110    let decoded = match urlencoding::decode(&payload.file_path) {
1111        Ok(p) => p,
1112        Err(_) => {
1113            return Json(SaveFileResponse {
1114                success: false,
1115                message: "Invalid file path encoding".into(),
1116            })
1117            .into_response()
1118        }
1119    };
1120
1121    let decoded_path = std::path::Path::new(decoded.as_ref());
1122    let full_path = if decoded_path.is_absolute() {
1123        decoded_path.to_path_buf()
1124    } else {
1125        ws.root.join(decoded.trim_start_matches('/'))
1126    };
1127    let canonical = match full_path.canonicalize() {
1128        Ok(p) => p,
1129        Err(_) => {
1130            return Json(SaveFileResponse {
1131                success: false,
1132                message: format!("File not found: {decoded}"),
1133            })
1134            .into_response()
1135        }
1136    };
1137
1138    if !canonical.starts_with(&ws.root) {
1139        return Json(SaveFileResponse {
1140            success: false,
1141            message: "Access denied".into(),
1142        })
1143        .into_response();
1144    }
1145    if !canonical.is_file() {
1146        return Json(SaveFileResponse {
1147            success: false,
1148            message: "Path is not a file".into(),
1149        })
1150        .into_response();
1151    }
1152    if canonical
1153        .extension()
1154        .is_none_or(|e| e.to_string_lossy().to_lowercase() != "md")
1155    {
1156        return Json(SaveFileResponse {
1157            success: false,
1158            message: "Only .md files can be edited".into(),
1159        })
1160        .into_response();
1161    }
1162    match fs::write(&canonical, &payload.content) {
1163        Ok(_) => Json(SaveFileResponse {
1164            success: true,
1165            message: "File saved successfully".into(),
1166        })
1167        .into_response(),
1168        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Json(SaveFileResponse {
1169            success: false,
1170            message: "File is read-only".into(),
1171        })
1172        .into_response(),
1173        Err(e) => Json(SaveFileResponse {
1174            success: false,
1175            message: format!("Failed to save: {e}"),
1176        })
1177        .into_response(),
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184    use serde_json::json;
1185
1186    #[test]
1187    fn test_websocket_message_serialization() {
1188        let msg = WebSocketMessage::LiveAction {
1189            data: json!({
1190                "clientId": "test-id",
1191                "action": "scroll_to",
1192                "xpath": "/p[1]",
1193                "offset": 0.5
1194            }),
1195        };
1196        let serialized = serde_json::to_string(&msg).unwrap();
1197        assert!(serialized.contains("\"type\":\"live_action\""));
1198        assert!(serialized.contains("\"clientId\":\"test-id\""));
1199    }
1200
1201    #[test]
1202    fn test_app_state_identity() {
1203        let (tx, _) = tokio::sync::mpsc::channel(1);
1204        let registry = Arc::new(crate::workspace::WorkspaceRegistry::new("salt".into()));
1205        let state = AppState {
1206            theme: Arc::new("dark".into()),
1207            tera: Arc::new(Tera::default()),
1208            shared_annotation: true,
1209            db: None,
1210            tx: None,
1211            workspace_registry: registry,
1212            management_token: Arc::new("token".into()),
1213            i18n_json: Arc::new("{}".into()),
1214            i18n_lang: Arc::new("zh".into()),
1215            shortcuts_json: Arc::new("{}".into()),
1216            styles_css: Arc::new("".into()),
1217            shutdown_tx: tx,
1218        };
1219        assert_eq!(state.management_token.as_str(), "token");
1220    }
1221}