use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::web_shell::{ build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection, render_document_title_controller_script, render_editor_island_adapter_script, }; use crate::transport::convex::execute_convex_mutation_by_name; use crate::workspace_shell::render_workspace_shell_sidebar_html; use axum::body::Body; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::time::Duration; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream"; const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct GatewayManifest { ok: bool, owner: &'static str, public_entry: String, legacy_next_base_url: Option, legacy_next_compat_enabled: bool, notes: Vec<&'static str>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RootEntryQuery { page_id: Option, workspace_id: Option, } pub async fn gateway_health(State(state): State) -> Response { let mut response = axum::Json(GatewayManifest { ok: true, owner: "mnote-web", public_entry: state.config().public_bind_addr.clone(), legacy_next_base_url: state.config().legacy_next_base_url.clone(), legacy_next_compat_enabled: state.config().enable_legacy_next_compat, notes: vec![ "3000 公开入口默认由 mnote-web gateway 拥有。", "Next App Router 只作为 legacy compat upstream 使用。", ], }) .into_response(); stamp_gateway_headers(response.headers_mut(), false); response } pub async fn auth_entry( State(state): State, Extension(context): Extension, request: Request, ) -> Result { if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() { return legacy_next_proxy(State(state), Extension(context), request).await; } let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage()); let mut response = Html(format!( r#" MNOTE Auth {} "#, crate::ssr::MNOTE_CSS, content )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); Ok(response) } pub async fn root_entry( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let requested_page_id = normalize_optional_id(query.page_id.as_deref()); let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID); let recent_page_id = normalize_optional_id(recent_page_id.as_deref()); let requested_or_recent_page_id = choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None); let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let workspace_projection = load_workspace_shell_projection( state.config(), &context, &workspace_id, requested_or_recent_page_id.as_deref(), &default_workspace_name, ) .await; let selected_active_page_id = choose_root_entry_active_page_id( requested_page_id, recent_page_id, workspace_projection.active_page_id.as_deref(), workspace_projection .my_page_items .first() .map(|item| item.id.as_str()), ); let sidebar_tree_html = load_sidebar_tree_html( state.config(), &context, &workspace_id, selected_active_page_id.as_deref(), ) .await .unwrap_or_default(); let file_tree_html = load_file_tree_html( state.config(), &context, &workspace_id, selected_active_page_id.as_deref(), ) .await .unwrap_or_default(); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), ); let workspace_name = workspace_projection.workspace_name.clone(); let active_page_id = selected_active_page_id.unwrap_or_default(); let active_page_title = workspace_projection .active_page_title .clone() .unwrap_or_default(); let render_workspace_entry = || { crate::ssr::render_view(leptos::view! { }) }; let (html_title, content, body_extra) = if active_page_id.trim().is_empty() { ("MNOTE".to_string(), render_workspace_entry(), String::new()) } else { match build_page_aggregate_snapshot( &state, &context, &active_page_id, Some(workspace_id.as_str()), ) .await { Ok(aggregate) => { let title = aggregate.head.title.as_str(); let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree) .unwrap_or_else(|_| "null".to_string()); let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string()); let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context); let content = crate::ssr::render_view(leptos::view! { }); let body_extra = format!( r#" {} {}"#, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), render_document_title_controller_script(), render_editor_island_adapter_script(), ); (title.to_string(), content, body_extra) } Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), } }; let mut response = Html(format!( r#" {} {} {} "#, escape_html(&html_title), crate::ssr::MNOTE_CSS, content, body_extra )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); Ok(response) } pub async fn legacy_next_proxy( State(state): State, Extension(context): Extension, request: Request, ) -> Result { if !state.config().enable_legacy_next_compat { return Err(WebError::service_unavailable_code( "legacy_next_compat_disabled", "Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } let Some(base_url) = state.config().legacy_next_base_url.as_deref() else { return Err(WebError::service_unavailable_code( "legacy_next_upstream_missing", "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); }; let path_and_query = request .uri() .path_and_query() .map(|value| value.as_str()) .unwrap_or("/"); let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}")) .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; let method = request.method().clone(); let headers = request.headers().clone(); let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024) .await .map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?; let upstream_origin = upstream_origin(&upstream_url); let mut upstream_request = client.request(method, upstream_url); for (name, value) in headers.iter() { if is_hop_by_hop_header(name.as_str()) || name == header::HOST { continue; } if name == header::ORIGIN { upstream_request = upstream_request.header(name, upstream_origin.as_str()); continue; } if name == header::REFERER { let normalized_referer = normalize_legacy_referer(value, &upstream_origin); upstream_request = upstream_request.header(name, normalized_referer); continue; } upstream_request = upstream_request.header(name, value); } let upstream_response = upstream_request .body(body.to_vec()) .send() .await .map_err(|error| { WebError::bad_gateway_code( "legacy_next_proxy_error", format!("legacy Next 请求失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") })?; let status = upstream_response.status(); let upstream_headers = upstream_response.headers().clone(); let body = upstream_response.bytes().await.map_err(|error| { WebError::bad_gateway_code( "legacy_next_proxy_body_error", format!("legacy Next 响应读取失败: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") })?; let mut response = Response::builder() .status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY)) .body(Body::from(body)) .map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?; for (name, value) in upstream_headers.iter() { if is_hop_by_hop_header(name.as_str()) { continue; } response.headers_mut().append(name, value.clone()); } stamp_gateway_headers(response.headers_mut(), true); Ok(response) } async fn resolve_root_workspace_id( state: &AppState, context: &RequestContext, requested_workspace_id: Option<&str>, ) -> Result { if let Some(workspace_id) = normalize_optional_id(requested_workspace_id) .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref())) { return Ok(workspace_id.to_string()); } let bootstrap = execute_convex_mutation_by_name( state.config(), context, "workspaces:ensureDefaultWorkspace", json!({ "fallbackName": state.config().dev_user_name, "workspaceIdIfCreate": format!("ws_{}", context.trace.request_id), }), None, None, "root_workspace_bootstrap", ) .await?; bootstrap .get("activeWorkspaceId") .and_then(serde_json::Value::as_str) .and_then(|value| normalize_optional_id(Some(value))) .map(ToOwned::to_owned) .ok_or_else(|| { WebError::bad_gateway_code( "root_workspace_bootstrap_bad_response", "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId", ) .with_context(context) .with_header("x-error-phase", "root_workspace_bootstrap") }) } fn normalize_optional_id(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } fn choose_root_entry_active_page_id( requested_page_id: Option<&str>, recent_page_id: Option<&str>, projection_active_page_id: Option<&str>, first_page_id: Option<&str>, ) -> Option { normalize_optional_id(requested_page_id) .or_else(|| normalize_optional_id(recent_page_id)) .or_else(|| normalize_optional_id(projection_active_page_id)) .or_else(|| normalize_optional_id(first_page_id)) .map(ToOwned::to_owned) } fn extract_cookie_value(context: &RequestContext, name: &str) -> Option { context .auth .cookie_header .as_deref()? .split(';') .find_map(|part| { let (cookie_name, cookie_value) = part.trim().split_once('=')?; if cookie_name.trim() == name { let value = cookie_value.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } else { None } }) } fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if legacy { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) { headers.insert(name, HeaderValue::from_static("next-app-router")); } } } fn is_hop_by_hop_header(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), "connection" | "keep-alive" | "proxy-authenticate" | "proxy-authorization" | "te" | "trailers" | "transfer-encoding" | "upgrade" ) } fn upstream_origin(url: &reqwest::Url) -> String { let host = url.host_str().unwrap_or("127.0.0.1"); match url.port() { Some(port) => format!("{}://{}:{}", url.scheme(), host, port), None => format!("{}://{}", url.scheme(), host), } } fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String { let referer = value.to_str().unwrap_or_default(); let Ok(parsed) = reqwest::Url::parse(referer) else { return upstream_origin.to_string(); }; let path = parsed.path(); let query = parsed .query() .map(|query| format!("?{query}")) .unwrap_or_default(); format!("{upstream_origin}{path}{query}") } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use tokio::net::TcpListener; use tower::util::ServiceExt; fn app() -> axum::Router { app_with_legacy_next_base_url("http://127.0.0.1:3100".into()) } fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router { app_with_config(legacy_next_base_url, true) } fn app_with_config( legacy_next_base_url: String, enable_legacy_next_compat: bool, ) -> axum::Router { build_app(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(legacy_next_base_url), enable_legacy_next_compat, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } async fn spawn_legacy_auth_upstream() -> String { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("legacy listener"); let addr = listener.local_addr().expect("legacy addr"); let app = axum::Router::new().route( "/auth", get(|| async { Html(r#""#) }) .post(|| async { "auth-post-ok" }), ); tokio::spawn(async move { axum::serve(listener, app).await.expect("legacy server"); }); format!("http://{addr}") } async fn spawn_legacy_origin_checked_upstream() -> String { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("legacy listener"); let addr = listener.local_addr().expect("legacy addr"); let expected_origin = format!("http://{addr}"); let app = axum::Router::new().route( "/api/auth", post(move |headers: HeaderMap| { let expected_origin = expected_origin.clone(); async move { let origin = headers .get("origin") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); if origin != expected_origin { return (StatusCode::FORBIDDEN, "Invalid origin"); } (StatusCode::OK, "ok") } }), ); tokio::spawn(async move { axum::serve(listener, app).await.expect("legacy server"); }); format!("http://{addr}") } async fn spawn_legacy_cookie_upstream() -> String { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("legacy listener"); let addr = listener.local_addr().expect("legacy addr"); let app = axum::Router::new().route( "/api/auth", post(|| async { let mut response = "ok".into_response(); response.headers_mut().append( header::SET_COOKIE, HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"), ); response.headers_mut().append( header::SET_COOKIE, HeaderValue::from_static( "__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly", ), ); response }), ); tokio::spawn(async move { axum::serve(listener, app).await.expect("legacy server"); }); format!("http://{addr}") } #[tokio::test] async fn gateway_health_declares_mnote_web_owner() { let response = app() .oneshot( Request::builder() .uri("/api/gateway/health") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["publicEntry"], "127.0.0.1:3000"); assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100"); } #[tokio::test] async fn root_entry_returns_wolai_workspace_layout_contract() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html .contains(r#""#)); assert!(html.contains(r#"data-testid="wolai-sidebar""#)); assert!(html.contains(r#"data-testid="wolai-topbar""#)); assert!(html.contains(r#"data-testid="wolai-floating-ai""#)); assert!(html.contains("星标置顶")); assert!(html.contains("我的页面")); assert!(html.contains("垃圾箱")); assert!(html.contains("模板中心")); assert!(!html.contains("欢迎使用 MNOTE 知识管理平台")); assert!(!html.contains(r#"文档"#)); } #[test] fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() { let selected = super::choose_root_entry_active_page_id( Some("page_query"), Some("page_recent"), Some("page_projection"), Some("page_first"), ); assert_eq!(selected.as_deref(), Some("page_query")); } #[test] fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() { let from_recent = super::choose_root_entry_active_page_id( None, Some("page_recent"), Some("page_projection"), Some("page_first"), ); assert_eq!(from_recent.as_deref(), Some("page_recent")); let from_projection = super::choose_root_entry_active_page_id( Some(" "), None, Some("page_projection"), Some("page_first"), ); assert_eq!(from_projection.as_deref(), Some("page_projection")); let from_first = super::choose_root_entry_active_page_id(None, None, None, Some("page_first")); assert_eq!(from_first.as_deref(), Some("page_first")); let empty = super::choose_root_entry_active_page_id(None, None, None, None); assert_eq!(empty, None); } #[tokio::test] async fn root_entry_uses_recent_page_cookie_as_active_page() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/") .header("cookie", "mnote_recent_page_id=page_child") .header("x-mnote-workspace-id", "ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-node-id="page_child""#)); assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#)); assert!(html.contains(r#"data-active="true""#)); assert!(html.contains(r#"data-root-active-page-id="page_child""#)); } #[tokio::test] async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/auth") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); assert!(response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .contains("text/html")); } #[tokio::test] async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() { let legacy_base_url = spawn_legacy_auth_upstream().await; let response = app_with_legacy_next_base_url(legacy_base_url) .oneshot( Request::builder() .uri("/auth") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-legacy-upstream") .and_then(|value| value.to_str().ok()), Some("next-app-router") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("测试账号快速登录")); } #[tokio::test] async fn auth_entry_proxies_post_to_legacy_next_when_compat_enabled() { let legacy_base_url = spawn_legacy_auth_upstream().await; let response = app_with_legacy_next_base_url(legacy_base_url) .oneshot( Request::builder() .method("POST") .uri("/auth") .header("origin", "http://127.0.0.1:3000") .header("referer", "http://127.0.0.1:3000/auth") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-legacy-upstream") .and_then(|value| value.to_str().ok()), Some("next-app-router") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert_eq!(text, "auth-post-ok"); } #[tokio::test] async fn legacy_proxy_normalizes_auth_post_origin_to_upstream_origin() { let legacy_base_url = spawn_legacy_origin_checked_upstream().await; let response = app_with_legacy_next_base_url(legacy_base_url) .oneshot( Request::builder() .method("POST") .uri("/api/auth") .header("origin", "http://127.0.0.1:3000") .header("referer", "http://127.0.0.1:3000/auth") .body(Body::from(r#"{"action":"auth:signIn"}"#)) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-mnote-legacy-upstream") .and_then(|value| value.to_str().ok()), Some("next-app-router") ); } #[tokio::test] async fn legacy_proxy_preserves_multiple_set_cookie_headers() { let legacy_base_url = spawn_legacy_cookie_upstream().await; let response = app_with_legacy_next_base_url(legacy_base_url) .oneshot( Request::builder() .method("POST") .uri("/api/auth") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let cookies = response.headers().get_all("set-cookie"); let values = cookies .iter() .map(|value| value.to_str().unwrap_or_default()) .collect::>(); assert!(values .iter() .any(|value| value.contains("__convexAuthJWT=jwt-demo"))); assert!(values .iter() .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo"))); } }