use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::provider_identity_sync::sync_provider_identities; use crate::routes::local_folder_source::{ create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_actor, is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot_with_reveal, load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ attach_sidebar_shortcuts_to_dataset, 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_editor_runtime_preload_links, render_local_file_tree_html, render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html, render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot, }; use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name; use crate::workspace_shell::{ apply_active_page, 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 base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use control_plane::{ session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, }; use futures_util::{SinkExt, StreamExt}; use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; 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"; const COOKIE_MNOTE_SESSION: &str = "mnote_session"; const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id"; const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type"; const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email"; const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name"; #[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, file_tree_scope: Option, tree_view: Option, restore_focus_row_id: Option, route_guard: Option, missing_page: 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 { 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 当前仅支持账号登录、注册与登出动作。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } if action == "auth:signOut" { return Ok(build_control_plane_sign_out_response(&state, &context)); } handle_control_plane_auth_action(&state, &context, &payload).await } pub async fn auth_entry( State(state): State, Extension(context): Extension, _request: Request, ) -> Result { if has_real_auth_context(&state, &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 admin_access_policy_entry( State(state): State, Extension(context): Extension, ) -> Result { if !has_real_auth_context(&state, &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); } if !current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_admin_required", "只有管理员可以访问目录授权页面", ) .with_context(&context)); } let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/admin/ai#ai-admin-access") .body(Body::empty()) .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); Ok(response) } pub async fn user_access_policy_entry( State(state): State, Extension(context): Extension, ) -> Result { if !has_real_auth_context(&state, &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 mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/user/ai#ai-admin-access") .body(Body::empty()) .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); Ok(response) } pub async fn admin_ai_entry( State(state): State, Extension(context): Extension, ) -> Result { if !has_real_auth_context(&state, &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); } if !current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "ai_admin_required", "只有管理员可以访问 AI 管理中心", ) .with_context(&context)); } ai_management_response(&state, &context, true) } pub async fn settings_entry( State(state): State, Extension(context): Extension, ) -> Result { if !has_real_auth_context(&state, &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); } ai_management_response( &state, &context, current_actor_is_local_admin(&state, &context), ) } pub async fn user_ai_entry( State(state): State, Extension(context): Extension, ) -> Result { if !has_real_auth_context(&state, &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); } ai_management_response(&state, &context, false) } fn ai_management_response( state: &AppState, context: &RequestContext, is_admin: bool, ) -> Result { let workspace_name = default_workspace_name_for_context(state, context); let content = crate::ssr::render_view(leptos::view! { }); let title = "MNote 设置"; let shell = if is_admin { "admin-ai" } else { "user-ai-admin" }; let mut response = Html(format!( r#" {} {} "#, title, crate::ssr::MNOTE_CSS, shell, escape_html(context.auth.actor_id.as_str()), content )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); Ok(response) } pub async fn root_entry( State(state): State, Extension(context): Extension, uri: Uri, Query(query): Query, ) -> Result { if !has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header( header::LOCATION, format!( "/auth?next={}", query_escape( uri.path_and_query() .map(|value| value.as_str()) .unwrap_or("/") ) ), ) .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 = default_workspace_name_for_context(&state, &context); let actor_id = current_actor_id(&state, &context).unwrap_or_else(|| context.auth.actor_id.clone()); let actor_type = current_actor_type(&state, &context); let should_render_local_first_landing = !is_local_folder && query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() && query .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() && requested_page_id.is_none(); let requests_filetree_first = query .tree_view .as_deref() .map(str::trim) .is_some_and(|value| value == "filetree"); let file_tree_scope = query .file_tree_scope .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); 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") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?; // Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned). let page_tree_snapshot = if let Some(scope) = file_tree_scope { load_local_folder_page_tree_scope_snapshot_with_reveal( root_uri, scope, requested_page_id.as_deref(), )? } else { load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())? }; let workspace_id = page_tree_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(), None, None, None); let mut workspace_dataset = page_tree_snapshot.dataset.clone(); attach_sidebar_shortcuts_to_dataset( &state, &context, &workspace_id, &mut workspace_dataset, ); let mut workspace_projection = build_workspace_shell_projection( &workspace_dataset, &workspace_id, requested_or_recent_page_id.as_deref(), "本地文件夹", ); let selected_active_page_id = requested_page_id.map(ToOwned::to_owned); if selected_active_page_id.is_none() { apply_active_page(&mut workspace_projection, None); } let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( &page_tree_snapshot, selected_active_page_id.as_deref(), ); let restore_focus_row_id = query .restore_focus_row_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); // Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan. // treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML. let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some(); let file_tree_html = if needs_sync_file_tree { render_local_file_tree_html_scoped( root_uri, selected_active_page_id.as_deref(), restore_focus_row_id, file_tree_scope, )? } else { render_local_file_tree_pending_shell_html() }; ( 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 if should_render_local_first_landing { let payload = create_default_local_workspace_for_actor(&actor_id, &actor_type) .map_err(|error| error.with_context(&context))?; let root_uri = payload .get("workspace") .and_then(|workspace| workspace.get("rootUri")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context) })? .to_string(); let snapshot = load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?; let workspace_id = snapshot .dataset .get("workspace") .and_then(|workspace| workspace.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("local-folder") .to_string(); let mut workspace_dataset = snapshot.dataset.clone(); attach_sidebar_shortcuts_to_dataset( &state, &context, &workspace_id, &mut workspace_dataset, ); let mut workspace_projection = build_workspace_shell_projection( &workspace_dataset, &workspace_id, requested_page_id.as_deref(), &default_workspace_name, ); let selected_active_page_id = requested_page_id.map(ToOwned::to_owned); if selected_active_page_id.is_none() { apply_active_page(&mut workspace_projection, None); } let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( &snapshot, selected_active_page_id.as_deref(), ); // Local-first landing also prefers shell-first FileTree (hydrate after paint). let file_tree_html = if requests_filetree_first { render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)? } else { render_local_file_tree_pending_shell_html() }; ( workspace_id, workspace_projection, sidebar_tree_html, file_tree_html, selected_active_page_id, Some("local_folder".to_string()), Some(root_uri), ) } 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( Some(&state), 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()), if requests_filetree_first { Some("filetree") } else { None }, file_tree_scope, ); 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 breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html( &workspace_projection, Some(active_page_id.as_str()), ); let show_admin_access_policy = current_actor_is_local_admin(&state, &context); let navigation_notice_html = render_navigation_guard_notice(&query); let navigation_html = if active_page_id.trim().is_empty() { if active_source_kind.as_deref() == Some("local_folder") { active_root_uri .as_deref() .map(|root_uri| { render_local_folder_navigation_page_html( &state, &context, &actor_id, root_uri, file_tree_scope, &navigation_notice_html, ) }) .transpose()? } else { Some(render_workspace_navigation_page_html( &navigation_notice_html, )) } } else { None }; let vault_nav_href = vault_nav_href_for_context( active_source_kind.as_deref(), active_root_uri.as_deref(), ); let render_workspace_entry = || { crate::ssr::render_view(leptos::view! { }) }; let (html_title, content, body_extra) = if active_page_id.trim().is_empty() { let panes_bootstrap_json = serde_json::to_string(&json!({ "schema": "mnote.document_panes_bootstrap.v1", "secondaryRequested": false, "secondaryInvalid": false, "panes": [], })) .unwrap_or_else(|_| "{}".to_string()); let body_extra = format!( r#" {}"#, escape_script_json(&panes_bootstrap_json), render_editor_island_adapter_script(), ); let body_extra = if state.config().enable_page_ai_pi_lab { body_extra + &format!( r#""# ) } else { body_extra }; ("MNOTE".to_string(), render_workspace_entry(), body_extra) } 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(), ); let body_extra = if state.config().enable_page_ai_pi_lab { body_extra + &format!( r#""# ) } else { body_extra }; (title.to_string(), content, body_extra) } Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), } }; let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") { render_editor_runtime_preload_links() } else { String::new() }; let mut response = Html(format!( r#" {} {} {} {} "#, escape_html(&html_title), editor_runtime_preload_links, crate::ssr::MNOTE_CSS, escape_html(context.auth.actor_id.as_str()), escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")), escape_html(active_root_uri.as_deref().unwrap_or("")), content, body_extra )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); Ok(response) } fn render_workspace_navigation_page_html(notice_html: &str) -> String { format!( r#"

主页

{notice_html}

最近访问的文件夹

暂无最近访问的文件夹。

最近访问的页面

暂无最近访问的页面。

"# ) } fn render_local_folder_navigation_page_html( state: &AppState, context: &RequestContext, actor_id: &str, root_uri: &str, relative_path: Option<&str>, notice_html: &str, ) -> Result { let relative_path = relative_path.map(str::trim).unwrap_or_default(); let snapshot = if relative_path.is_empty() { load_local_folder_file_tree_snapshot(root_uri)? } else { load_local_folder_file_tree_children_snapshot(root_uri, relative_path)? }; let items = snapshot .projection .get("items") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let title = if relative_path.is_empty() { "本地文件夹".to_string() } else { relative_path .split('/') .next_back() .filter(|value| !value.trim().is_empty()) .unwrap_or(relative_path) .to_string() }; let mut folders = Vec::new(); let mut pages = Vec::new(); let mut resources = Vec::new(); for item in items { let row_kind = item .get("rowKind") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); let title = item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题"); let resource_meta = item.get("resourceMeta").and_then(Value::as_object); let item_relative_path = item .get("relativePath") .and_then(Value::as_str) .or_else(|| { resource_meta .and_then(|meta| meta.get("workspacePath")) .and_then(|workspace_path| workspace_path.get("relativePath")) .and_then(Value::as_str) }) .map(str::trim) .unwrap_or_default(); let document_id = resource_meta .and_then(|meta| meta.get("documentId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); match row_kind { "folder" | "directory" | "asset-folder" => folders.push(render_navigation_link( title, &navigation_folder_href(root_uri, item_relative_path), "folder", item_relative_path, )), "document" | "markdown" | "index" => { if let Some(document_id) = document_id { pages.push(render_navigation_link( title, &navigation_document_href(root_uri, relative_path, document_id), "page", item_relative_path, )); } } _ => resources.push(render_navigation_link( title, "#", "resource", item_relative_path, )), } } record_navigation_recent_folder(state, actor_id, root_uri, relative_path, &title, None); let recent_folders = crate::routes::navigation_recent::load_navigation_recent_records( state, context, actor_id, Some("folder"), 10, ); let recent_pages = crate::routes::navigation_recent::load_navigation_recent_records( state, context, actor_id, Some("page"), 20, ); Ok(format!( r#"

{title}

{path_label}

{notice_html}

文件夹

{folders}

页面

{pages}

资源

{resources}

最近访问的文件夹

{recent_folders}

最近访问的页面

{recent_pages}
"#, relative_path = escape_html(relative_path), title = escape_html(&title), path_label = escape_html(if relative_path.is_empty() { root_uri } else { relative_path }), notice_html = notice_html, folders = if folders.is_empty() { "

没有子文件夹。

".to_string() } else { folders.join("") }, pages = if pages.is_empty() { "

没有 Markdown 页面。

".to_string() } else { pages.join("") }, resources = if resources.is_empty() { "

没有资源。

".to_string() } else { resources.join("") }, recent_folders = render_recent_navigation_items(&recent_folders), recent_pages = render_recent_navigation_items(&recent_pages), )) } fn render_navigation_guard_notice(query: &RootEntryQuery) -> String { let route_guard = query .route_guard .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let missing_page = query .missing_page .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let Some(message) = route_guard.or(missing_page).map(|_| { if route_guard == Some("local_workspace_access_denied") { "当前账号无权访问原页面,已回到导航页。" } else if route_guard == Some("local_folder_root_required") { "原页面缺少本地文件夹信息,已回到导航页。" } else { "原页面已不存在,已回到当前文件夹导航页。" } }) else { return String::new(); }; let target = missing_page.or(route_guard).unwrap_or_default(); format!( r#"
{}{}
"#, escape_html(target), escape_html(message), escape_html(target), ) } fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String { if records.is_empty() { return "

暂无最近访问。

".to_string(); } records .iter() .map(|record| { let href = if record.kind == "folder" { navigation_folder_href( &record.root_uri, record.relative_path.as_deref().unwrap_or(""), ) } else if let Some(document_id) = record.document_id.as_deref() { navigation_document_href( &record.root_uri, parent_scope_from_relative_path(record.relative_path.as_deref().unwrap_or("")), document_id, ) } else { "#".to_string() }; render_navigation_link( &record.title, &href, &record.kind, record.relative_path.as_deref().unwrap_or(""), ) }) .collect::>() .join("") } fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String { let is_page = kind == "page"; let class_name = if is_page { "mnote-navigation-page__item mnote-page-block-link" } else { "mnote-navigation-page__item" }; let page_attributes = if is_page { r#" data-area="page-block" data-block-type="page""# } else { "" }; format!( r#"{}"#, escape_html(kind), escape_html(relative_path), escape_html(href), escape_html(title), ) } fn record_navigation_recent_folder( state: &AppState, actor_id: &str, root_uri: &str, relative_path: &str, title: &str, workspace_id: Option<&str>, ) { if actor_id.trim().is_empty() || actor_id.trim() == "anonymous" || root_uri.trim().is_empty() { return; } let _ = state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: actor_id.trim().to_string(), workspace_id: workspace_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), kind: "folder".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.trim().to_string(), relative_path: Some(relative_path.trim().trim_matches('/').to_string()) .filter(|value| !value.is_empty()), document_id: None, title: title.trim().to_string(), metadata_json: "{}".to_string(), }); } fn parent_scope_from_relative_path(relative_path: &str) -> &str { relative_path .trim() .rsplit_once('/') .map(|(parent, _)| parent) .unwrap_or("") } fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String { let mut href = format!( "/?sourceKind=local_folder&rootUri={}&treeView=filetree", query_escape(root_uri) ); if !relative_path.trim().is_empty() { href.push_str("&fileTreeScope="); href.push_str(&query_escape(relative_path)); } href } fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String { let mut href = format!( "/documents/{}?sourceKind=local_folder&rootUri={}", query_escape(document_id), query_escape(root_uri) ); if !file_tree_scope.trim().is_empty() { href.push_str("&fileTreeScope="); href.push_str(&query_escape(file_tree_scope)); } href } fn query_escape(value: &str) -> String { value .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { vec![byte as char] } _ => format!("%{byte:02X}").chars().collect::>(), }) .collect() } /// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context. pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String { let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty()); let source_kind = source_kind .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| root_uri.map(|_| "local_folder")); match (source_kind, root_uri) { (Some(source_kind), Some(root_uri)) => format!( "/vault?sourceKind={}&rootUri={}", query_escape(source_kind), query_escape(root_uri) ), (Some(source_kind), None) => { format!("/vault?sourceKind={}", query_escape(source_kind)) } (None, Some(root_uri)) => format!( "/vault?sourceKind=local_folder&rootUri={}", query_escape(root_uri) ), (None, None) => "/vault".to_string(), } } /// GET /files → permanent product surface is /vault (left-rail password vault). pub async fn files_redirect_to_vault( Query(query): Query, ) -> Result { let mut location = String::from("/vault"); let mut parts: Vec = Vec::new(); if let Some(root_uri) = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { parts.push(format!("rootUri={}", query_escape(root_uri))); } if let Some(source_kind) = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { parts.push(format!("sourceKind={}", query_escape(source_kind))); } else if !parts.is_empty() { parts.push("sourceKind=local_folder".to_string()); } if let Some(workspace_id) = query .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { parts.push(format!("workspaceId={}", query_escape(workspace_id))); } if !parts.is_empty() { location.push('?'); location.push_str(&parts.join("&")); } let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, location) .body(Body::empty()) .map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); Ok(response) } /// GET /vault — dedicated password vault workbench (local_folder only for P0). pub async fn vault_entry( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { if !crate::routes::vault::vault_feature_enabled() { return Err(crate::routes::vault::vault_disabled_error().with_context(&context)); } if !has_real_auth_context(&state, &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 source_kind = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); // Product path: local_folder workspace with rootUri. if source_kind == Some("local_folder") || root_uri.is_some() { let root_uri = root_uri.ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let workspace_root = ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; // Best-effort ensure vault dirs so first paint has a writable layout. let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root); let workspace_id = crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default(); let file_tree_html = render_local_file_tree_pending_shell_html(); let mut workspace_dataset = json!({ "active_workspace_id": workspace_id, "workspaces": [{ "id": workspace_id, "name": "我的空间" }], "documents": [], }); attach_sidebar_shortcuts_to_dataset( &state, &context, &workspace_id, &mut workspace_dataset, ); let workspace_projection = build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间"); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, None, ); let bootstrap = crate::routes::vault::bootstrap_list_json( &workspace_root, crate::routes::vault_store::VaultItemStatus::Active, ) .unwrap_or_else(|_| { json!({ "schema": "mnote.vault.list.v1", "status": "active", "revision": 0, "updatedAt": "", "items": [], }) }); let vault_workbench_html = render_vault_workbench_html(&workspace_id, root_uri, &bootstrap); let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri)); let content = crate::ssr::render_view(leptos::view! {
}); let mut response = Html(format!( r#" 密码箱 {} "#, crate::ssr::MNOTE_CSS, escape_html(root_uri), content, )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); context.apply_response_headers(response.headers_mut()); return Ok(response); } // Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap). // Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503. let default_workspace_name = default_workspace_name_for_context(&state, &context); let workspace_id = normalize_optional_id(query.workspace_id.as_deref()) .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref())) .map(ToOwned::to_owned) .unwrap_or_else(|| "local-folder".to_string()); let mut workspace_dataset = json!({ "active_workspace_id": workspace_id, "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], "documents": [], }); attach_sidebar_shortcuts_to_dataset( &state, &context, &workspace_id, &mut workspace_dataset, ); let workspace_projection = build_workspace_shell_projection( &workspace_dataset, &workspace_id, None, &default_workspace_name, ); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(""), Some(""), None, None, ); let empty_bootstrap = json!({ "schema": "mnote.vault.list.v1", "status": "active", "revision": 0, "updatedAt": "", "items": [], "needsLocalFolder": true, }); let vault_workbench_html = render_vault_workbench_html(&workspace_id, "", &empty_bootstrap); 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_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String { let bootstrap_raw = bootstrap.to_string(); let bootstrap_json = escape_script_json(&bootstrap_raw); let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js"); format!( r#"

密码箱

"#, workspace_id = escape_html(workspace_id), root_uri_esc = escape_html(root_uri), bootstrap_json = bootstrap_json, runtime_src = runtime_src, ) } pub async fn trash_entry( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { if !has_real_auth_context(&state, &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 source_kind = query .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if source_kind == Some("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") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let workspace_id = crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default(); let file_tree_html = render_local_file_tree_pending_shell_html(); let mut workspace_dataset = json!({ "active_workspace_id": workspace_id, "workspaces": [{ "id": workspace_id, "name": "我的空间" }], "documents": [], }); attach_sidebar_shortcuts_to_dataset( &state, &context, &workspace_id, &mut workspace_dataset, ); let workspace_projection = build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间"); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, None, ); let trash_workbench_html = render_local_trash_workbench_html( &workspace_id, root_uri, &load_local_trash_entries(root_uri)?, ); let content = crate::ssr::render_view(leptos::view! {
}); let mut response = Html(format!( r#" 本地文件夹垃圾箱 {} "#, crate::ssr::MNOTE_CSS, escape_html(root_uri), content, )) .into_response(); stamp_gateway_headers(response.headers_mut(), false); context.apply_response_headers(response.headers_mut()); return Ok(response); } let default_workspace_name = default_workspace_name_for_context(&state, &context); let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let workspace_projection = load_workspace_shell_projection( Some(&state), 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()), None, None, ); 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 render_local_trash_workbench_html( workspace_id: &str, root_uri: &str, entries: &BTreeMap, ) -> String { let mut document_rows = Vec::new(); let mut resource_rows = Vec::new(); for (entry_id, entry) in entries { let deleted_at = entry.deleted_at_ms.to_string(); let original = escape_html(&entry.original_relative_path); let trash_path = escape_html(&entry.trash_relative_path); let kind = escape_html(&entry.resource_kind); let filetree_row_id = if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" { format!("doc:{}", entry.document_id) } else { format!("local:asset:{}", entry.original_relative_path) }; let row = format!( r#"
{kind} {original} 回收站:{trash_path} · {deleted_at}
"#, entry_id = escape_html(entry_id), kind = kind, filetree_row_id = escape_html(&filetree_row_id), original = original, trash_path = trash_path, deleted_at = escape_html(&deleted_at), command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()), ); if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" { document_rows.push(row); } else { resource_rows.push(row); } } let document_body = if document_rows.is_empty() { r#"
暂无已删除页面
"#.to_string() } else { document_rows.join("") }; let resource_body = if resource_rows.is_empty() { r#"
暂无已删除资源
"#.to_string() } else { resource_rows.join("") }; format!( r#"

本地文件夹垃圾箱

本地删除项保存在当前目录的 .mnote/trash.mnote/trash-index.json 中。

页面 {document_count}

{document_body}

资源 {resource_count}

{resource_body}
"#, workspace_id = escape_html(workspace_id), root_uri = escape_html(root_uri), document_count = document_rows.len(), resource_count = resource_rows.len(), document_empty_disabled = if document_rows.is_empty() { " disabled" } else { "" }, resource_empty_disabled = if resource_rows.is_empty() { " disabled" } else { "" }, document_body = document_body, resource_body = resource_body, ) } fn local_trash_command_id( entry_id: &str, entry: &crate::routes::local_folder_source::LocalTrashEntry, ) -> String { if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" { return entry.document_id.clone(); } entry_id.to_string() } 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("") } #[allow(dead_code)] 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_retired_mutation_by_name( state.config(), context, "workspaces:ensureDefaultWorkspace", json!({ "fallbackName": current_actor_display_name(state, context) .unwrap_or_else(|| state.config().dev_user_name.clone()), "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") }) } pub(crate) fn default_workspace_name_for_context( state: &AppState, context: &RequestContext, ) -> String { let display_name = current_actor_display_name(state, context) .unwrap_or_else(|| state.config().dev_user_name.clone()); format!("{} 的空间", display_name.trim()) } pub(crate) fn current_actor_display_name( state: &AppState, context: &RequestContext, ) -> Option { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { return Some(resolved.user.display_name); } } extract_encoded_cookie_value(context, COOKIE_MNOTE_ACTOR_NAME).or_else(|| { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { None } else { Some(actor_id.to_string()) } }) } pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { return Some(resolved.user.id); } } let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { None } else { Some(actor_id.to_string()) } } pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if matches!( state.control_plane().get_session_by_token_hash(&token_hash), Ok(Some(_)) ) { return "user".to_string(); } } context.auth.actor_type.trim().to_string() } pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role); } } is_local_access_policy_admin_context(context) } fn normalize_optional_id(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return true; } if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if matches!( state.control_plane().get_session_by_token_hash(&token_hash), Ok(Some(_)) ) { 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 handle_control_plane_auth_action( state: &AppState, context: &RequestContext, payload: &serde_json::Value, ) -> Result { let params = payload .pointer("/args/params") .and_then(Value::as_object) .ok_or_else(|| { WebError::bad_request_code("auth_bad_request", "登录请求缺少 args.params") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; let flow = params .get("flow") .and_then(Value::as_str) .unwrap_or("signIn"); let password = params .get("password") .and_then(Value::as_str) .map(str::to_string) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("auth_password_required", "请填写密码") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; let session_token = new_session_token(); let token_hash = session_token_hash(&session_token); let password_for_provider_sync = password.clone(); let resolved = if flow == "signUp" { let email = params .get("email") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "auth_signup_email_required", "注册账号时请填写邮箱;登录时可以使用用户名。", ) .with_context(context) .with_header("x-error-phase", "auth_signup_payload") })?; let username = params .get("name") .or_else(|| params.get("username")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or_else(|| email.split('@').next().unwrap_or("user")); let user = state .control_plane() .upsert_user(UpsertUserInput { id: Some(username.to_string()), email: Some(email.to_string()), username: username.to_string(), display_name: username.to_string(), role: None, password_hash: None, }) .map_err(|error| control_plane_auth_error(context, error))?; state .control_plane() .create_password_identity(CreatePasswordIdentityInput { user_id: user.id.clone(), email: user.email.clone(), username: user.username.clone(), password: password.clone(), }) .map_err(|error| control_plane_auth_error(context, error))?; state .control_plane() .ensure_default_workspace(&user.id) .map_err(|error| control_plane_auth_error(context, error))?; state .control_plane() .authenticate_password(AuthenticatePasswordInput { account: email.to_string(), password, session_id: None, token_hash, user_agent: None, ip_hash: None, expires_at: None, }) .map_err(|error| control_plane_auth_error(context, error))? } else { let account = params .get("account") .or_else(|| params.get("email")) .or_else(|| params.get("name")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("auth_account_required", "请填写邮箱或用户名") .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; state .control_plane() .authenticate_password(AuthenticatePasswordInput { account: account.to_string(), password, session_id: None, token_hash, user_agent: None, ip_hash: None, expires_at: None, }) .map_err(|error| control_plane_auth_error(context, error))? }; let audit_action = if flow == "signUp" { "control.auth.signed_up" } else { "control.auth.signed_in" }; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(resolved.user.id.clone()), action: audit_action.to_string(), target_kind: "auth_session".to_string(), target_id: Some(resolved.session.id.clone()), metadata_json: serde_json::to_string(&json!({ "email": resolved.user.email, "name": resolved.user.display_name, "flow": flow, })) .unwrap_or_else(|_| "{}".to_string()), }); let provider_sync_results = if matches!(flow, "signUp" | "signIn") { let results = sync_provider_identities(&resolved.user, &password_for_provider_sync).await; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(resolved.user.id.clone()), action: "control.auth.provider_identities_synced".to_string(), target_kind: "user".to_string(), target_id: Some(resolved.user.id.clone()), metadata_json: serde_json::to_string(&json!({ "providers": results.iter().map(|item| json!({ "provider": item.provider, "ok": item.ok, "message": item.message, "providerUserId": item.provider_user_id, })).collect::>(), })) .unwrap_or_else(|_| "{}".to_string()), }); Some(results) } else { None }; let mut response = build_control_plane_auth_response( context, &session_token, &resolved.user.id, resolved.user.email.as_deref().unwrap_or_default(), &resolved.user.display_name, &effective_control_plane_auth_actor_type(&resolved.user.id, &resolved.user.role), ); if let Some(results) = provider_sync_results { let all_ok = results.iter().all(|item| item.ok); let header_value = if all_ok { "ok" } else { "partial" }; if let Ok(value) = HeaderValue::from_str(header_value) { response .headers_mut() .insert("x-mnote-provider-identity-sync", value); } } Ok(response) } fn control_plane_auth_error( context: &RequestContext, error: control_plane::ControlPlaneError, ) -> WebError { match error { control_plane::ControlPlaneError::Unauthorized(message) => { WebError::bad_request_code("auth_invalid_credentials", message) } control_plane::ControlPlaneError::Conflict(message) => { WebError::bad_request_code("auth_conflict", message) } control_plane::ControlPlaneError::InvalidInput(message) => { WebError::bad_request_code("auth_bad_request", message) } other => WebError::internal(format!("SQLite Auth 失败: {other}")), } .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") } fn build_control_plane_auth_response( context: &RequestContext, session_token: &str, user_id: &str, email: &str, name: &str, actor_type: &str, ) -> Response { let mut response = axum::Json(json!({ "ok": true, "userId": user_id, "email": email, "name": name, "authMode": "controlPlaneSession", "actorType": actor_type })) .into_response(); set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token); set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id); set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type); if !email.trim().is_empty() { set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email); } if !name.trim().is_empty() { set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, name); } expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_JWT); expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_REFRESH_TOKEN); expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION); stamp_gateway_headers(response.headers_mut(), false); apply_trace_response_headers(context, response.headers_mut()); response } fn effective_control_plane_auth_actor_type(user_id: &str, stored_role: &str) -> String { let role = stored_role.trim(); let fallback_role = if role.is_empty() { "user" } else { role }; if crate::routes::local_folder_source::is_local_access_policy_admin_actor( user_id, fallback_role, ) { "admin".to_string() } else { fallback_role.to_string() } } fn build_control_plane_sign_out_response(state: &AppState, context: &RequestContext) -> Response { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { let _ = state.control_plane().revoke_session(&resolved.session.id); let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(resolved.user.id.clone()), action: "control.auth.signed_out".to_string(), target_kind: "auth_session".to_string(), target_id: Some(resolved.session.id.clone()), metadata_json: serde_json::to_string(&json!({ "email": resolved.user.email, "name": resolved.user.display_name, })) .unwrap_or_else(|_| "{}".to_string()), }); } } build_sign_out_response(context) } fn new_session_token() -> String { uuid::Uuid::new_v4().simple().to_string() } fn build_sign_out_response(context: &RequestContext) -> Response { let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response(); clear_auth_cookies(response.headers_mut()); stamp_gateway_headers(response.headers_mut(), false); apply_trace_response_headers(context, response.headers_mut()); response } fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) { insert_response_header(headers, "x-request-id", &context.trace.request_id); insert_response_header(headers, "x-trace-id", &context.trace.trace_id); if let Some(workspace_id) = &context.workspace.workspace_id { insert_response_header(headers, "x-mnote-workspace-id", workspace_id); } } fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) { let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else { return; }; let Ok(value) = HeaderValue::from_str(value) else { return; }; headers.insert(name, value); } fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) { let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax"); if let Ok(value) = HeaderValue::from_str(&cookie) { headers.append(header::SET_COOKIE, value); } } fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) { let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes()); set_literal_cookie(headers, name, &encoded); } fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) { expire_cookie(headers, COOKIE_MNOTE_SESSION); expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT); expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN); expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION); expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID); expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE); expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL); expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME); } 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 extract_encoded_cookie_value(context: &RequestContext, name: &str) -> Option { let encoded = extract_cookie_value(context, name)?; let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?; String::from_utf8(decoded) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } 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 control_plane::{UpsertNavigationRecentInput, UpsertUserInput}; use tokio::net::TcpListener; use tower::util::ServiceExt; fn temp_root(name: &str) -> std::path::PathBuf { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let path = std::env::temp_dir().join(format!("{name}-{stamp}")); std::fs::create_dir_all(&path).expect("temp root"); path } 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, enable_page_ai_pi_lab: false, 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_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#"文档"#)); } #[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":"已删表格.table","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("已删表格.table")); assert!(html.contains("new EventSource")); assert!(html.contains("/api/tree/events")); assert!(!html.contains("pollMs")); assert!(html.contains("refreshTrashWorkbenchFromServer")); assert!(!html.contains("window.location.reload")); } #[tokio::test] async fn trash_entry_renders_local_folder_trash_workbench() { let root = temp_root("mnote-local-folder-trash-entry"); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &format!("file://{}", root.display()), ) .expect("init local workspace"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir"); std::fs::write( root.join(".mnote").join("trash-index.json"), serde_json::json!({ "entries": { "local-md:Deleted~2FDeleted.md": { "documentId": "local-md:Deleted~2FDeleted.md", "resourceKind": "markdown_bundle", "resourceScope": "local_folder", "originalRelativePath": "Deleted", "trashRelativePath": ".mnote/trash/Deleted", "deletedAtMs": 1770000000000u64 } } }) .to_string(), ) .expect("write trash index"); let root_uri = format!("file://{}", root.display()); let uri = format!( "/trash?sourceKind=local_folder&rootUri={}", root_uri.replace(':', "%3A").replace('/', "%2F") ); let response = app() .oneshot( Request::builder() .uri(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-trash-source-kind="local_folder""#)); assert!(html.contains("Deleted")); assert!(html.contains(r#"data-trash-action="local-restore""#)); assert!(html.contains(r#"data-trash-action="local-purge""#)); assert!(html.contains(r#"data-filetree-row-id="doc:local-md:Deleted~2FDeleted.md""#)); assert!(html.contains("mnote.pendingLocalFolderRestoreFiletreeRowId")); assert!(html.contains(r#"data-trash-action="local-empty-documents""#)); assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#)); assert!(html.contains("rows.reduce(function(chain, row)")); assert!(!html.contains("Promise.all(rows.map(function(row)")); let _ = std::fs::remove_dir_all(&root); } #[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_unauthenticated_redirects_to_auth_with_next() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/?sourceKind=local_folder&rootUri=file:///tmp/mnote&treeView=filetree") .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?next=%2F%3FsourceKind%3Dlocal_folder%26rootUri%3Dfile%3A%2F%2F%2Ftmp%2Fmnote%26treeView%3Dfiletree" ) ); } #[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("/?workspaceId=ws_demo") .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_first_landing_without_convex() { let _guard = crate::test_support::agent_env_lock() .lock() .expect("env lock"); let base = temp_root("mnote-root-local-first-landing"); std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base); let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) .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"); std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR"); 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("初始化的新页面")); assert!(html.contains(r#"data-mnote-source-kind="local_folder""#)); assert!(html.contains(r#"data-testid="mnote-document-workspace""#)); assert!(html.contains(r#"data-testid="mnote-navigation-page""#)); assert!(html.contains(r#"data-mnote-resource-tab-host="true""#)); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__")); assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#)); assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#)); assert!(!html.contains("workspaces:ensureDefaultWorkspace")); let _ = std::fs::remove_dir_all(&base); } #[tokio::test] async fn root_entry_renders_access_policy_dialog_templates_for_all_actors() { let admin_response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) .oneshot( Request::builder() .uri("/") .header("x-mnote-actor-id", "admin_real") .header("x-mnote-actor-type", "admin") .body(Body::empty()) .expect("request"), ) .await .expect("admin response"); assert_eq!(admin_response.status(), StatusCode::OK); let admin_body = to_bytes(admin_response.into_body(), usize::MAX) .await .expect("body"); let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8"); assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); assert!(!admin_html.contains(r#"data-testid="mnote-account-access-policy""#)); assert!(admin_html.contains(r#"data-mnote-action="open-account-menu""#)); assert!(admin_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); let user_response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) .oneshot( Request::builder() .uri("/") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("user response"); assert_eq!(user_response.status(), StatusCode::OK); let user_body = to_bytes(user_response.into_body(), usize::MAX) .await .expect("body"); let user_html = String::from_utf8(user_body.to_vec()).expect("utf8"); assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); assert!(!user_html.contains(r#"data-testid="mnote-account-access-policy""#)); assert!(user_html.contains(r#"data-mnote-action="open-account-menu""#)); assert!(user_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); } #[tokio::test] async fn root_entry_uses_control_plane_session_display_name_for_workspace_label() { use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput}; let app_state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); app_state .control_plane() .upsert_user(UpsertUserInput { id: Some("shujuan".into()), email: Some("shujuan@example.com".into()), username: "shujuan".into(), display_name: "shujuan".into(), role: None, password_hash: None, }) .expect("upsert control-plane user"); app_state .control_plane() .create_session(CreateSessionInput { id: None, user_id: "shujuan".into(), token_hash: session_token_hash("session-shujuan"), user_agent: None, ip_hash: None, expires_at: None, }) .expect("create control-plane session"); let response = build_app(app_state) .oneshot( Request::builder() .uri("/") .header("cookie", "mnote_session=session-shujuan") .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("shujuan 的空间")); assert!(!html.contains("开发用户 的空间")); } #[tokio::test] async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() { use control_plane::{session_token_hash, CreateSessionInput}; let app_state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); app_state .control_plane() .upsert_user(UpsertUserInput { id: Some("normal-user-session".into()), email: Some("normal@example.com".into()), username: "normal-user-session".into(), display_name: "普通用户".into(), role: Some("user".into()), password_hash: None, }) .expect("upsert control-plane user"); app_state .control_plane() .create_session(CreateSessionInput { id: None, user_id: "normal-user-session".into(), token_hash: session_token_hash("normal-session-token"), user_agent: None, ip_hash: None, expires_at: None, }) .expect("create control-plane session"); let app = build_app(app_state); let stale_admin_cookie = "mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin"; let settings_response = app .clone() .oneshot( Request::builder() .uri("/settings") .header("cookie", stale_admin_cookie) .body(Body::empty()) .expect("request"), ) .await .expect("settings response"); assert_eq!(settings_response.status(), StatusCode::OK); let body = to_bytes(settings_response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("data-ai-admin-role=\"user\"")); assert!(!html.contains("data-ai-admin-role=\"admin\"")); assert!(!html.contains("href=\"#ai-admin-users\"")); let admin_page_response = app .clone() .oneshot( Request::builder() .uri("/admin/ai") .header("cookie", stale_admin_cookie) .body(Body::empty()) .expect("request"), ) .await .expect("admin page response"); assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN); let admin_api_response = app .oneshot( Request::builder() .uri("/api/ai-admin/users") .header("cookie", stale_admin_cookie) .body(Body::empty()) .expect("request"), ) .await .expect("admin api response"); assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn admin_access_policy_entry_requires_admin_actor() { let user_response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/admin/access-policy") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("user response"); assert_eq!(user_response.status(), StatusCode::FORBIDDEN); let admin_response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/admin/access-policy") .header("x-mnote-actor-id", "admin_real") .header("x-mnote-actor-type", "admin") .body(Body::empty()) .expect("request"), ) .await .expect("admin response"); assert_eq!(admin_response.status(), StatusCode::SEE_OTHER); assert_eq!( admin_response.headers().get(header::LOCATION).unwrap(), "/admin/ai#ai-admin-access" ); } #[tokio::test] async fn user_access_policy_entry_renders_user_share_management() { let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .uri("/user/access-policy") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("user access policy response"); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( response.headers().get(header::LOCATION).unwrap(), "/user/ai#ai-admin-access" ); } #[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()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); 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("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md") .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("README.md")); assert!(html.contains(r#"id="sidebar-tree-root""#)); assert!(html.contains(r#"data-shell-mode="page""#)); assert!(html.contains(r#"data-node-id="local-md:README.md""#)); assert!(html.contains(r#"data-row-id="local:folder:docs""#)); assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#)); 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_local_folder_without_page_renders_navigation_page_without_editor_bootstrap() { let root = temp_root("mnote-root-local-folder-navigation-page"); 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"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); 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("cookie", "mnote_recent_page_id=local-md:README.md") .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-testid="mnote-navigation-page""#)); assert!(html.contains(r#"data-navigation-scope-kind="folder""#)); assert!(html.contains("README.md")); assert!(html.contains("docs")); assert!(html.contains("最近访问的文件夹")); assert!(html.contains("最近访问的页面")); assert!(!html.contains(r#"data-root-active-page-id="local-md:README.md""#)); assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__")); assert!(!html.contains("__MNOTE_EDITOR_BOOTSTRAP__")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(!html.contains("mnote-leptos-tiptap-spike-island")); } #[tokio::test] async fn root_entry_local_folder_scope_renders_navigation_without_root_sibling_document() { let root = temp_root("mnote-root-local-folder-scope-navigation-page"); std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) .expect("create scoped tree"); std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page"); std::fs::write(root.join("design").join("Plan.md"), "# Plan\n").expect("write plan"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); 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&fileTreeScope=design&missingPage=local-md%3Adesign%7E2FMissing.md" )) .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-testid="mnote-navigation-page""#)); assert!(html.contains(r#"data-navigation-relative-path="design""#)); assert!(html.contains("Plan.md")); assert!(html.contains("05-editor-mainline")); assert!(html.contains(r#"data-testid="mnote-navigation-guard-notice""#)); assert!(html.contains("原页面已不存在")); assert!(html.contains("local-md:design~2FMissing.md")); assert!(!html.contains("Home.md")); assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__")); } #[tokio::test] async fn root_entry_local_folder_navigation_renders_user_recent_links() { let root = temp_root("mnote-root-local-folder-navigation-recent"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan"); std::fs::create_dir_all(root.join("archive")).expect("create archive"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); let state = AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_real".to_string()), email: Some("user_real@example.com".to_string()), username: "user_real".to_string(), display_name: "user_real".to_string(), role: None, password_hash: None, }) .expect("upsert user"); state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: "user_real".to_string(), workspace_id: None, kind: "folder".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.clone(), relative_path: Some("archive".to_string()), document_id: None, title: "archive".to_string(), metadata_json: "{}".to_string(), }) .expect("folder recent"); state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: "user_real".to_string(), workspace_id: None, kind: "page".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.clone(), relative_path: Some("docs/Plan.md".to_string()), document_id: Some("local-md:docs~2FPlan.md".to_string()), title: "Plan".to_string(), metadata_json: "{}".to_string(), }) .expect("page recent"); let response = build_app(state) .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-testid="mnote-navigation-recent-folders""#)); assert!(html.contains(r#"data-local-relative-path="archive""#)); assert!(html.contains("fileTreeScope=archive")); assert!(html.contains(r#"data-testid="mnote-navigation-recent-pages""#)); assert!(html.contains(r#"href="/documents/local-md%3Adocs%7E2FPlan.md"#)); assert!(html.contains("fileTreeScope=docs")); } #[tokio::test] async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() { let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once"); 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"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); crate::routes::local_folder_source::reset_local_page_tree_snapshot_test_loads(); 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); assert_eq!( crate::routes::local_folder_source::local_page_tree_snapshot_test_loads_for_root( &root_uri ), 1, "root entry should reuse the already loaded local page tree snapshot instead of scanning it twice", ); } #[tokio::test] async fn root_entry_local_folder_filetree_scope_renders_scoped_page_tree() { let root = temp_root("mnote-root-local-folder-scoped-page-tree"); std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) .expect("create scoped design tree"); std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page"); std::fs::write( root.join("design") .join("05-editor-mainline") .join("Target.md"), "# Target\n", ) .expect("write scoped page"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); // pageId 触发 reveal:lazy PageTree 在 scope 内展开 active 文档父链。 let page_id = "local-md:design~2F05-editor-mainline~2FTarget.md"; 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&fileTreeScope=design&pageId={page_id}" )) .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-node-id="local-dir:design~2F05-editor-mainline""#)); assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)); assert!( !html.contains(r#"data-node-id="local-md:Home.md""#), "星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树" ); } #[tokio::test] async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() { let root = temp_root("mnote-root-local-folder-no-active-page"); std::fs::create_dir_all(root.join("attachments")).expect("create attachments"); std::fs::write(root.join("attachments").join("report-a.pdf"), b"%PDF-1.4\n") .expect("write pdf"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); 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("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md") .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(r#"data-row-id="local:folder:attachments""#)); assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#)); assert!(html.contains(r#"data-testid="mnote-document-workspace""#)); assert!(html.contains(r#"data-testid="mnote-navigation-page""#)); assert!(html.contains(r#"data-mnote-resource-tab-host="true""#)); assert!(!html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#)); assert!(html.contains(r#"data-pane-role="primary""#)); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains( r#""# )); assert!(!html.contains( r#""# )); assert!(!html.contains( r#""# )); assert!( include_str!("../../browser/document-resource-tab-runtime.js") .contains("openResourceInActiveTab") ); assert!(!html.contains(r#"