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