use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot; 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, render_local_file_tree_html, render_local_sidebar_tree_html, }; use crate::transport::convex::execute_convex_mutation_by_name; use crate::workspace_shell::{ build_workspace_shell_projection, 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"; const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT"; const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken"; const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token"; const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session"; #[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, source_kind: Option, root_uri: 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 favicon() -> Response { let mut response = Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response()); stamp_gateway_headers(response.headers_mut(), false); response } pub async fn auth_api( 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 body = axum::body::to_bytes(request.into_body(), 256 * 1024) .await .map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?; let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| { WebError::bad_request_code( "auth_bad_request", format!("登录请求不是合法 JSON: {error}"), ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; let action = payload .get("action") .and_then(|value| value.as_str()) .unwrap_or_default(); if action != "auth:signIn" && action != "auth:signOut" { return Err(WebError::bad_request_code( "auth_action_unsupported", "Rust gateway 当前仅支持 Convex Auth 登录与登出动作。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } let convex_response = run_convex_auth_action(&state, &context, &payload).await?; Ok(build_auth_proxy_response(&convex_response, &context)) } pub async fn auth_entry( State(_state): State, Extension(context): Extension, _request: Request, ) -> Result { if has_real_auth_context(&context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } 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 { if !has_real_auth_context(&context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } let is_local_folder = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) == Some("local_folder"); 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 default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let ( workspace_id, workspace_projection, sidebar_tree_html, file_tree_html, selected_active_page_id, active_source_kind, active_root_uri, ) = if is_local_folder { let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; let workspace_id = snapshot .dataset .get("workspace") .and_then(|workspace| workspace.get("id")) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("local-folder") .to_string(); let requested_or_recent_page_id = choose_root_entry_active_page_id( requested_page_id.clone(), recent_page_id.clone(), None, None, ); let workspace_projection = build_workspace_shell_projection( &snapshot.dataset, &workspace_id, requested_or_recent_page_id.as_deref(), "本地文件夹", ); let selected_active_page_id = choose_root_entry_active_page_id( requested_page_id.clone(), recent_page_id.clone(), workspace_projection.active_page_id.as_deref(), workspace_projection .my_page_items .first() .map(|item| item.id.as_str()), ); let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?; let file_tree_html = render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?; ( workspace_id, workspace_projection, sidebar_tree_html, file_tree_html, selected_active_page_id, Some("local_folder".to_string()), Some(root_uri.to_string()), ) } else { let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let requested_or_recent_page_id = choose_root_entry_active_page_id( requested_page_id.clone(), recent_page_id.clone(), None, None, ); 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.clone(), recent_page_id.clone(), 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(); ( workspace_id, workspace_projection, sidebar_tree_html, file_tree_html, selected_active_page_id, None, None, ) }; 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()), active_source_kind.as_deref(), active_root_uri.as_deref(), ) .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, active_source_kind.as_deref(), active_root_uri.as_deref(), ); 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 has_real_auth_context(context: &RequestContext) -> bool { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return true; } extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some() || extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some() } async fn run_convex_auth_action( state: &AppState, context: &RequestContext, payload: &serde_json::Value, ) -> Result { let convex_url = state .config() .convex_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::service_unavailable_code( "convex_config_missing", "缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法执行 Convex Auth。", ) .with_context(context) .with_header("x-error-phase", "convex_auth_url") .with_header("x-upstream-service", "convex") })?; let action = payload .get("action") .and_then(|value| value.as_str()) .unwrap_or_default(); let mut args = payload.get("args").cloned().unwrap_or_else(|| json!({})); if action == "auth:signIn" && args .get("refreshToken") .map(|value| !value.is_null()) .unwrap_or(false) { if let Some(refresh_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_REFRESH_TOKEN) { args["refreshToken"] = serde_json::Value::String(refresh_token); } } let request_body = json!({ "path": action, "format": "convex_encoded_json", "args": [args], }); let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .build() .map_err(|error| { WebError::internal(format!("Convex Auth HTTP 客户端创建失败: {error}")) .with_context(context) .with_header("x-error-phase", "convex_auth_client") .with_header("x-upstream-service", "convex") })?; let mut request = client .post(format!("{}/api/action", convex_url.trim_end_matches('/'))) .header("Content-Type", "application/json") .header("Convex-Client", "mnote-web") .json(&request_body); if let Some(token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) { request = request.header(header::AUTHORIZATION, format!("Bearer {token}")); } let response = request.send().await.map_err(|error| { WebError::bad_gateway_code( "convex_auth_proxy_error", format!("Convex Auth 请求失败: {error}"), ) .with_context(context) .with_header("x-error-phase", "convex_auth_action") .with_header("x-upstream-service", "convex") })?; let status = response.status(); let value: serde_json::Value = response.json().await.map_err(|error| { WebError::bad_gateway_code( "convex_auth_response_invalid", format!("Convex Auth 响应不是合法 JSON: {error}"), ) .with_context(context) .with_header("x-error-phase", "convex_auth_decode") .with_header("x-upstream-service", "convex") })?; if !status.is_success() && status.as_u16() != 560 { return Err(WebError::bad_gateway_code( "convex_auth_upstream_error", format!("Convex Auth 返回 HTTP {}: {}", status.as_u16(), value), ) .with_context(context) .with_header("x-error-phase", "convex_auth_status") .with_header("x-upstream-service", "convex")); } Ok(value) } fn build_auth_proxy_response( convex_response: &serde_json::Value, context: &RequestContext, ) -> Response { if convex_response .get("status") .and_then(|value| value.as_str()) != Some("success") { let message = convex_response .get("errorMessage") .and_then(|value| value.as_str()) .unwrap_or("Convex Auth 登录失败。"); let mut response = axum::Json(json!({ "error": message })).into_response(); *response.status_mut() = StatusCode::BAD_REQUEST; clear_auth_cookies(response.headers_mut()); stamp_gateway_headers(response.headers_mut(), false); context.apply_response_headers(response.headers_mut()); return response; } let value = convex_response .get("value") .cloned() .unwrap_or_else(|| json!({})); let tokens = value.get("tokens"); let mut response_body = value.clone(); if let Some(tokens) = tokens { if tokens.is_null() { response_body["tokens"] = serde_json::Value::Null; } else if let Some(token) = tokens.get("token").and_then(|value| value.as_str()) { response_body["tokens"] = json!({ "token": token, "refreshToken": "dummy", }); } } let mut response = axum::Json(response_body).into_response(); if let Some(tokens) = tokens { if tokens.is_null() { clear_auth_cookies(response.headers_mut()); } else { set_auth_cookie_from_value( response.headers_mut(), COOKIE_CONVEX_AUTH_JWT, tokens.get("token"), ); set_auth_cookie_from_value( response.headers_mut(), COOKIE_CONVEX_AUTH_REFRESH_TOKEN, tokens.get("refreshToken"), ); expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION); } } stamp_gateway_headers(response.headers_mut(), false); context.apply_response_headers(response.headers_mut()); response } fn set_auth_cookie_from_value( headers: &mut axum::http::HeaderMap, name: &'static str, value: Option<&serde_json::Value>, ) { let Some(value) = value.and_then(|value| value.as_str()) else { return; }; let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax"); if let Ok(value) = HeaderValue::from_str(&cookie) { headers.append(header::SET_COOKIE, value); } } fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) { expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT); expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN); expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION); } fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) { let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); if let Ok(value) = HeaderValue::from_str(&cookie) { headers.append(header::SET_COOKIE, value); } } 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 { app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None) } fn app_with_config_and_convex_url( legacy_next_base_url: String, enable_legacy_next_compat: bool, convex_url: Option, ) -> 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, 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_convex_auth_upstream() -> String { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("convex auth listener"); let addr = listener.local_addr().expect("convex auth addr"); let app = axum::Router::new().route( "/api/action", post(|| async { axum::Json(serde_json::json!({ "status": "success", "value": { "tokens": { "token": "jwt-demo", "refreshToken": "refresh-demo" } } })) }), ); tokio::spawn(async move { axum::serve(listener, app) .await .expect("convex auth server"); }); format!("http://{addr}") } 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("/") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .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") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .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 root_entry_renders_local_folder_without_debug_tree_route() { let root = std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("docs")).expect("create local docs"); std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md"); std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md"); std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset"); let root_uri = format!("file://{}", root.display()); let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri(format!( "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree" )) .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); 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-mnote-shell="workspace""#)); assert!(html.contains("local_folder")); assert!(html.contains("Local Root")); assert!(html.contains(r#"data-row-id="local:folder:docs""#)); assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#)); assert!(!html.contains("legacy_next_compat_disabled")); assert!(!html.contains(r#"href="/tree"#)); } #[tokio::test] async fn root_entry_redirects_anonymous_viewer_to_auth() { 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::SEE_OTHER); assert_eq!( response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()), Some("/auth") ); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); } #[tokio::test] async fn root_entry_allows_forwarded_actor_to_enter_workspace() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .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-mnote-shell="workspace""#)); } #[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 favicon_is_handled_by_gateway_when_compat_disabled() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/favicon.ico") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!( response .headers() .get("x-mnote-web-owner") .and_then(|value| value.to_str().ok()), Some("mnote-web") ); } #[tokio::test] async fn auth_api_sets_convex_auth_cookies_when_compat_disabled() { let convex_url = spawn_convex_auth_upstream().await; let response = app_with_config_and_convex_url( "http://127.0.0.1:3100".into(), false, Some(convex_url), ) .oneshot( Request::builder() .method("POST") .uri("/api/auth") .header("content-type", "application/json") .body(Body::from( r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#, )) .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 cookies = response.headers().get_all(header::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"))); 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["tokens"]["token"], "jwt-demo"); assert_eq!(payload["tokens"]["refreshToken"], "dummy"); } #[tokio::test] async fn auth_entry_uses_mnote_web_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()), None ); 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-mnote-shell="auth""#)); assert!(html.contains("邮箱登录")); assert!(html.contains("测试账号快速登录")); } #[tokio::test] async fn auth_entry_redirects_authenticated_viewer_to_root() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/auth") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( response .headers() .get(header::LOCATION) .and_then(|value| value.to_str().ok()), Some("/") ); } #[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"))); } }