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 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; 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("/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/search/documents", post(search::documents)) .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/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("/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/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/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/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/{session_id}", get(hermes_client::get_session), ) .route( "/client/sessions/{session_id}/resume", post(hermes_client::resume_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/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 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); } }