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