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