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, Redirect, Response},
8    routing::{delete, get, post},
9    Json, Router,
10};
11use futures_util::{stream::StreamExt, SinkExt};
12use qrcode::render::unicode::Dense1x2;
13use qrcode::{EcLevel, QrCode};
14use rusqlite::Connection;
15use serde::{Deserialize, Serialize};
16use std::fs;
17use std::net::SocketAddr;
18use std::sync::{Arc, Mutex};
19use tera::Tera;
20use tokio::net::TcpListener;
21use tokio::sync::{broadcast, mpsc};
22
23use crate::assets::{CssAssets, IconAssets, JsAssets, Templates};
24use crate::i18n;
25use crate::markdown::MarkdownRenderer;
26use crate::search::{SearchQuery, SearchResult};
27use crate::workspace::{
28    expand_and_canonicalize, generate_token, ServerLock, WorkspaceConfig, WorkspaceEntry,
29    WorkspaceFlags, WorkspaceRegistry,
30};
31
32/// Public wire-format types served by the (non-chat) HTTP surface.
33///
34/// Everything re-exported here is part of the JSON contract that the browser
35/// and external API consumers read. The types live in their implementation
36/// modules; this submodule lifts them back into the public API so callers
37/// don't have to chase across modules to know what is and isn't a wire
38/// contract.
39pub mod api {
40    pub use crate::workspace::WorkspaceInfo;
41}
42
43/// Initial workspace for the server (one per CLI path / GUI workspace entry).
44pub struct WorkspaceInit {
45    pub path: std::path::PathBuf,
46    pub flags: WorkspaceFlags,
47    /// Path within this workspace to open in the browser (e.g. "notes/file.md").
48    pub initial_path: Option<String>,
49}
50
51/// Server configuration
52pub struct ServerConfig {
53    pub host: String,
54    pub port: u16,
55    pub theme: String,
56    pub qr: Option<String>,
57    pub open_browser: Option<String>,
58    pub shared_annotation: bool,
59    /// Random salt for workspace ID generation; None = auto-generate.
60    pub salt: Option<String>,
61    pub initial_workspaces: Vec<WorkspaceInit>,
62    /// Pre-bound listener (GUI mode): server adopts this instead of binding fresh,
63    /// eliminating the TOCTOU race between port discovery and actual bind.
64    pub bound_listener: Option<std::net::TcpListener>,
65    /// Externally-owned registry (GUI mode): share the same registry between
66    /// the Tauri commands and the HTTP server so additions are immediately visible.
67    pub registry: Option<Arc<WorkspaceRegistry>>,
68    /// Management API token. None = auto-generate and write to lock file.
69    pub management_token: Option<String>,
70    /// UI language override: "zh", "en", or None (auto-detect via sys_locale).
71    pub language: Option<String>,
72    /// Custom keyboard shortcut overrides (JSON object, injected into browser pages).
73    pub shortcuts_json: Option<String>,
74    /// Custom CSS variable overrides for `--markon-*` design tokens.
75    /// Pre-rendered by `AppSettings::render_styles_css` as a complete CSS
76    /// block carrying its own selectors (`:root { ... }` for light/single-
77    /// value tokens, `html[data-theme="dark"] { ... }` for dark overrides),
78    /// so templates inject it verbatim — no wrapping selector.
79    pub styles_css: Option<String>,
80    /// Default chat surface: "in_page" or "popout". Surfaced to the browser
81    /// via the `default-chat-mode` meta tag.
82    pub default_chat_mode: String,
83    /// When true, collapsed sections are forced visible during print so their
84    /// content ends up on paper. When false (default) the content stays hidden
85    /// and a small placeholder marks the position of the collapsed section.
86    pub print_collapsed_content: bool,
87}
88
89#[derive(Clone)]
90pub(crate) struct AppState {
91    pub theme: Arc<String>,
92    pub tera: Arc<Tera>,
93    #[allow(dead_code)]
94    pub shared_annotation: bool,
95    pub db: Option<Arc<Mutex<Connection>>>,
96    pub tx: Option<broadcast::Sender<String>>,
97    pub workspace_registry: Arc<WorkspaceRegistry>,
98    pub management_token: Arc<String>,
99    /// Pre-built i18n JSON string for injection into templates.
100    pub i18n_json: Arc<String>,
101    /// Resolved UI language ("zh" or "en").
102    pub i18n_lang: Arc<String>,
103    /// Keyboard shortcut overrides JSON (empty string if none).
104    pub shortcuts_json: Arc<String>,
105    /// CSS variable overrides string.
106    pub styles_css: Arc<String>,
107    /// Default chat surface ("in_page" or "popout").
108    pub default_chat_mode: Arc<String>,
109    /// Whether collapsed sections should be printed (true) or replaced by a
110    /// placeholder (false). Mirrored to the browser as a `<html>` data attr.
111    pub print_collapsed_content: bool,
112    /// Shutdown channel.
113    pub shutdown_tx: mpsc::Sender<()>,
114    /// Dev-only: esbuild watcher posts to /_/dev/reload-trigger and the
115    /// webview's SSE stream listens on this channel to fire location.reload().
116    /// Cheap to keep in release builds (one Arc<broadcast::Sender>); the
117    /// routes that read it are only registered behind cfg(debug_assertions).
118    #[cfg(debug_assertions)]
119    pub dev_reload_tx: Arc<broadcast::Sender<()>>,
120}
121
122async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
123    let _ = state.shutdown_tx.send(()).await;
124    StatusCode::OK
125}
126
127fn detect_lang(override_lang: &Option<String>) -> String {
128    match override_lang {
129        Some(lang) => i18n::resolve_lang(lang).to_string(),
130        None => i18n::resolve_lang("auto").to_string(),
131    }
132}
133
134pub fn workspace_url_path(workspace_id: &str, initial_path: Option<&str>) -> String {
135    match initial_path {
136        Some(path) => format!("/{workspace_id}/{}", path.trim_start_matches('/')),
137        None => format!("/{workspace_id}/"),
138    }
139}
140
141pub fn browser_base_url(bind_host: &str, port: u16) -> String {
142    let trimmed = bind_host.trim();
143    let host = match trimmed {
144        "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
145        other if other.starts_with('[') && other.ends_with(']') => other.to_string(),
146        other => match other.parse::<std::net::IpAddr>() {
147            Ok(std::net::IpAddr::V6(addr)) => format!("[{addr}]"),
148            _ => other.to_string(),
149        },
150    };
151    format!("http://{host}:{port}")
152}
153
154pub fn build_workspace_url(base: &str, workspace_path: &str) -> String {
155    let suffix = if workspace_path.starts_with('/') {
156        workspace_path.to_string()
157    } else {
158        format!("/{workspace_path}")
159    };
160    format!("{}{}", base.trim_end_matches('/'), suffix)
161}
162
163pub fn print_compact_qr(data: &str) -> Result<(), Box<dyn std::error::Error>> {
164    // Use low error correction level for smaller QR codes
165    let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::L)?;
166
167    // Render using Dense1x2 (Unicode half-blocks: ▀▄█) for compact display
168    // Dense1x2 uses 2 vertical pixels per character (half-block characters)
169    // This naturally compensates for terminal fonts where character height > width
170    // Note: The aspect ratio depends on the terminal font - it won't be perfect square
171    // on all terminals, but Dense1x2 provides the best balance between size and readability
172    let string = code
173        .render::<Dense1x2>()
174        .quiet_zone(false) // No quiet zone to save space
175        .build();
176
177    // Add spacing: 4 spaces on the left, blank line below
178    for line in string.lines() {
179        println!("    {line}"); // 4 spaces on the left
180    }
181    println!(); // Blank line below
182
183    Ok(())
184}
185
186#[derive(Serialize, Deserialize, Debug)]
187#[serde(tag = "type")]
188enum WebSocketMessage {
189    #[serde(rename = "all_annotations")]
190    AllAnnotations { annotations: Vec<serde_json::Value> },
191    // Mutating variants carry an optional `op_id` set by the originating
192    // client. The server treats it as opaque and round-trips it verbatim so
193    // the originator can recognise (and skip) its own echo. Old clients that
194    // don't send the field deserialize as `None` and serialize without it
195    // (`skip_serializing_if = Option::is_none`), preserving wire compat.
196    #[serde(rename = "new_annotation")]
197    NewAnnotation {
198        annotation: serde_json::Value,
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        op_id: Option<String>,
201    },
202    #[serde(rename = "delete_annotation")]
203    DeleteAnnotation {
204        id: String,
205        #[serde(default, skip_serializing_if = "Option::is_none")]
206        op_id: Option<String>,
207    },
208    #[serde(rename = "clear_annotations")]
209    ClearAnnotations {
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        op_id: Option<String>,
212    },
213    #[serde(rename = "viewed_state")]
214    ViewedState {
215        state: serde_json::Value,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        op_id: Option<String>,
218    },
219    #[serde(rename = "update_viewed_state")]
220    UpdateViewedState {
221        state: serde_json::Value,
222        #[serde(default, skip_serializing_if = "Option::is_none")]
223        op_id: Option<String>,
224    },
225    #[serde(rename = "live_action")]
226    LiveAction { data: serde_json::Value },
227    /// Sent by the file watcher when a file under a workspace was modified
228    /// externally. The browser tab compares `workspace_id` (and `path`) to
229    /// what it's currently displaying and reloads if it matches.
230    #[serde(rename = "file_changed")]
231    FileChanged { workspace_id: String, path: String },
232}
233
234pub async fn start(config: ServerConfig) -> Result<(), String> {
235    let ServerConfig {
236        host,
237        port,
238        theme,
239        qr,
240        open_browser,
241        shared_annotation,
242        salt,
243        initial_workspaces,
244        bound_listener,
245        registry,
246        management_token,
247        language,
248        shortcuts_json,
249        styles_css,
250        default_chat_mode,
251        print_collapsed_content,
252    } = config;
253
254    // Initialize Tera template engine from embedded resources.
255    let mut tera = Tera::default();
256    for file_name in Templates::iter() {
257        if let Some(file) = Templates::get(&file_name) {
258            match std::str::from_utf8(&file.data) {
259                Ok(content) => {
260                    if let Err(e) = tera.add_raw_template(&file_name, content) {
261                        return Err(format!("Failed to add template '{file_name}': {e}"));
262                    }
263                }
264                Err(e) => {
265                    return Err(format!("Failed to read template '{file_name}': {e}"));
266                }
267            }
268        }
269    }
270
271    // A broadcast channel (for WebSocket fan-out) is needed whenever either
272    // shared_annotation or Live is active. The SQLite-backed annotation DB is
273    // only required by shared_annotation; Live is fire-and-forget broadcast.
274    //
275    // GUI mode (`registry: Some`) lets the user toggle these flags after the
276    // server has started, but axum's Router is immutable once built. To avoid
277    // a "404 on /_/ws" the moment the user enables Live or Shared notes from
278    // the tray, GUI mode wires the WebSocket route and broadcast channel up
279    // front and lazily opens the annotation DB regardless of the initial
280    // flag values.
281    let is_gui_mode = registry.is_some();
282    let has_live = initial_workspaces.iter().any(|w| w.flags.enable_live);
283    let has_chat = initial_workspaces.iter().any(|w| w.flags.enable_chat);
284    let needs_ws = is_gui_mode || shared_annotation || has_live;
285    let needs_db = is_gui_mode || shared_annotation || has_chat;
286    let db = if needs_db {
287        let db_path = std::env::var("MARKON_SQLITE_PATH").unwrap_or_else(|_| {
288            let home = dirs::home_dir().expect("Cannot find home directory");
289            home.join(".markon/annotation.sqlite")
290                .to_string_lossy()
291                .to_string()
292        });
293        let parent_dir = std::path::Path::new(&db_path).parent().unwrap();
294        fs::create_dir_all(parent_dir).expect("Failed to create database directory");
295        let conn = Connection::open(&db_path).expect("Failed to open database");
296        conn.execute(
297            "CREATE TABLE IF NOT EXISTS annotations (
298                id TEXT PRIMARY KEY,
299                file_path TEXT NOT NULL,
300                data TEXT NOT NULL
301            )",
302            [],
303        )
304        .expect("Failed to create annotations table");
305        conn.execute(
306            "CREATE TABLE IF NOT EXISTS viewed_state (
307                file_path TEXT PRIMARY KEY,
308                state TEXT NOT NULL,
309                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
310            )",
311            [],
312        )
313        .expect("Failed to create viewed_state table");
314        crate::chat::storage::ChatStorage::init(&conn).expect("Failed to create chat tables");
315        Some(Arc::new(Mutex::new(conn)))
316    } else {
317        None
318    };
319    let tx = needs_ws.then(|| broadcast::channel(100).0);
320
321    // Build workspace registry and register initial workspaces.
322    let effective_salt = salt.unwrap_or_else(|| format!("markon:{port}"));
323    let registry = registry.unwrap_or_else(|| Arc::new(WorkspaceRegistry::new(effective_salt)));
324    // Hand the broadcaster to the registry **before** seeding initial
325    // workspaces so single-file watchers spawned from inside `add()` already
326    // have it. Watchers read the slot lazily on each event, but doing it now
327    // means the first emitted event is delivered.
328    registry.set_live_broadcaster(tx.clone());
329
330    // Track first workspace's URL path for browser/QR.
331    let mut first_workspace_url_path: Option<String> = None;
332
333    for ws_init in initial_workspaces {
334        let path = expand_and_canonicalize(&ws_init.path.to_string_lossy())
335            .unwrap_or_else(|_| ws_init.path.clone());
336        let id = registry.add(WorkspaceConfig {
337            path,
338            flags: ws_init.flags,
339            single_file: None,
340        });
341        if first_workspace_url_path.is_none() {
342            let url_path = workspace_url_path(&id, ws_init.initial_path.as_deref());
343            first_workspace_url_path = Some(url_path);
344        }
345    }
346
347    let token = Arc::new(management_token.unwrap_or_else(generate_token));
348
349    let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
350
351    let state = AppState {
352        theme: Arc::new(theme),
353        tera: Arc::new(tera),
354        shared_annotation,
355        db,
356        tx,
357        workspace_registry: registry,
358        management_token: token.clone(),
359        i18n_json: Arc::new(i18n::load_i18n()),
360        i18n_lang: Arc::new(detect_lang(&language)),
361        // Default to "null" (valid JS literal) so `= {{ shortcuts_json | safe }};`
362        // renders as `= null;` when no overrides; an empty string would produce
363        // `= ;`, a syntax error that silently breaks i18n and shortcut runtime.
364        shortcuts_json: Arc::new(shortcuts_json.unwrap_or_else(|| "null".to_string())),
365        styles_css: Arc::new(styles_css.unwrap_or_default()),
366        default_chat_mode: Arc::new(default_chat_mode),
367        print_collapsed_content,
368        shutdown_tx,
369        #[cfg(debug_assertions)]
370        dev_reload_tx: Arc::new(broadcast::channel::<()>(16).0),
371    };
372
373    // Management API: requires loopback source IP + valid token header.
374    let mgmt = Router::new()
375        .route("/api/workspace", post(add_workspace_handler))
376        .route(
377            "/api/workspace/{id}",
378            delete(remove_workspace_handler).put(update_workspace_handler),
379        )
380        .route("/api/workspaces", get(list_workspaces_handler))
381        .route("/api/save", post(save_file_handler))
382        .route("/api/shutdown", post(shutdown_handler))
383        .layer(axum::middleware::from_fn_with_state(
384            state.clone(),
385            require_local_and_token,
386        ));
387
388    let mut app = Router::new()
389        // Static assets (literal prefix beats /{workspace_id}/ param)
390        .route("/favicon.ico", get(serve_favicon))
391        .route("/_/favicon.ico", get(serve_favicon))
392        .route("/_/favicon.svg", get(serve_favicon_svg))
393        .route("/_/css/{filename}", get(serve_css))
394        .route("/_/js/{*path}", get(serve_js))
395        .route("/_/ws/{workspace_id}", get(config_ws_handler))
396        // Read-only public APIs
397        .route("/search", get(search_handler))
398        .route("/api/preview", post(preview_handler))
399        // Workspace content routes
400        // Chat popout — minimal chat-only page that ChatManager opens via
401        // window.open. Registered before the catch-all `{*path}` so the
402        // literal `_/chat` segment wins.
403        .route("/{workspace_id}/_/chat", get(handle_chat_popout))
404        .route("/{workspace_id}/", get(handle_workspace_root))
405        .route("/{workspace_id}/{*path}", get(handle_workspace_path))
406        // Everything else → 404
407        .fallback(|| async { StatusCode::NOT_FOUND })
408        .merge(mgmt);
409
410    if needs_ws {
411        app = app.route("/_/ws", get(ws_handler));
412    }
413
414    // Dev-only live-reload: esbuild's watch onEnd hook POSTs the trigger,
415    // server fans it out as an SSE event, the webview reloads. cfg gate keeps
416    // these routes (and the heavy tokio_stream / sse plumbing) out of release
417    // builds entirely.
418    #[cfg(debug_assertions)]
419    {
420        app = app
421            .route("/_/dev/reload-stream", get(dev_reload_stream))
422            .route("/_/dev/reload-trigger", post(dev_reload_trigger));
423    }
424
425    // Chat endpoints: SSE chat stream + thread/file REST. Each handler
426    // checks `enable_chat` per-workspace and 403s otherwise, so it's safe
427    // to register unconditionally.
428    let app = app.merge(crate::chat::routes::router());
429
430    let app = app.with_state(state);
431
432    let listener = if let Some(std_listener) = bound_listener {
433        std_listener
434            .set_nonblocking(true)
435            .map_err(|e| format!("Failed to set non-blocking: {e}"))?;
436        TcpListener::from_std(std_listener)
437            .map_err(|e| format!("Failed to convert listener: {e}"))?
438    } else {
439        let addr = format!("{}:{}", host, port)
440            .parse::<SocketAddr>()
441            .map_err(|e| format!("Invalid host address '{}': {}", host, e))?;
442        TcpListener::bind(&addr)
443            .await
444            .map_err(|e| format!("Failed to bind to {addr}: {e}"))?
445    };
446    let addr = listener
447        .local_addr()
448        .map_err(|e| format!("Failed to get local address: {e}"))?;
449    println!("listening on http://{addr}");
450    if let Some(ref p) = first_workspace_url_path {
451        println!("workspace: http://{addr}{p}");
452    }
453
454    // Write lock file so CLI can discover this server.
455    let _lock_guard = {
456        if let Err(e) = (ServerLock {
457            port: addr.port(),
458            token: token.as_ref().clone(),
459        })
460        .write()
461        {
462            tracing::warn!("failed to write lock file: {e}");
463        }
464        struct LockGuard;
465        impl Drop for LockGuard {
466            fn drop(&mut self) {
467                ServerLock::remove();
468            }
469        }
470        LockGuard
471    };
472
473    // Helper: build a full URL from a base option string.
474    let make_url = |base_option: &str, ws_path: &Option<String>| -> String {
475        let base = if base_option == "local" {
476            format!("http://{addr}")
477        } else {
478            base_option.to_string()
479        };
480        match ws_path {
481            Some(p) => build_workspace_url(&base, p),
482            None => format!("{}/", base.trim_end_matches('/')),
483        }
484    };
485
486    let custom_base = qr
487        .as_ref()
488        .filter(|u| u.as_str() != "missing")
489        .or_else(|| open_browser.as_ref().filter(|u| u.as_str() != "local"));
490    if let Some(base) = custom_base {
491        println!(
492            "accessible at {}",
493            make_url(base, &first_workspace_url_path)
494        );
495    }
496
497    if let Some(ref qr_option) = qr {
498        println!();
499        let qr_url = if qr_option == "missing" {
500            format!("http://{addr}")
501        } else {
502            make_url(qr_option, &first_workspace_url_path)
503        };
504        if let Err(e) = print_compact_qr(&qr_url) {
505            eprintln!("Failed to generate QR code: {e}");
506        }
507    }
508
509    if let Some(ref base_opt) = open_browser {
510        let url = make_url(base_opt, &first_workspace_url_path);
511        if let Err(e) = open::that(&url) {
512            tracing::warn!("best-effort browser open failed: {e}");
513        }
514    }
515
516    axum::serve(
517        listener,
518        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
519    )
520    .with_graceful_shutdown(async move {
521        shutdown_rx.recv().await;
522        println!("Shutting down...");
523    })
524    .await
525    .map_err(|e| format!("Server error: {e}"))?;
526    Ok(())
527}
528
529/// Lightweight always-on WebSocket per workspace — pushes a "reload" text frame
530/// whenever workspace flags change. Requires same-origin (see
531/// `check_ws_origin`) so a foreign page cannot subscribe to a victim's
532/// workspace config stream when the server is shared on a LAN.
533async fn config_ws_handler(
534    ws: WebSocketUpgrade,
535    State(state): State<AppState>,
536    AxumPath(workspace_id): AxumPath<String>,
537    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
538    headers: axum::http::HeaderMap,
539) -> impl IntoResponse {
540    if !check_ws_origin(&headers, &addr) {
541        return StatusCode::FORBIDDEN.into_response();
542    }
543    let Some(ws_entry) = state.workspace_registry.get(&workspace_id) else {
544        return StatusCode::NOT_FOUND.into_response();
545    };
546    let mut rx = ws_entry.config_tx.subscribe();
547    ws.on_upgrade(move |mut socket| async move {
548        while let Ok(()) = rx.recv().await {
549            if socket
550                .send(axum::extract::ws::Message::Text("reload".into()))
551                .await
552                .is_err()
553            {
554                break;
555            }
556        }
557    })
558}
559
560/// Reject cross-origin WebSocket upgrades. When the server is bound to a
561/// non-loopback interface (LAN share / QR-code mobile access), any browser on
562/// the same network could otherwise open `/_/ws` from an attacker page and
563/// read or poison annotations under a victim's identity. Browsers always
564/// send `Origin` on WS handshakes; the rule is "Origin authority must equal
565/// Host authority". Native (non-browser) clients can omit Origin entirely —
566/// we let those through only when the TCP peer is loopback, since that's
567/// where local CLI tooling legitimately connects without an Origin header.
568fn check_ws_origin(headers: &axum::http::HeaderMap, peer: &std::net::SocketAddr) -> bool {
569    let origin = headers
570        .get(axum::http::header::ORIGIN)
571        .and_then(|v| v.to_str().ok());
572    match origin {
573        None => peer.ip().is_loopback(),
574        // Sandboxed iframes and some `file://` contexts send `Origin: null`.
575        // We refuse rather than try to interpret what they mean.
576        Some(o) if o.trim().eq_ignore_ascii_case("null") => false,
577        Some(o) => {
578            let host = headers
579                .get(axum::http::header::HOST)
580                .and_then(|v| v.to_str().ok());
581            origin_matches_host(o, host)
582        }
583    }
584}
585
586/// Validate the first frame the WebSocket client sends as its `file_path`
587/// identity. The value is used as a SQL key (parameterized, so no injection)
588/// and as a broadcast match key — it does NOT have to point at a real file
589/// on disk. We still reject obviously dangerous shapes so a foreign client
590/// cannot claim a path like `../etc/passwd` and have it silently persist /
591/// fan out to other connected viewers.
592///
593/// Constraints:
594/// - Non-empty and at most 1024 bytes (db keys should be modest).
595/// - No NUL bytes (defends downstream code that might pass the value to C
596///   string APIs in syntect, sqlite, etc.).
597/// - No `..` path components and no absolute path prefixes.
598fn is_valid_ws_file_path(path: &str) -> bool {
599    if path.is_empty() || path.len() > 1024 || path.contains('\0') {
600        return false;
601    }
602    let p = std::path::Path::new(path);
603    for comp in p.components() {
604        match comp {
605            std::path::Component::ParentDir
606            | std::path::Component::RootDir
607            | std::path::Component::Prefix(_) => return false,
608            _ => {}
609        }
610    }
611    true
612}
613
614/// True when `origin` (e.g. `http://192.168.1.10:1618`) and `host` (e.g.
615/// `192.168.1.10:1618`) refer to the same authority. The origin's authority
616/// is the part after `scheme://` up to the path/query. Comparison is
617/// case-insensitive on the host part — port is matched verbatim.
618fn origin_matches_host(origin: &str, host: Option<&str>) -> bool {
619    let Some(host) = host else { return false };
620    let Some(rest) = origin.split_once("://").map(|(_, r)| r) else {
621        return false;
622    };
623    // Strip path/query if any (shouldn't normally be present on Origin).
624    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
625    authority.eq_ignore_ascii_case(host)
626}
627
628/// Middleware: management API only accepts loopback source + valid token header.
629async fn require_local_and_token(
630    State(state): State<AppState>,
631    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
632    req: axum::extract::Request,
633    next: axum::middleware::Next,
634) -> Response {
635    if !addr.ip().is_loopback() {
636        return StatusCode::FORBIDDEN.into_response();
637    }
638    let ok = req
639        .headers()
640        .get("X-Markon-Token")
641        .and_then(|v| v.to_str().ok())
642        .map(|t| t == state.management_token.as_str())
643        .unwrap_or(false);
644    if !ok {
645        return StatusCode::UNAUTHORIZED.into_response();
646    }
647    next.run(req).await
648}
649
650async fn ws_handler(
651    ws: WebSocketUpgrade,
652    State(state): State<AppState>,
653    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
654    headers: axum::http::HeaderMap,
655) -> impl IntoResponse {
656    if !check_ws_origin(&headers, &addr) {
657        return StatusCode::FORBIDDEN.into_response();
658    }
659    ws.on_upgrade(move |socket| handle_socket(socket, state))
660        .into_response()
661}
662
663#[cfg(debug_assertions)]
664async fn dev_reload_stream(State(state): State<AppState>) -> impl IntoResponse {
665    use axum::response::sse::{Event, KeepAlive, Sse};
666    use std::convert::Infallible;
667    let rx = state.dev_reload_tx.subscribe();
668    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|item| async move {
669        // Drop lagged frames silently; we only need *some* recent reload.
670        item.ok()
671            .map(|()| Ok::<Event, Infallible>(Event::default().event("reload")))
672    });
673    Sse::new(stream).keep_alive(KeepAlive::default())
674}
675
676#[cfg(debug_assertions)]
677async fn dev_reload_trigger(State(state): State<AppState>) -> impl IntoResponse {
678    // send() errors only when there are no subscribers; that's fine — esbuild
679    // can fire before any webview connects, we just no-op.
680    let _ = state.dev_reload_tx.send(());
681    StatusCode::NO_CONTENT
682}
683
684async fn load_annotations(db: Arc<Mutex<Connection>>, file_path: String) -> Vec<serde_json::Value> {
685    tokio::task::spawn_blocking(move || {
686        let db = db.lock().unwrap();
687        let mut stmt = match db.prepare("SELECT data FROM annotations WHERE file_path = ?1") {
688            Ok(s) => s,
689            Err(e) => {
690                tracing::error!(file_path = %file_path, "load_annotations: prepare failed: {e}");
691                return Vec::new();
692            }
693        };
694        let rows = match stmt.query_map([file_path.as_str()], |row| row.get::<_, String>(0)) {
695            Ok(r) => r,
696            Err(e) => {
697                tracing::error!(file_path = %file_path, "load_annotations: query_map failed: {e}");
698                return Vec::new();
699            }
700        };
701        rows.filter_map(Result::ok)
702            .filter_map(|s| serde_json::from_str(&s).ok())
703            .collect()
704    })
705    .await
706    .unwrap_or_else(|e| {
707        tracing::error!("load_annotations join error: {e}");
708        Vec::new()
709    })
710}
711
712async fn load_viewed_state(db: Arc<Mutex<Connection>>, file_path: String) -> serde_json::Value {
713    tokio::task::spawn_blocking(move || {
714        let db = db.lock().unwrap();
715        let state_json = db
716            .query_row(
717                "SELECT state FROM viewed_state WHERE file_path = ?1",
718                [file_path.as_str()],
719                |row| row.get::<_, String>(0),
720            )
721            .unwrap_or_else(|_| "{}".to_string());
722        serde_json::from_str(&state_json).unwrap_or_else(|_| serde_json::json!({}))
723    })
724    .await
725    .unwrap_or_else(|e| {
726        tracing::error!("load_viewed_state join error: {e}");
727        serde_json::json!({})
728    })
729}
730
731async fn send_json(
732    sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
733    msg: &WebSocketMessage,
734) -> Result<(), ()> {
735    let Ok(encoded) = serde_json::to_string(msg) else {
736        return Err(());
737    };
738    sender
739        .send(Message::Text(encoded.into()))
740        .await
741        .map_err(|_| ())
742}
743
744fn broadcast_msg(tx: &broadcast::Sender<String>, msg: &WebSocketMessage) {
745    if let Ok(encoded) = serde_json::to_string(msg) {
746        let _ = tx.send(encoded);
747    }
748}
749
750/// Side-effect plan computed inside the blocking SQLite worker. Returned to
751/// the async caller so the broadcast (which touches the tokio channel) stays
752/// on the runtime, not on the blocking pool.
753enum DbResult {
754    Broadcast(WebSocketMessage),
755    /// Clear-annotations side-effect: broadcast a `clear_annotations` plus a
756    /// reset `viewed_state` (both empty). Carries the originator's `op_id`
757    /// so it propagates to every fan-out frame for echo dedup.
758    BroadcastClear {
759        op_id: Option<String>,
760    },
761    None,
762}
763
764async fn handle_client_msg(
765    db: Option<Arc<Mutex<Connection>>>,
766    tx: broadcast::Sender<String>,
767    file_path: String,
768    msg: WebSocketMessage,
769) {
770    // LiveAction is pure broadcast — no DB needed. Handle it before the DB
771    // short-circuit so Live works in workspaces where shared_annotation is off.
772    if let WebSocketMessage::LiveAction { data } = msg {
773        broadcast_msg(&tx, &WebSocketMessage::LiveAction { data });
774        return;
775    }
776    let Some(db) = db else { return };
777
778    // One spawn_blocking per inbound message: take the lock exactly once,
779    // run whichever SQL the message requires, then return the broadcast plan
780    // for the async side to fan out.
781    let result = tokio::task::spawn_blocking(move || {
782        let conn = db.lock().unwrap();
783        match msg {
784            WebSocketMessage::NewAnnotation { annotation, op_id } => {
785                let Some(id) = annotation["id"].as_str().map(str::to_owned) else {
786                    return DbResult::None;
787                };
788                let Ok(data) = serde_json::to_string(&annotation) else {
789                    return DbResult::None;
790                };
791                if let Err(e) = conn.execute(
792                    "INSERT OR REPLACE INTO annotations (id, file_path, data) VALUES (?1, ?2, ?3)",
793                    [id.as_str(), file_path.as_str(), data.as_str()],
794                ) {
795                    tracing::error!(file_path = %file_path, "insert annotation failed: {e}");
796                    return DbResult::None;
797                }
798                DbResult::Broadcast(WebSocketMessage::NewAnnotation { annotation, op_id })
799            }
800            WebSocketMessage::DeleteAnnotation { id, op_id } => {
801                if let Err(e) = conn.execute(
802                    "DELETE FROM annotations WHERE id = ?1 AND file_path = ?2",
803                    [id.as_str(), file_path.as_str()],
804                ) {
805                    tracing::error!(file_path = %file_path, "delete annotation failed: {e}");
806                    return DbResult::None;
807                }
808                DbResult::Broadcast(WebSocketMessage::DeleteAnnotation { id, op_id })
809            }
810            WebSocketMessage::ClearAnnotations { op_id } => {
811                tracing::info!(file_path = %file_path, "clearing annotations");
812                if let Err(e) = conn.execute(
813                    "DELETE FROM annotations WHERE file_path = ?1",
814                    [file_path.as_str()],
815                ) {
816                    tracing::error!(file_path = %file_path, "clear annotations failed: {e}");
817                }
818                if let Err(e) = conn.execute(
819                    "DELETE FROM viewed_state WHERE file_path = ?1",
820                    [file_path.as_str()],
821                ) {
822                    tracing::error!(file_path = %file_path, "clear viewed_state failed: {e}");
823                }
824                DbResult::BroadcastClear { op_id }
825            }
826            WebSocketMessage::UpdateViewedState {
827                state: viewed,
828                op_id,
829            } => {
830                let Ok(state_json) = serde_json::to_string(&viewed) else {
831                    return DbResult::None;
832                };
833                if let Err(e) = conn.execute(
834                    "INSERT OR REPLACE INTO viewed_state (file_path, state, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
835                    [file_path.as_str(), state_json.as_str()],
836                ) {
837                    tracing::error!(file_path = %file_path, "update viewed_state failed: {e}");
838                    return DbResult::None;
839                }
840                DbResult::Broadcast(WebSocketMessage::ViewedState {
841                    state: viewed,
842                    op_id,
843                })
844            }
845            _ => DbResult::None,
846        }
847    })
848    .await;
849
850    let result = match result {
851        Ok(r) => r,
852        Err(e) => {
853            tracing::error!("handle_client_msg join error: {e}");
854            return;
855        }
856    };
857
858    match result {
859        DbResult::Broadcast(out) => broadcast_msg(&tx, &out),
860        DbResult::BroadcastClear { op_id } => {
861            broadcast_msg(
862                &tx,
863                &WebSocketMessage::ClearAnnotations {
864                    op_id: op_id.clone(),
865                },
866            );
867            broadcast_msg(
868                &tx,
869                &WebSocketMessage::ViewedState {
870                    state: serde_json::Value::Object(serde_json::Map::new()),
871                    op_id,
872                },
873            );
874        }
875        DbResult::None => {}
876    }
877}
878
879async fn handle_socket(socket: WebSocket, state: AppState) {
880    let (mut sender, mut receiver) = socket.split();
881
882    // tx is required (broadcast fan-out). db is optional — only present when
883    // shared_annotation is on; absent when only Live is active.
884    let Some(tx) = state.tx.clone() else {
885        return;
886    };
887    let db = state.db.clone();
888    let mut rx = tx.subscribe();
889
890    let file_path = match receiver.next().await {
891        Some(Ok(Message::Text(text))) => text.to_string(),
892        _ => {
893            tracing::warn!("failed to receive file path from client");
894            return;
895        }
896    };
897    if !is_valid_ws_file_path(&file_path) {
898        tracing::warn!(file_path = %file_path, "rejecting suspicious file_path from client");
899        return;
900    }
901
902    // Only send initial annotation/viewed state when a persistence layer exists.
903    if let Some(db) = db.as_ref() {
904        let annotations = load_annotations(db.clone(), file_path.clone()).await;
905        tracing::debug!(
906            file_path = %file_path,
907            count = annotations.len(),
908            "sending initial annotations to client",
909        );
910        if send_json(
911            &mut sender,
912            &WebSocketMessage::AllAnnotations { annotations },
913        )
914        .await
915        .is_err()
916        {
917            return;
918        }
919        let viewed = load_viewed_state(db.clone(), file_path.clone()).await;
920        if send_json(
921            &mut sender,
922            &WebSocketMessage::ViewedState {
923                state: viewed,
924                op_id: None,
925            },
926        )
927        .await
928        .is_err()
929        {
930            return;
931        }
932    }
933
934    let mut send_task = tokio::spawn(async move {
935        while let Ok(msg) = rx.recv().await {
936            if sender.send(Message::Text(msg.into())).await.is_err() {
937                break;
938            }
939        }
940    });
941
942    let mut recv_task = tokio::spawn(async move {
943        while let Some(Ok(Message::Text(text))) = receiver.next().await {
944            let Ok(msg) = serde_json::from_str::<WebSocketMessage>(&text) else {
945                continue;
946            };
947            handle_client_msg(db.clone(), tx.clone(), file_path.clone(), msg).await;
948        }
949    });
950
951    tokio::select! {
952        _ = (&mut send_task) => recv_task.abort(),
953        _ = (&mut recv_task) => send_task.abort(),
954    };
955}
956
957// ── Workspace content handlers ────────────────────────────────────────────────
958
959/// Standalone chat-only page. Opened by ChatManager.#openPopout() in its own
960/// browser-level window. Returns the minimal `chat.html` template — no
961/// markdown body, no TOC, no Live, no annotations bundle. The shared
962/// `main.js` bundle still loads, but at boot it sees `<meta name="chat-only">`
963/// and routes to `ChatManager.initPopout()` instead of `MarkonApp`.
964async fn handle_chat_popout(
965    State(state): State<AppState>,
966    AxumPath(workspace_id): AxumPath<String>,
967) -> impl IntoResponse {
968    let Some(ws) = state.workspace_registry.get(&workspace_id) else {
969        return StatusCode::NOT_FOUND.into_response();
970    };
971    // Hide the chat page entirely if chat is disabled for this workspace —
972    // mirrors the same gate the in-page chat panel respects via the
973    // `enable-chat` meta flag.
974    if !ws.flags().enable_chat {
975        return StatusCode::NOT_FOUND.into_response();
976    }
977    let mut context = tera::Context::new();
978    context.insert("workspace_id", &workspace_id);
979    context.insert("theme", state.theme.as_str());
980    context.insert("title", &"Markon Chat".to_string());
981    context.insert("i18n_json", state.i18n_json.as_str());
982    context.insert("i18n_lang", state.i18n_lang.as_str());
983    context.insert("styles_css", state.styles_css.as_str());
984    context.insert("default_chat_mode", state.default_chat_mode.as_str());
985    match state.tera.render("chat.html", &context) {
986        Ok(html) => Html(html).into_response(),
987        Err(e) => (
988            StatusCode::INTERNAL_SERVER_ERROR,
989            format!("Template error: {e}"),
990        )
991            .into_response(),
992    }
993}
994
995async fn handle_workspace_root(
996    State(state): State<AppState>,
997    AxumPath(workspace_id): AxumPath<String>,
998) -> impl IntoResponse {
999    let Some(ws) = state.workspace_registry.get(&workspace_id) else {
1000        return StatusCode::NOT_FOUND.into_response();
1001    };
1002    // Single-file workspace: there's no listing, just the one document.
1003    // 302 to the file URL so the user lands directly on the rendered .md.
1004    if let Some(only) = &ws.single_file {
1005        return Redirect::to(&format!("/{workspace_id}/{only}")).into_response();
1006    }
1007    render_directory_listing(&workspace_id, &ws, None, &state)
1008}
1009
1010async fn handle_workspace_path(
1011    State(state): State<AppState>,
1012    AxumPath((workspace_id, path)): AxumPath<(String, String)>,
1013) -> impl IntoResponse {
1014    let Some(ws) = state.workspace_registry.get(&workspace_id) else {
1015        return StatusCode::NOT_FOUND.into_response();
1016    };
1017
1018    let decoded = urlencoding::decode(&path).unwrap_or_else(|_| path.clone().into());
1019    let rel = decoded.trim_start_matches('/');
1020    // Single-file gate: reject anything outside the pinned file and the
1021    // assets it currently references. Directory listings are not allowed
1022    // either — `allows()` is false for everything else.
1023    if ws.is_ephemeral() && !ws.allows(rel) {
1024        return (StatusCode::NOT_FOUND, "Path not found").into_response();
1025    }
1026    let full_path = ws.root.join(rel);
1027
1028    let canonical = match full_path.canonicalize() {
1029        Ok(p) => p,
1030        Err(_) => {
1031            return (StatusCode::NOT_FOUND, format!("Path not found: {decoded}")).into_response()
1032        }
1033    };
1034
1035    if !canonical.starts_with(&ws.root) {
1036        return (StatusCode::FORBIDDEN, "Access denied").into_response();
1037    }
1038
1039    if canonical.is_file() {
1040        if canonical
1041            .extension()
1042            .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md")
1043        {
1044            render_markdown_file(&canonical.to_string_lossy(), &workspace_id, &ws, &state)
1045        } else {
1046            serve_file(&canonical)
1047        }
1048    } else if canonical.is_dir() {
1049        if ws.is_ephemeral() {
1050            // Defense in depth: `allows()` already rejects directories, but
1051            // be explicit so a future change to `allows()` can't accidentally
1052            // expose a sibling listing.
1053            return (StatusCode::NOT_FOUND, "Path not found").into_response();
1054        }
1055        render_directory_listing(&workspace_id, &ws, Some(&decoded), &state)
1056    } else {
1057        (StatusCode::NOT_FOUND, "Path not found").into_response()
1058    }
1059}
1060
1061// ── Workspace management API ──────────────────────────────────────────────────
1062
1063#[derive(Deserialize)]
1064struct AddWorkspaceRequest {
1065    path: String,
1066    #[serde(flatten)]
1067    flags: WorkspaceFlags,
1068}
1069
1070#[derive(Serialize)]
1071struct AddWorkspaceResponse {
1072    id: String,
1073}
1074
1075async fn add_workspace_handler(
1076    State(state): State<AppState>,
1077    Json(req): Json<AddWorkspaceRequest>,
1078) -> impl IntoResponse {
1079    let path = match expand_and_canonicalize(&req.path) {
1080        Ok(p) => p,
1081        Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
1082    };
1083    let id = state.workspace_registry.add(WorkspaceConfig {
1084        path,
1085        flags: req.flags,
1086        single_file: None,
1087    });
1088    Json(AddWorkspaceResponse { id }).into_response()
1089}
1090
1091async fn remove_workspace_handler(
1092    State(state): State<AppState>,
1093    AxumPath(id): AxumPath<String>,
1094) -> impl IntoResponse {
1095    if state.workspace_registry.remove(&id) {
1096        StatusCode::OK
1097    } else {
1098        StatusCode::NOT_FOUND
1099    }
1100}
1101
1102async fn update_workspace_handler(
1103    State(state): State<AppState>,
1104    AxumPath(id): AxumPath<String>,
1105    Json(flags): Json<WorkspaceFlags>,
1106) -> impl IntoResponse {
1107    if state.workspace_registry.update_flags(&id, flags) {
1108        StatusCode::OK
1109    } else {
1110        StatusCode::NOT_FOUND
1111    }
1112}
1113
1114async fn list_workspaces_handler(State(state): State<AppState>) -> impl IntoResponse {
1115    Json(state.workspace_registry.info_list())
1116}
1117
1118// ── Search handler ────────────────────────────────────────────────────────────
1119
1120#[derive(Deserialize)]
1121struct WorkspaceSearchQuery {
1122    ws: String,
1123    #[serde(flatten)]
1124    q: SearchQuery,
1125}
1126
1127async fn search_handler(
1128    State(state): State<AppState>,
1129    axum::extract::Query(query): axum::extract::Query<WorkspaceSearchQuery>,
1130) -> impl IntoResponse {
1131    if query.q.q.is_empty() {
1132        return Json(Vec::<SearchResult>::new());
1133    }
1134    let Some(ws) = state.workspace_registry.get(&query.ws) else {
1135        return Json(Vec::new());
1136    };
1137    if !ws.enable_search.load(std::sync::atomic::Ordering::Relaxed) {
1138        return Json(Vec::new());
1139    }
1140    let Some(idx) = ws.search_index.load_full() else {
1141        return Json(Vec::new()); // still indexing
1142    };
1143    let results = idx.search(&query.q.q, 20).unwrap_or_else(|e| {
1144        tracing::warn!("search error: {e}");
1145        Vec::new()
1146    });
1147    Json(results)
1148}
1149
1150fn render_markdown_file(
1151    file_path: &str,
1152    workspace_id: &str,
1153    ws: &WorkspaceEntry,
1154    state: &AppState,
1155) -> Response {
1156    match fs::read_to_string(file_path) {
1157        Ok(markdown_input) => {
1158            let renderer = MarkdownRenderer::new(&state.theme);
1159            let (html_content, has_mermaid, toc) = renderer.render(&markdown_input);
1160
1161            let title = std::path::Path::new(file_path)
1162                .file_name()
1163                .map(|n| n.to_string_lossy().to_string())
1164                .unwrap_or_else(|| file_path.to_string());
1165
1166            let mut context = tera::Context::new();
1167            context.insert("title", &format!("markon - {title}"));
1168            context.insert("file_path", file_path);
1169            context.insert("workspace_id", workspace_id);
1170            context.insert("theme", state.theme.as_str());
1171            context.insert("content", &html_content);
1172            // Back link: parent dir of this file within the workspace.
1173            // Suppressed for single-file workspaces — `/{id}/` 303-redirects
1174            // back to this same file (see `handle_workspace_root`), so a
1175            // "Back to file list" link would be a no-op trap.
1176            let back_link = std::path::Path::new(file_path)
1177                .parent()
1178                .and_then(|p| p.strip_prefix(&ws.root).ok())
1179                .map(|rel| {
1180                    let rel_str = rel.to_string_lossy();
1181                    if rel_str.is_empty() {
1182                        format!("/{workspace_id}/")
1183                    } else {
1184                        format!("/{workspace_id}/{}/", rel_str)
1185                    }
1186                })
1187                .unwrap_or_else(|| format!("/{workspace_id}/"));
1188            context.insert("back_link", &back_link);
1189            context.insert("show_back_link", &!ws.is_ephemeral());
1190            context.insert("has_mermaid", &has_mermaid);
1191            context.insert("toc", &toc);
1192            let flags = ws.flags();
1193            context.insert("shared_annotation", &flags.shared_annotation);
1194            context.insert("enable_viewed", &flags.enable_viewed);
1195            context.insert("enable_search", &flags.enable_search);
1196            context.insert("enable_edit", &flags.enable_edit);
1197            context.insert("enable_live", &flags.enable_live);
1198            context.insert("enable_chat", &flags.enable_chat);
1199
1200            if flags.enable_edit {
1201                // JSON-encode and HTML-escape so </script> in content can't break the page.
1202                let json = serde_json::to_string(&markdown_input)
1203                    .unwrap_or_default()
1204                    .replace('<', "\\u003c")
1205                    .replace('>', "\\u003e")
1206                    .replace('&', "\\u0026");
1207                context.insert("markdown_content_json", &json);
1208                context.insert("management_token", state.management_token.as_str());
1209            }
1210
1211            context.insert("i18n_json", state.i18n_json.as_str());
1212            context.insert("i18n_lang", state.i18n_lang.as_str());
1213            context.insert("shortcuts_json", state.shortcuts_json.as_str());
1214            context.insert("styles_css", state.styles_css.as_str());
1215            context.insert("default_chat_mode", state.default_chat_mode.as_str());
1216            context.insert("print_collapsed_content", &state.print_collapsed_content);
1217
1218            match state.tera.render("layout.html", &context) {
1219                Ok(html) => Html(html).into_response(),
1220                Err(e) => (
1221                    StatusCode::INTERNAL_SERVER_ERROR,
1222                    format!("Template error: {e}"),
1223                )
1224                    .into_response(),
1225            }
1226        }
1227        Err(e) => {
1228            let mut context = tera::Context::new();
1229            context.insert("title", "Error");
1230            context.insert("theme", state.theme.as_str());
1231            context.insert(
1232                "content",
1233                &format!(
1234                    r#"<p style="color: red;">Error reading file '{file_path}': {e}</p>
1235                       <a href="/">← Back to file list</a>"#
1236                ),
1237            );
1238            context.insert("show_back_link", &false);
1239            context.insert("has_mermaid", &false);
1240            context.insert("i18n_json", state.i18n_json.as_str());
1241            context.insert("i18n_lang", state.i18n_lang.as_str());
1242            context.insert("shortcuts_json", state.shortcuts_json.as_str());
1243            context.insert("styles_css", state.styles_css.as_str());
1244            context.insert("default_chat_mode", state.default_chat_mode.as_str());
1245            context.insert("print_collapsed_content", &state.print_collapsed_content);
1246
1247            match state.tera.render("layout.html", &context) {
1248                Ok(html) => Html(html).into_response(),
1249                Err(e) => (
1250                    StatusCode::INTERNAL_SERVER_ERROR,
1251                    format!("Template error: {e}"),
1252                )
1253                    .into_response(),
1254            }
1255        }
1256    }
1257}
1258
1259fn render_directory_listing(
1260    workspace_id: &str,
1261    ws: &WorkspaceEntry,
1262    dir_param: Option<&str>,
1263    state: &AppState,
1264) -> Response {
1265    use std::path::PathBuf;
1266
1267    let current_dir = if let Some(dir_str) = dir_param {
1268        let p = PathBuf::from(dir_str);
1269        if p.is_absolute() {
1270            p
1271        } else {
1272            ws.root.join(&p)
1273        }
1274    } else {
1275        ws.root.clone()
1276    };
1277
1278    let current_dir = match current_dir.canonicalize() {
1279        Ok(p) => p,
1280        Err(e) => {
1281            return (StatusCode::BAD_REQUEST, format!("Invalid directory: {e}")).into_response()
1282        }
1283    };
1284
1285    #[derive(serde::Serialize)]
1286    struct Entry {
1287        name: String,
1288        is_dir: bool,
1289        link: String,
1290    }
1291
1292    let mut entries: Vec<Entry> = match fs::read_dir(&current_dir) {
1293        Ok(dir_entries) => dir_entries
1294            .filter_map(|e| e.ok())
1295            .filter_map(|entry| {
1296                let path = entry.path();
1297                let name = entry.file_name().to_string_lossy().to_string();
1298                if name.starts_with('.') {
1299                    return None;
1300                }
1301                // Use file_type() — avoids stat() syscall that can block on AutoFS mount points.
1302                let file_type = match entry.file_type() {
1303                    Ok(ft) => ft,
1304                    Err(_) => return None,
1305                };
1306                let is_dir = file_type.is_dir();
1307                let rel = path
1308                    .strip_prefix(&ws.root)
1309                    .unwrap_or(&path)
1310                    .to_string_lossy()
1311                    .to_string();
1312                if is_dir {
1313                    Some(Entry {
1314                        name,
1315                        is_dir: true,
1316                        link: format!("/{workspace_id}/{rel}/"),
1317                    })
1318                } else {
1319                    let is_md = path
1320                        .extension()
1321                        .is_some_and(|e| e.to_string_lossy().to_lowercase() == "md");
1322                    if is_md {
1323                        Some(Entry {
1324                            name,
1325                            is_dir: false,
1326                            link: format!("/{workspace_id}/{rel}"),
1327                        })
1328                    } else {
1329                        None
1330                    }
1331                }
1332            })
1333            .collect(),
1334        Err(e) => {
1335            return (
1336                StatusCode::INTERNAL_SERVER_ERROR,
1337                format!("Error reading directory: {e}"),
1338            )
1339                .into_response()
1340        }
1341    };
1342
1343    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
1344        (true, false) => std::cmp::Ordering::Less,
1345        (false, true) => std::cmp::Ordering::Greater,
1346        _ => a.name.cmp(&b.name),
1347    });
1348
1349    let show_parent = current_dir != ws.root;
1350    let parent_link: Option<String> = if show_parent {
1351        current_dir.parent().map(|parent| {
1352            let rel = parent
1353                .strip_prefix(&ws.root)
1354                .map(|p| p.to_string_lossy().to_string())
1355                .unwrap_or_default();
1356            if rel.is_empty() {
1357                format!("/{workspace_id}/")
1358            } else {
1359                format!("/{workspace_id}/{rel}/")
1360            }
1361        })
1362    } else {
1363        None
1364    };
1365
1366    let mut context = tera::Context::new();
1367    context.insert("theme", state.theme.as_str());
1368    context.insert("workspace_id", workspace_id);
1369    context.insert("current_dir", &current_dir.display().to_string());
1370    context.insert("entries", &entries);
1371    context.insert("show_parent", &show_parent);
1372    context.insert("parent_link", &parent_link);
1373    context.insert(
1374        "enable_search",
1375        &ws.enable_search.load(std::sync::atomic::Ordering::Relaxed),
1376    );
1377    context.insert("i18n_json", state.i18n_json.as_str());
1378    context.insert("i18n_lang", state.i18n_lang.as_str());
1379    context.insert("shortcuts_json", state.shortcuts_json.as_str());
1380    context.insert("styles_css", state.styles_css.as_str());
1381
1382    match state.tera.render("directory.html", &context) {
1383        Ok(html) => Html(html).into_response(),
1384        Err(e) => (
1385            StatusCode::INTERNAL_SERVER_ERROR,
1386            format!("Template error: {e}"),
1387        )
1388            .into_response(),
1389    }
1390}
1391
1392async fn serve_favicon() -> impl IntoResponse {
1393    // Redirect /_/favicon.ico to /_/favicon.svg
1394    (
1395        StatusCode::MOVED_PERMANENTLY,
1396        [(header::LOCATION, "/_/favicon.svg")],
1397    )
1398        .into_response()
1399}
1400
1401async fn serve_favicon_svg() -> impl IntoResponse {
1402    serve_static_file("favicon.svg", IconAssets::get, "image/svg+xml")
1403}
1404
1405async fn serve_css(AxumPath(filename): AxumPath<String>) -> impl IntoResponse {
1406    serve_static_file(&filename, CssAssets::get, "text/css")
1407}
1408
1409async fn serve_js(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
1410    serve_static_file(&path, JsAssets::get, "application/javascript")
1411}
1412
1413fn serve_static_file<F>(filename: &str, getter: F, content_type: &str) -> Response
1414where
1415    F: FnOnce(&str) -> Option<rust_embed::EmbeddedFile>,
1416{
1417    match getter(filename) {
1418        Some(file) => (
1419            StatusCode::OK,
1420            [(header::CONTENT_TYPE, content_type)],
1421            file.data.into_owned(),
1422        )
1423            .into_response(),
1424        None => (StatusCode::NOT_FOUND, "File not found").into_response(),
1425    }
1426}
1427
1428fn serve_file(path: &std::path::Path) -> Response {
1429    match fs::read(path) {
1430        Ok(content) => {
1431            let mime_type = mime_guess::from_path(path)
1432                .first_or_octet_stream()
1433                .essence_str()
1434                .to_string();
1435            (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], content).into_response()
1436        }
1437        Err(e) => (
1438            StatusCode::INTERNAL_SERVER_ERROR,
1439            format!("Error reading file: {e}"),
1440        )
1441            .into_response(),
1442    }
1443}
1444
1445// ── File editing API ──────────────────────────────────────────────────────────
1446
1447#[derive(Deserialize)]
1448struct SaveFileRequest {
1449    workspace_id: String,
1450    file_path: String,
1451    content: String,
1452}
1453
1454#[derive(Serialize)]
1455struct SaveFileResponse {
1456    success: bool,
1457    message: String,
1458}
1459
1460async fn save_file_handler(
1461    State(state): State<AppState>,
1462    Json(payload): Json<SaveFileRequest>,
1463) -> impl IntoResponse {
1464    let ws = match state.workspace_registry.get(&payload.workspace_id) {
1465        Some(w) => w,
1466        None => {
1467            return Json(SaveFileResponse {
1468                success: false,
1469                message: "Workspace not found".into(),
1470            })
1471            .into_response()
1472        }
1473    };
1474
1475    if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
1476        return Json(SaveFileResponse {
1477            success: false,
1478            message: "Edit feature is not enabled".into(),
1479        })
1480        .into_response();
1481    }
1482
1483    let decoded = match urlencoding::decode(&payload.file_path) {
1484        Ok(p) => p,
1485        Err(_) => {
1486            return Json(SaveFileResponse {
1487                success: false,
1488                message: "Invalid file path encoding".into(),
1489            })
1490            .into_response()
1491        }
1492    };
1493
1494    let decoded_path = std::path::Path::new(decoded.as_ref());
1495    let full_path = if decoded_path.is_absolute() {
1496        decoded_path.to_path_buf()
1497    } else {
1498        ws.root.join(decoded.trim_start_matches('/'))
1499    };
1500    let canonical = match full_path.canonicalize() {
1501        Ok(p) => p,
1502        Err(_) => {
1503            return Json(SaveFileResponse {
1504                success: false,
1505                message: format!("File not found: {decoded}"),
1506            })
1507            .into_response()
1508        }
1509    };
1510
1511    if !canonical.starts_with(&ws.root) {
1512        return Json(SaveFileResponse {
1513            success: false,
1514            message: "Access denied".into(),
1515        })
1516        .into_response();
1517    }
1518    // Single-file gate, mirroring `handle_workspace_path`: writes outside
1519    // the pinned file (and its allowed assets) are rejected even when the
1520    // path resolves inside `ws.root`. No-op for normal directory workspaces.
1521    if ws.is_ephemeral() {
1522        let rel = canonical
1523            .strip_prefix(&ws.root)
1524            .map(|p| p.to_string_lossy().to_string())
1525            .unwrap_or_default();
1526        if !ws.allows(&rel) {
1527            return Json(SaveFileResponse {
1528                success: false,
1529                message: "Access denied".into(),
1530            })
1531            .into_response();
1532        }
1533    }
1534    if !canonical.is_file() {
1535        return Json(SaveFileResponse {
1536            success: false,
1537            message: "Path is not a file".into(),
1538        })
1539        .into_response();
1540    }
1541    if canonical
1542        .extension()
1543        .is_none_or(|e| e.to_string_lossy().to_lowercase() != "md")
1544    {
1545        return Json(SaveFileResponse {
1546            success: false,
1547            message: "Only .md files can be edited".into(),
1548        })
1549        .into_response();
1550    }
1551    match fs::write(&canonical, &payload.content) {
1552        Ok(_) => Json(SaveFileResponse {
1553            success: true,
1554            message: "File saved successfully".into(),
1555        })
1556        .into_response(),
1557        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Json(SaveFileResponse {
1558            success: false,
1559            message: "File is read-only".into(),
1560        })
1561        .into_response(),
1562        Err(e) => Json(SaveFileResponse {
1563            success: false,
1564            message: format!("Failed to save: {e}"),
1565        })
1566        .into_response(),
1567    }
1568}
1569
1570// ── Markdown preview API ──────────────────────────────────────────────────────
1571
1572#[derive(Deserialize)]
1573struct PreviewRequest {
1574    content: String,
1575}
1576
1577#[derive(Serialize)]
1578struct PreviewResponse {
1579    html: String,
1580    has_mermaid: bool,
1581}
1582
1583async fn preview_handler(
1584    State(state): State<AppState>,
1585    Json(payload): Json<PreviewRequest>,
1586) -> impl IntoResponse {
1587    let renderer = MarkdownRenderer::new(&state.theme);
1588    let (html, has_mermaid, _toc) = renderer.render(&payload.content);
1589    Json(PreviewResponse { html, has_mermaid }).into_response()
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595    use serde_json::json;
1596
1597    use axum::http::HeaderMap;
1598    use std::net::{IpAddr, Ipv4Addr};
1599
1600    fn headers_with(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
1601        let mut h = HeaderMap::new();
1602        if let Some(o) = origin {
1603            h.insert("origin", o.parse().unwrap());
1604        }
1605        if let Some(host) = host {
1606            h.insert("host", host.parse().unwrap());
1607        }
1608        h
1609    }
1610
1611    fn loopback() -> SocketAddr {
1612        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1618)
1613    }
1614
1615    fn lan_peer() -> SocketAddr {
1616        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 51234)
1617    }
1618
1619    #[test]
1620    fn ws_origin_accepts_matching_authority() {
1621        let h = headers_with(Some("http://192.168.1.10:1618"), Some("192.168.1.10:1618"));
1622        assert!(check_ws_origin(&h, &lan_peer()));
1623    }
1624
1625    #[test]
1626    fn ws_origin_rejects_cross_origin() {
1627        let h = headers_with(Some("http://evil.example.com"), Some("192.168.1.10:1618"));
1628        assert!(!check_ws_origin(&h, &lan_peer()));
1629    }
1630
1631    #[test]
1632    fn ws_origin_rejects_port_mismatch() {
1633        let h = headers_with(Some("http://127.0.0.1:9000"), Some("127.0.0.1:1618"));
1634        assert!(!check_ws_origin(&h, &loopback()));
1635    }
1636
1637    #[test]
1638    fn ws_origin_rejects_null_origin() {
1639        let h = headers_with(Some("null"), Some("127.0.0.1:1618"));
1640        assert!(!check_ws_origin(&h, &loopback()));
1641    }
1642
1643    #[test]
1644    fn ws_missing_origin_allowed_only_from_loopback() {
1645        let h = headers_with(None, Some("127.0.0.1:1618"));
1646        assert!(check_ws_origin(&h, &loopback()));
1647        assert!(!check_ws_origin(&h, &lan_peer()));
1648    }
1649
1650    #[test]
1651    fn ws_origin_case_insensitive_host_match() {
1652        let h = headers_with(
1653            Some("http://Example.Local:1618"),
1654            Some("example.local:1618"),
1655        );
1656        assert!(check_ws_origin(&h, &loopback()));
1657    }
1658
1659    #[test]
1660    fn ws_origin_with_trailing_path_still_matches_authority() {
1661        // Defensive: spec says Origin has no path, but some clients append one.
1662        let h = headers_with(Some("http://127.0.0.1:1618/"), Some("127.0.0.1:1618"));
1663        assert!(check_ws_origin(&h, &loopback()));
1664    }
1665
1666    #[test]
1667    fn ws_file_path_accepts_normal_paths() {
1668        assert!(is_valid_ws_file_path("notes/intro.md"));
1669        assert!(is_valid_ws_file_path("README.md"));
1670        assert!(is_valid_ws_file_path("docs/api/index.html"));
1671    }
1672
1673    #[test]
1674    fn ws_file_path_rejects_parent_traversal() {
1675        assert!(!is_valid_ws_file_path("../etc/passwd"));
1676        assert!(!is_valid_ws_file_path("notes/../../etc/passwd"));
1677    }
1678
1679    #[test]
1680    fn ws_file_path_rejects_absolute_path() {
1681        assert!(!is_valid_ws_file_path("/etc/passwd"));
1682    }
1683
1684    #[test]
1685    fn ws_file_path_rejects_nul_byte_and_empty() {
1686        assert!(!is_valid_ws_file_path(""));
1687        assert!(!is_valid_ws_file_path("a\0b"));
1688    }
1689
1690    #[test]
1691    fn ws_file_path_rejects_overlong() {
1692        let big = "a".repeat(1025);
1693        assert!(!is_valid_ws_file_path(&big));
1694    }
1695
1696    #[test]
1697    fn test_websocket_message_serialization() {
1698        let msg = WebSocketMessage::LiveAction {
1699            data: json!({
1700                "clientId": "test-id",
1701                "action": "scroll_to",
1702                "xpath": "/p[1]",
1703                "offset": 0.5
1704            }),
1705        };
1706        let serialized = serde_json::to_string(&msg).unwrap();
1707        assert!(serialized.contains("\"type\":\"live_action\""));
1708        assert!(serialized.contains("\"clientId\":\"test-id\""));
1709    }
1710
1711    /// `NewAnnotation` round-trips `op_id` verbatim in both directions and
1712    /// the field is omitted from the wire when `None` — keeping the protocol
1713    /// backward-compatible with clients that don't know about it yet.
1714    #[test]
1715    fn test_new_annotation_op_id_round_trip() {
1716        // Some(op_id): present on the wire, parsed back identically.
1717        let with = WebSocketMessage::NewAnnotation {
1718            annotation: json!({ "id": "anno-1", "text": "hi" }),
1719            op_id: Some("op-abc".into()),
1720        };
1721        let json_with = serde_json::to_string(&with).unwrap();
1722        assert!(
1723            json_with.contains("\"op_id\":\"op-abc\""),
1724            "wire form should include op_id: {json_with}"
1725        );
1726        let parsed: WebSocketMessage = serde_json::from_str(&json_with).unwrap();
1727        match parsed {
1728            WebSocketMessage::NewAnnotation { op_id, .. } => {
1729                assert_eq!(op_id.as_deref(), Some("op-abc"));
1730            }
1731            _ => panic!("expected NewAnnotation"),
1732        }
1733
1734        // None: omitted from the wire (back-compat with old clients).
1735        let without = WebSocketMessage::NewAnnotation {
1736            annotation: json!({ "id": "anno-2" }),
1737            op_id: None,
1738        };
1739        let json_without = serde_json::to_string(&without).unwrap();
1740        assert!(
1741            !json_without.contains("op_id"),
1742            "wire form should omit op_id when None: {json_without}"
1743        );
1744
1745        // An old-client payload with no op_id field deserialises to None.
1746        let legacy = r#"{"type":"new_annotation","annotation":{"id":"x"}}"#;
1747        let parsed_legacy: WebSocketMessage = serde_json::from_str(legacy).unwrap();
1748        match parsed_legacy {
1749            WebSocketMessage::NewAnnotation { op_id, .. } => assert!(op_id.is_none()),
1750            _ => panic!("expected NewAnnotation"),
1751        }
1752    }
1753
1754    #[test]
1755    fn test_app_state_identity() {
1756        let (tx, _) = tokio::sync::mpsc::channel(1);
1757        let registry = Arc::new(crate::workspace::WorkspaceRegistry::new("salt".into()));
1758        let state = AppState {
1759            theme: Arc::new("dark".into()),
1760            tera: Arc::new(Tera::default()),
1761            shared_annotation: true,
1762            db: None,
1763            tx: None,
1764            workspace_registry: registry,
1765            management_token: Arc::new("token".into()),
1766            i18n_json: Arc::new("{}".into()),
1767            i18n_lang: Arc::new("zh".into()),
1768            shortcuts_json: Arc::new("{}".into()),
1769            styles_css: Arc::new("".into()),
1770            default_chat_mode: Arc::new("in_page".into()),
1771            print_collapsed_content: false,
1772            shutdown_tx: tx,
1773            #[cfg(debug_assertions)]
1774            dev_reload_tx: Arc::new(broadcast::channel::<()>(1).0),
1775        };
1776        assert_eq!(state.management_token.as_str(), "token");
1777    }
1778
1779    #[test]
1780    fn browser_base_url_uses_localhost_for_wildcard_binds() {
1781        assert_eq!(browser_base_url("0.0.0.0", 6419), "http://127.0.0.1:6419");
1782        assert_eq!(browser_base_url("::", 6419), "http://127.0.0.1:6419");
1783    }
1784
1785    #[test]
1786    fn browser_base_url_preserves_specific_hosts() {
1787        assert_eq!(
1788            browser_base_url("192.168.1.20", 6419),
1789            "http://192.168.1.20:6419"
1790        );
1791        assert_eq!(browser_base_url("::1", 6419), "http://[::1]:6419");
1792    }
1793}