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::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ build_document_panes_bootstrap_json, 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::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use futures_util::{SinkExt, StreamExt}; use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::time::Duration; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; 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(), None, ) .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 panes_bootstrap_json = build_document_panes_bootstrap_json( &aggregate, &context, active_source_kind.as_deref(), active_root_uri.as_deref(), None, None, None, false, false, ); let content = crate::ssr::render_view(leptos::view! { }); let body_extra = format!( r#" {} {}"#, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), escape_script_json(&panes_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 trash_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 default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let workspace_projection = load_workspace_shell_projection( state.config(), &context, &workspace_id, None, &default_workspace_name, ) .await; let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, &workspace_id, None) .await .unwrap_or_default(); let file_tree_html = load_file_tree_html(state.config(), &context, &workspace_id, None, None) .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 dataset = load_sidebar_dataset(state.config(), &context, &workspace_id) .await .unwrap_or_else(|_| { json!({ "active_workspace_id": workspace_id, "workspaces": [{ "id": workspace_id, "name": workspace_projection.workspace_name }], "documents": [], "trashed_documents": [], "trashed_media_assets": [], "trashed_mindmap_assets": [], "trashed_table_assets": [], "degraded": true }) }); let trash_workbench_html = render_trash_workbench_html(&workspace_id, &dataset); let workspace_name = workspace_projection.workspace_name.clone(); let content = crate::ssr::render_view(leptos::view! {
}); let mut response = Html(format!( r#" 垃圾箱 {} "#, crate::ssr::MNOTE_CSS, content, )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); context.apply_response_headers(response.headers_mut()); Ok(response) } fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String { let documents = json_array(dataset, "trashed_documents"); let media_assets = json_array(dataset, "trashed_media_assets"); let mindmap_assets = json_array(dataset, "trashed_mindmap_assets"); let table_assets = json_array(dataset, "trashed_table_assets"); let resource_count = media_assets.len() + mindmap_assets.len() + table_assets.len(); let document_rows = render_trashed_document_rows(workspace_id, documents); let mut resource_rows = String::new(); resource_rows.push_str(&render_trashed_resource_rows("media", "附件", media_assets)); resource_rows.push_str(&render_trashed_resource_rows( "mindmap", "思维导图", mindmap_assets, )); resource_rows.push_str(&render_trashed_resource_rows("table", "表格", table_assets)); let resource_body = if resource_count == 0 { r#"
暂无已删除资源
"#.to_string() } else { resource_rows }; format!( r#"

垃圾箱

默认删除的页面会先进入这里;彻底删除会永久移除。

页面 {document_count}

{document_rows}

资源 {resource_count}

{resource_body}

资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。

"#, workspace_id = escape_html(workspace_id), document_count = documents.len(), empty_disabled = if documents.is_empty() { " disabled" } else { "" }, resource_empty_disabled = if resource_count == 0 { " disabled" } else { "" }, resource_count = resource_count, document_rows = document_rows, resource_body = resource_body, ) } fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] { dataset .get(key) .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or(&[]) } fn render_trashed_document_rows(workspace_id: &str, documents: &[Value]) -> String { if documents.is_empty() { return r#"
暂无已删除页面
"#.to_string(); } documents .iter() .filter_map(|document| { let id = document.get("id").and_then(Value::as_str)?; let title = document .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题"); let deleted_at = document .get("deleted_at") .or_else(|| document.get("deletedAt")) .and_then(Value::as_str) .unwrap_or(""); Some(format!( r#"
{title} {deleted_at}
"#, id = escape_html(id), workspace_id = escape_html(workspace_id), title = escape_html(title), deleted_at = escape_html(deleted_at), )) }) .collect::>() .join("") } fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> String { resources .iter() .filter_map(|resource| { let id = resource.get("id").and_then(Value::as_str)?; let document_id = resource .get("document_id") .or_else(|| resource.get("documentId")) .and_then(Value::as_str) .unwrap_or(""); let title = resource .get("file_name") .or_else(|| resource.get("title")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("未命名资源"); let deleted_at = resource .get("deleted_at") .or_else(|| resource.get("deletedAt")) .and_then(Value::as_str) .unwrap_or(""); Some(format!( r#"
{label} {title} {deleted_at}
"#, kind = escape_html(kind), id = escape_html(id), document_id = escape_html(document_id), label = escape_html(label), title = escape_html(title), deleted_at = escape_html(deleted_at), )) }) .collect::>() .join("") } 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) } #[allow(dead_code)] pub async fn legacy_next_websocket_proxy( ws: WebSocketUpgrade, State(state): State, Extension(context): Extension, uri: Uri, ) -> 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 WebSocket。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); }; let upstream_url = build_legacy_next_ws_url(base_url, &uri)?; Ok(ws.on_upgrade(move |socket| async move { if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await { tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开"); } })) } #[allow(dead_code)] fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result { let upstream = reqwest::Url::parse(base_url) .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; let scheme = match upstream.scheme() { "https" => "wss", _ => "ws", }; let host = upstream .host_str() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?; let host_with_port = match upstream.port() { Some(port) => format!("{host}:{port}"), None => host.to_string(), }; let path_and_query = uri .path_and_query() .map(|value| value.as_str()) .unwrap_or("/"); Ok(format!("{scheme}://{host_with_port}{path_and_query}")) } #[allow(dead_code)] async fn proxy_legacy_next_websocket( socket: WebSocket, upstream_url: String, ) -> Result<(), Box> { let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?; let (mut client_tx, mut client_rx) = socket.split(); let (mut upstream_tx, mut upstream_rx) = upstream.split(); let client_to_upstream = async { while let Some(message) = client_rx.next().await { let Ok(message) = message else { break; }; if upstream_tx .send(axum_ws_to_tungstenite(message)) .await .is_err() { break; } } }; let upstream_to_client = async { while let Some(message) = upstream_rx.next().await { let Ok(message) = message else { break; }; if client_tx .send(tungstenite_to_axum_ws(message)) .await .is_err() { break; } } }; tokio::select! { _ = client_to_upstream => {} _ = upstream_to_client => {} } Ok(()) } #[allow(dead_code)] fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage { match message { AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()), AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value), AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value), AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value), AxumWsMessage::Close(_) => TungsteniteMessage::Close(None), } } #[allow(dead_code)] fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage { match message { TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()), TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value), TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value), TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value), TungsteniteMessage::Close(_) => AxumWsMessage::Close(None), TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None), } } 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 { app_with_query_fixtures( legacy_next_base_url, enable_legacy_next_compat, convex_url, None, ) } fn app_with_query_fixtures( legacy_next_base_url: String, enable_legacy_next_compat: bool, convex_url: Option, query_fixtures_json: 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, enable_editor_actor: true, 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, 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}") } async fn spawn_legacy_unmatched_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("/unmigrated", get(|| async { "legacy-ok" })); 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#"文档"#)); } #[tokio::test] async fn trash_entry_renders_real_workspace_trash_workbench() { let response = app_with_query_fixtures( "http://127.0.0.1:3100".into(), false, None, Some( r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(), ), ) .oneshot( Request::builder() .uri("/trash?workspaceId=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-testid="mnote-trash-workbench""#)); assert!(html.contains("已删页面")); assert!(html.contains(r#"data-trash-action="restore""#)); assert!(html.contains(r#"data-trash-action="purge""#)); assert!(html.contains(r#"data-trash-action="empty-documents""#)); assert!(html.contains(r#"data-document-id="page_trash""#)); assert!(html.contains(r#"data-trash-action="empty-resources""#)); assert!(html.contains(r#"data-trash-action="resource-restore""#)); assert!(html.contains(r#"data-trash-action="resource-purge""#)); assert!(html.contains(r#"data-resource-kind="media""#)); assert!(html.contains(r#"data-resource-kind="mindmap""#)); assert!(html.contains(r#"data-resource-kind="table""#)); assert!(html.contains("已删附件.png")); assert!(html.contains("已删思维导图.json")); assert!(html.contains("已删表格.luckysheet")); assert!(html.contains("new EventSource")); assert!(html.contains("/api/tree/events")); assert!(html.contains("refreshTrashWorkbenchFromServer")); assert!(!html.contains("window.location.reload")); } #[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_active_page_includes_document_panes_bootstrap() { let response = app_with_query_fixtures( "http://127.0.0.1:3100".into(), false, None, Some( r#"{ "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "服务端页面", "updated_at": "2026-04-18T09:30:00Z", "can_edit": true, "word_count": 42, "character_count": 128, "block_count": 1 }, "documents:getContent": { "content": [{"id": "block_1", "type": "paragraph", "content": []}], "revision": 7, "conflict_detection_key": "doc_1:7", "pageSubtree": {"rootNodeId": "doc_1", "outline": []} } }"# .into(), ), ) .oneshot( Request::builder() .uri("/?pageId=doc_1&workspaceId=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("__MNOTE_PAGE_AGGREGATE__")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("mnote.document_panes_bootstrap.v1")); } #[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"))); } #[tokio::test] async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() { let legacy_base_url = spawn_legacy_unmatched_upstream().await; let response = app_with_legacy_next_base_url(legacy_base_url) .oneshot( Request::builder() .uri("/unmigrated") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!( response .headers() .get("x-mnote-legacy-upstream") .and_then(|value| value.to_str().ok()), None ); } }