mod bridge; pub(crate) mod command_support; mod compat; mod dev_hot; mod documents; mod editor; mod gateway; mod health; mod hermes; mod hermes_client; mod hermes_tools; mod kernel; mod local_folder_events; mod local_folder_source; mod local_markdown_parser; mod local_search_index; mod media; mod mindmap_api; mod mindmap_shell; mod onlyoffice; mod page_ai_workflow; mod query_support; mod resource_trash; mod search; mod session; mod snapshot_support; mod sse; mod stream_support; mod tree; pub(crate) mod web_shell; mod ws; pub(crate) use local_folder_source::{ ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri, update_local_markdown_title, update_local_page_options, write_local_markdown_page_body, }; pub(crate) use local_search_index::refresh_local_search_index_for_path; use crate::app::AppState; use axum::routing::{any, delete, get, post, put}; use axum::Router; pub fn build_router(state: AppState) -> Router { let hermes_base_path = state.config().hermes_base_path.clone(); let enable_debug_shell_routes = state.config().enable_debug_shell_routes; let mut router = Router::new() .route("/health", get(health::health)) .route("/", get(gateway::root_entry)) .route("/trash", get(gateway::trash_entry)) .route("/favicon.ico", get(gateway::favicon)) .route( "/admin/access-policy", get(gateway::admin_access_policy_entry), ) .route( "/user/access-policy", get(gateway::user_access_policy_entry), ) .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry)) .route("/search", get(search::shell)) .route( "/mindmap/{doc_id}/{mindmap_id}", get(mindmap_shell::mindmap_object_shell), ) .route( "/api/mindmap/{doc_id}/{mindmap_id}", get(mindmap_api::get_mindmap) .post(mindmap_api::apply_mindmap_command) .delete(resource_trash::mindmap_delete) .patch(resource_trash::mindmap_trash_action), ) .route( "/api/mindmap-trash/empty", post(resource_trash::mindmap_empty_trash), ) .route( "/documents/{document_id}", get(web_shell::document_page_shell), ) .route( "/api/page-aggregate/{document_id}", get(web_shell::page_aggregate), ) .route( "/api/leptos-tiptap-runtime/manifest.json", get(web_shell::leptos_tiptap_manifest), ) .route( "/api/leptos-tiptap-runtime/{*asset_path}", get(web_shell::leptos_tiptap_asset), ) .route( "/api/editor/image-placeholder.svg", get(web_shell::editor_image_placeholder_asset), ) .route( "/api/mnote-browser-runtime/resource-open-runtime.js", get(web_shell::resource_open_runtime_asset), ) .route( "/api/mnote-browser-runtime/local-upload-runtime.js", get(web_shell::local_upload_runtime_asset), ) .route( "/api/mnote-browser-runtime/filetree-runtime.js", get(web_shell::filetree_runtime_asset), ) .route( "/api/mnote-browser-runtime/filetree-selection-runtime.js", get(web_shell::filetree_selection_runtime_asset), ) .route( "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", get(web_shell::filetree_context_menu_runtime_asset), ) .route( "/api/mnote-browser-runtime/filetree-dnd-runtime.js", get(web_shell::filetree_dnd_runtime_asset), ) .route( "/api/mnote-browser-runtime/filetree-keyboard-runtime.js", get(web_shell::filetree_keyboard_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-shell-runtime.js", get(web_shell::sidebar_shell_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-workspace-runtime.js", get(web_shell::sidebar_workspace_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-page-tree-runtime.js", get(web_shell::sidebar_page_tree_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-tree-live-apply-runtime.js", get(web_shell::sidebar_tree_live_apply_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js", get(web_shell::sidebar_filetree_open_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-filetree-command-runtime.js", get(web_shell::sidebar_filetree_command_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js", get(web_shell::sidebar_filetree_upload_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js", get(web_shell::sidebar_attachment_open_runtime_asset), ) .route( "/api/mnote-browser-runtime/sidebar-tree-runtime.js", get(web_shell::sidebar_tree_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-live-controller.js", get(web_shell::tree_live_controller_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-runtime.js", get(web_shell::tree_shell_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-page-runtime.js", get(web_shell::tree_shell_page_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-state-runtime.js", get(web_shell::tree_shell_state_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-icons-runtime.js", get(web_shell::tree_shell_icons_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js", get(web_shell::tree_shell_filetree_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js", get(web_shell::tree_shell_filetree_menu_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js", get(web_shell::tree_shell_filetree_dnd_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", get(web_shell::tree_shell_picker_runtime_asset), ) .route( "/api/mnote-browser-runtime/tree-shell-dom-runtime.js", get(web_shell::tree_shell_dom_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", get(web_shell::document_conflict_panel_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-pane-runtime.js", get(web_shell::document_pane_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-mindmap-host-runtime.js", get(web_shell::document_mindmap_host_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-resource-tab-runtime.js", get(web_shell::document_resource_tab_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-session-runtime.js", get(web_shell::document_session_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-slash-position-runtime.js", get(web_shell::document_slash_position_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", get(web_shell::document_tiptap_conversion_runtime_asset), ) .route( "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", get(web_shell::document_editor_adapter_runtime_asset), ) .route("/api/search/documents", post(search::documents)) .route( "/api/search/local-index/refresh", post(search::refresh_local_index), ) .route( "/api/search/local-index/backlinks", get(search::local_index_backlinks), ) .route( "/api/search/local-index/tags", get(search::local_index_tags), ) .route("/api/gateway/health", get(gateway::gateway_health)) .route("/api/dev/hot-reload", get(dev_hot::hot_reload)) .route("/api/runtime/config", get(session::runtime_config)) .route("/api/auth", post(gateway::auth_api)) .route("/api/auth/session", get(session::session)) .route("/api/auth/whoami", get(session::session)) .route("/api/auth/mnote-web-token", get(session::session)) .route("/api/auth/session/refresh", post(session::refresh_session)) .route( "/api/admin/access-policy", get(local_folder_source::get_local_access_policy), ) .route( "/api/admin/access-policy/validate-root", post(local_folder_source::validate_local_access_root), ) .route( "/api/admin/access-policy/grants", post(local_folder_source::create_local_access_grant), ) .route( "/api/admin/access-policy/grants/{grant_id}", delete(local_folder_source::delete_local_access_grant), ) .route( "/api/user/access-policy", get(local_folder_source::get_user_access_policy), ) .route( "/api/user/access-policy/grants", post(local_folder_source::create_user_access_grant), ) .route( "/api/user/access-policy/grants/{grant_id}", delete(local_folder_source::delete_user_access_grant), ) .route( "/api/admin/share-links", get(local_folder_source::get_share_links).post(local_folder_source::create_share_link), ) .route( "/api/admin/share-links/{link_id}", delete(local_folder_source::delete_share_link), ) .route( "/api/admin/share-grants", get(local_folder_source::get_share_grants) .post(local_folder_source::create_share_grant), ) .route( "/api/admin/share-grants/{share_id}", delete(local_folder_source::delete_share_grant), ) .route( "/api/user/share-grants", get(local_folder_source::get_user_share_grants) .post(local_folder_source::create_user_share_grant), ) .route( "/api/user/share-grants/{share_id}", delete(local_folder_source::delete_user_share_grant), ) .route( "/api/local-folder/shared-cache/record", post(local_folder_source::record_shared_cache), ) .route( "/api/local-folder/sync/pending-change", post(local_folder_source::record_sync_pending_change), ) .route( "/api/local-folder/sync/conflict-report", post(local_folder_source::write_sync_conflict_report), ) .route("/api/ai-agent/run", post(compat::next_ai_agent_run)) .route( "/api/page-ai/block-edit-workflow", post(page_ai_workflow::block_edit_workflow), ) .route("/onlyoffice", get(onlyoffice::page)) .route( "/office/{document_id}/{asset_id}", get(onlyoffice::object_shell), ) .route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy)) .route("/cache/{*path}", any(onlyoffice::cache_proxy)) .route("/api/onlyoffice/sign", post(onlyoffice::sign)) .route("/api/onlyoffice/proxy", get(onlyoffice::proxy)) .route("/api/onlyoffice/callback", post(onlyoffice::callback)) .route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave)) .route("/api/media/upload", post(media::upload)) .route("/api/media/sign", get(media::sign)) .route("/api/media/batch", post(resource_trash::media_batch)) .route("/api/media/purge", post(resource_trash::media_purge)) .route( "/api/media/empty-trash", post(resource_trash::media_empty_trash), ) .route("/api/tables/restore", post(resource_trash::table_restore)) .route("/api/tables/create", post(resource_trash::table_create)) .route( "/api/tables/{table_id}", delete(resource_trash::table_delete), ) .route("/api/tables/purge", post(resource_trash::table_purge)) .route( "/api/tables/empty-trash", post(resource_trash::table_empty_trash), ) .route( "/api/tree/filetree/upload-target-preflight", post(media::filetree_upload_target_preflight), ) .route( "/api/tree/filetree/drop-preflight", post(tree::filetree_drop_preflight), ) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) .route("/api/documents/buffer-state", get(documents::buffer_state)) .route("/api/documents/purge", post(documents::purge)) .route("/api/documents/empty-trash", post(documents::empty_trash)) .route("/api/documents/title", post(documents::title)) .route("/api/documents/options", post(documents::options)) .route("/api/documents/save", post(documents::save)) .route("/api/page-body/write", post(documents::page_body_write)) .route( "/api/documents/runtime/transform", post(editor::transform_runtime_snapshot), ) .route( "/api/tree/projections/sidebar", get(kernel::project_tree_sidebar), ) .route("/api/tree/projections/page", get(kernel::project_tree_page)) .route("/api/tree/projections/file", get(kernel::project_tree_file)) .route( "/api/kernel/projections/sidebar", get(kernel::project_sidebar), ) .route("/api/kernel/subtree", get(kernel::subtree)) .route("/api/kernel/edges", get(kernel::edges)) .route("/api/kernel/graph", get(kernel::graph)) .route("/api/tree/commands", post(tree::tree_command)) .route( "/api/tree/local-folder-watch", get(tree::local_folder_watch), ) .route( "/api/local-folder/events", get(local_folder_events::local_folder_events), ) .route( "/api/local-folder/workspaces/default", post(local_folder_source::create_default_local_workspace), ) .route( "/api/local-folder/assets/upload", post(local_folder_source::upload_local_markdown_asset), ) .route( "/api/local-folder/files/open", get(local_folder_source::open_local_file), ) .route( "/api/local-folder/files/stat", get(local_folder_source::stat_local_file), ) .route( "/api/local-folder/resource/read", get(local_folder_source::read_local_resource), ) .route( "/api/local-folder/resource/write", post(local_folder_source::write_local_resource), ) .route( "/api/tree/runtime/reduce", post(tree::reduce_tree_shell_runtime), ) .route("/api/bridge/workspace", get(bridge::workspace)) .route("/api/bridge/request", get(bridge::request)) .route("/api/bridge/trace", get(bridge::trace)) .route("/api/tree/events", get(sse::tree_events)) .route("/api/stream/events", get(sse::events)) .route("/api/realtime/ws", get(ws::socket)) .nest( &hermes_base_path, Router::new() .route("/health", get(hermes::health)) .route("/bridge", post(hermes::bridge_runtime)) .route( "/client/sessions", get(hermes_client::list_sessions).post(hermes_client::create_session), ) .route( "/client/sessions/search", get(hermes_client::search_sessions), ) .route( "/client/sessions/{session_id}", get(hermes_client::get_session).delete(hermes_client::delete_session), ) .route( "/client/sessions/{session_id}/resume", post(hermes_client::resume_session), ) .route( "/client/sessions/{session_id}/rename", post(hermes_client::rename_session), ) .route( "/client/sessions/{session_id}/auto-title", post(hermes_client::auto_title_session), ) .route("/client/gateway/health", get(hermes_client::gateway_health)) .route("/client/profiles", get(hermes_client::list_profiles)) .route( "/client/profiles/active", put(hermes_client::switch_active_profile), ) .route( "/client/profiles/{profile_name}", get(hermes_client::get_profile), ) .route( "/client/profile-memory", get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory), ) .route("/client/skills", get(hermes_client::list_skills)) .route("/client/skills/toggle", put(hermes_client::toggle_skill)) .route("/client/tools/toggle", put(hermes_client::toggle_tool)) .route("/client/runs", post(hermes_client::create_run)) .route( "/client/sessions/{session_id}/queue/{queue_id}", delete(hermes_client::cancel_queued_run), ) .route("/client/events/{run_id}", get(hermes_client::stream_events)) .route( "/client/runs/{run_id}/abort", post(hermes_client::abort_run), ) .route( "/client/runs/{run_id}/resolve-permission", post(hermes_client::resolve_permission), ) .route("/client/models", get(hermes_client::list_models)) .route("/client/tools", get(hermes_client::list_tools)), ) .nest( "/api/hermes/tools", Router::new() .route("/mnote/manifest", get(hermes_tools::mnote_manifest)) .route("/mnote/call", post(hermes_tools::mnote_call)) .route("/mnote/audit", get(hermes_tools::mnote_audit)), ); if enable_debug_shell_routes { router = router .route("/tree", get(tree::tree_shell)) .route("/document-debug", get(editor::document_editor_shell)); } router.with_state(state) } #[cfg(test)] mod tests { use super::build_router; use crate::app::{AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::Router; use tower::ServiceExt; fn app(enable_debug_shell_routes: bool) -> Router { build_router(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, enable_debug_shell_routes, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn debug_shell_routes_are_not_mounted_by_default() { for path in ["/tree", "/document-debug"] { let response = app(false) .oneshot( Request::builder() .uri(path) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!( response.status(), StatusCode::NOT_FOUND, "{path} 默认不应暴露" ); } } #[tokio::test] async fn debug_shell_routes_are_only_available_when_explicitly_enabled() { let tree_response = app(true) .oneshot( Request::builder() .uri("/tree") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_ne!( tree_response.status(), StatusCode::NOT_FOUND, "/tree 显式启用 debug gate 后不应继续返回 404", ); let document_debug_response = app(true) .oneshot( Request::builder() .uri("/document-debug") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_ne!( document_debug_response.status(), StatusCode::NOT_FOUND, "/document-debug 显式启用 debug gate 后不应继续返回 404", ); } #[tokio::test] async fn onlyoffice_and_media_transport_routes_are_explicitly_mounted() { for (method, path) in [ ("GET", "/onlyoffice"), ("POST", "/api/onlyoffice/sign"), ("GET", "/api/onlyoffice/proxy?u=x"), ("POST", "/api/onlyoffice/callback"), ("POST", "/api/onlyoffice/forcesave"), ("POST", "/api/media/upload"), ("GET", "/api/media/sign?assetId=x"), ("POST", "/api/tree/filetree/upload-target-preflight"), ( "GET", "/onlyoffice-server/web-apps/apps/api/documents/api.js", ), ] { let response = app(false) .oneshot( Request::builder() .method(method) .uri(path) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_ne!( response.status(), StatusCode::NOT_FOUND, "{method} {path} 应显式挂到 Rust 3000 单入口边界", ); } } #[tokio::test] async fn mnote_browser_runtime_assets_are_explicitly_mounted() { for path in [ "/api/mnote-browser-runtime/resource-open-runtime.js", "/api/mnote-browser-runtime/local-upload-runtime.js", "/api/mnote-browser-runtime/filetree-runtime.js", "/api/mnote-browser-runtime/filetree-selection-runtime.js", "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", "/api/mnote-browser-runtime/filetree-dnd-runtime.js", "/api/mnote-browser-runtime/filetree-keyboard-runtime.js", "/api/mnote-browser-runtime/sidebar-shell-runtime.js", "/api/mnote-browser-runtime/sidebar-workspace-runtime.js", "/api/mnote-browser-runtime/sidebar-page-tree-runtime.js", "/api/mnote-browser-runtime/sidebar-tree-live-apply-runtime.js", "/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js", "/api/mnote-browser-runtime/sidebar-filetree-command-runtime.js", "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js", "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js", "/api/mnote-browser-runtime/sidebar-tree-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", "/api/mnote-browser-runtime/tree-shell-page-runtime.js", "/api/mnote-browser-runtime/tree-shell-state-runtime.js", "/api/mnote-browser-runtime/tree-shell-icons-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js", "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", "/api/mnote-browser-runtime/tree-shell-dom-runtime.js", "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", "/api/mnote-browser-runtime/document-pane-runtime.js", "/api/mnote-browser-runtime/document-mindmap-host-runtime.js", "/api/mnote-browser-runtime/document-resource-tab-runtime.js", "/api/mnote-browser-runtime/document-session-runtime.js", "/api/mnote-browser-runtime/document-slash-position-runtime.js", "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", ] { let response = app(false) .oneshot( Request::builder() .uri(path) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!( response.status(), StatusCode::OK, "{path} 应返回 runtime asset" ); let content_type = response .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or(""); assert!( content_type.starts_with("application/javascript"), "{path} 应声明 JavaScript content-type" ); } } #[tokio::test] async fn dev_hot_reload_endpoint_is_mounted() { let response = app(false) .oneshot( Request::builder() .uri("/api/dev/hot-reload") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); } }