Files
mnote/rust/crates/mnote-web/src/routes/gateway.rs
T

1381 lines
50 KiB
Rust
Raw Normal View History

2026-04-29 12:24:44 +08:00
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
2026-05-08 00:41:03 +08:00
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
2026-04-29 14:36:24 +08:00
use crate::routes::web_shell::{
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
2026-04-29 14:36:24 +08:00
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
2026-05-08 00:41:03 +08:00
render_local_file_tree_html, render_local_sidebar_tree_html,
2026-04-29 14:36:24 +08:00
};
use crate::transport::convex::execute_convex_mutation_by_name;
2026-05-08 00:41:03 +08:00
use crate::workspace_shell::{
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
2026-04-29 12:24:44 +08:00
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};
2026-04-29 14:36:24 +08:00
use serde_json::json;
2026-04-29 12:24:44 +08:00
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";
2026-05-06 21:44:20 +08:00
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";
2026-04-29 12:24:44 +08:00
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GatewayManifest {
ok: bool,
owner: &'static str,
public_entry: String,
legacy_next_base_url: Option<String>,
legacy_next_compat_enabled: bool,
notes: Vec<&'static str>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
2026-04-29 14:36:24 +08:00
workspace_id: Option<String>,
2026-05-08 00:41:03 +08:00
source_kind: Option<String>,
root_uri: Option<String>,
2026-04-29 12:24:44 +08:00
}
pub async fn gateway_health(State(state): State<AppState>) -> 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
}
2026-05-06 21:44:20 +08:00
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(
2026-04-29 12:24:44 +08:00
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
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;
}
2026-05-06 21:44:20 +08:00
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<AppState>,
Extension(context): Extension<RequestContext>,
_request: Request<Body>,
) -> Result<Response, WebError> {
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);
}
2026-04-29 12:24:44 +08:00
let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE Auth</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
2026-04-29 14:36:24 +08:00
) -> Result<Response, WebError> {
2026-05-06 21:44:20 +08:00
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);
}
2026-05-08 00:41:03 +08:00
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
2026-04-29 14:36:24 +08:00
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
2026-04-29 12:24:44 +08:00
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
2026-04-29 14:36:24 +08:00
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
2026-04-29 12:24:44 +08:00
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
2026-05-08 00:41:03 +08:00
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,
)
};
2026-04-29 12:24:44 +08:00
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
2026-04-29 14:36:24 +08:00
Some(file_tree_html.as_str()),
2026-04-29 12:24:44 +08:00
);
let workspace_name = workspace_projection.workspace_name.clone();
2026-04-29 14:36:24 +08:00
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! {
<crate::ssr::pages::home::HomePage
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
/>
})
};
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()),
2026-05-08 00:41:03 +08:00
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());
2026-05-08 00:41:03 +08:00
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! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
document_id={active_page_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
page_subtree_json={page_subtree_json}
/>
});
let body_extra = format!(
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
{}
{}"#,
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()),
}
};
2026-04-29 12:24:44 +08:00
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
2026-04-29 12:24:44 +08:00
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
{}
{}
2026-04-29 12:24:44 +08:00
</body>
</html>"#,
escape_html(&html_title),
2026-04-29 12:24:44 +08:00
crate::ssr::MNOTE_CSS,
content,
body_extra
2026-04-29 12:24:44 +08:00
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
2026-04-29 14:36:24 +08:00
Ok(response)
2026-04-29 12:24:44 +08:00
}
pub async fn legacy_next_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
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)
}
2026-04-29 14:36:24 +08:00
async fn resolve_root_workspace_id(
state: &AppState,
context: &RequestContext,
requested_workspace_id: Option<&str>,
) -> Result<String, WebError> {
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())
}
2026-05-06 21:44:20 +08:00
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<serde_json::Value, WebError> {
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);
}
}
2026-04-29 14:36:24 +08:00
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<String> {
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)
}
2026-04-29 12:24:44 +08:00
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
2026-04-29 14:36:24 +08:00
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())
}
2026-04-29 12:24:44 +08:00
} else {
2026-04-29 14:36:24 +08:00
None
2026-04-29 12:24:44 +08:00
}
2026-04-29 14:36:24 +08:00
})
2026-04-29 12:24:44 +08:00
}
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,
2026-05-06 21:44:20 +08:00
) -> 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<String>,
2026-04-29 12:24:44 +08:00
) -> 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(),
2026-05-06 21:44:20 +08:00
convex_url,
2026-04-29 12:24:44 +08:00
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
2026-04-29 14:36:24 +08:00
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
2026-04-29 12:24:44 +08:00
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
2026-05-06 21:44:20 +08:00
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}")
}
2026-04-29 12:24:44 +08:00
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#"<html><body><button>测试账号快速登录</button></body></html>"#)
})
.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("/")
2026-05-06 21:44:20 +08:00
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
2026-04-29 12:24:44 +08:00
.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");
2026-04-29 14:36:24 +08:00
assert!(html
.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
2026-04-29 12:24:44 +08:00
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#"<a href="/documents">文档</a>"#));
}
2026-04-29 14:36:24 +08:00
#[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);
}
2026-04-29 12:24:44 +08:00
#[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")
2026-04-29 14:36:24 +08:00
.header("x-mnote-workspace-id", "ws_demo")
2026-05-06 21:44:20 +08:00
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
2026-04-29 12:24:44 +08:00
.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""#));
2026-04-29 14:36:24 +08:00
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""#));
2026-04-29 12:24:44 +08:00
}
2026-05-08 00:41:03 +08:00
#[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"#));
}
2026-05-06 21:44:20 +08:00
#[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""#));
}
2026-04-29 12:24:44 +08:00
#[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]
2026-05-06 21:44:20 +08:00
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::<Vec<_>>();
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() {
2026-04-29 12:24:44 +08:00
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()),
2026-05-06 21:44:20 +08:00
None
2026-04-29 12:24:44 +08:00
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
2026-05-06 21:44:20 +08:00
assert!(html.contains(r#"data-mnote-shell="auth""#));
assert!(html.contains("邮箱登录"));
2026-04-29 12:24:44 +08:00
assert!(html.contains("测试账号快速登录"));
}
#[tokio::test]
2026-05-06 21:44:20 +08:00
async fn auth_entry_redirects_authenticated_viewer_to_root() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
2026-04-29 12:24:44 +08:00
.oneshot(
Request::builder()
.uri("/auth")
2026-05-06 21:44:20 +08:00
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
2026-04-29 12:24:44 +08:00
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
2026-05-06 21:44:20 +08:00
assert_eq!(response.status(), StatusCode::SEE_OTHER);
2026-04-29 12:24:44 +08:00
assert_eq!(
response
.headers()
2026-05-06 21:44:20 +08:00
.get(header::LOCATION)
2026-04-29 12:24:44 +08:00
.and_then(|value| value.to_str().ok()),
2026-05-06 21:44:20 +08:00
Some("/")
2026-04-29 12:24:44 +08:00
);
}
#[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::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("__convexAuthJWT=jwt-demo")));
assert!(values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
}
}