Files
mnote/rust/crates/mnote-web/src/routes/gateway.rs
T
Agent Board 262e66b02e feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
2026-07-25 14:25:37 +08:00

4652 lines
182 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
legacy_next_compat_enabled: bool,
notes: Vec<&'static str>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
file_tree_scope: Option<String>,
tree_view: Option<String>,
restore_focus_row_id: Option<String>,
route_guard: Option<String>,
missing_page: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
let mut response = axum::Json(GatewayManifest {
ok: true,
owner: "mnote-web",
public_entry: state.config().public_bind_addr.clone(),
legacy_next_base_url: state.config().legacy_next_base_url.clone(),
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
notes: vec![
"3000 公开入口默认由 mnote-web gateway 拥有。",
"Next App Router 只作为 legacy compat upstream 使用。",
],
})
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
response
}
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<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(context): Extension<RequestContext>,
_request: Request<Body>,
) -> Result<Response, WebError> {
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#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE Auth</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn admin_access_policy_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
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<Response, WebError> {
let workspace_name = default_workspace_name_for_context(state, context);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::ai_admin::AiManagementPage
workspace_name={workspace_name}
is_admin={is_admin}
/>
});
let title = "MNote 设置";
let shell = if is_admin {
"admin-ai"
} else {
"user-ai-admin"
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
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<AppState>,
Extension(context): Extension<RequestContext>,
uri: Uri,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
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! {
<crate::ssr::pages::home::HomePage
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
breadcrumb_html={breadcrumb_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
vault_nav_href={vault_nav_href.clone()}
/>
})
};
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#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}"#,
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#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} 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! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
document_id={active_page_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={true}
vault_nav_href={vault_nav_href.clone()}
/>
});
let body_extra = format!(
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}
{}"#,
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#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} 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#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
{}
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
{}
{}
</body>
</html>"#,
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#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="workspace" data-navigation-relative-path="">
<header class="mnote-navigation-page__header">
<h1>主页</h1>
</header>
{notice_html}
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
<h2>最近访问的文件夹</h2>
<p>暂无最近访问的文件夹。</p>
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
<h2>最近访问的页面</h2>
<p>暂无最近访问的页面。</p>
</section>
</section>"#
)
}
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<String, WebError> {
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#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="folder" data-navigation-relative-path="{relative_path}">
<header class="mnote-navigation-page__header">
<h1>{title}</h1>
<p>{path_label}</p>
</header>
{notice_html}
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-folders">
<h2>文件夹</h2>
{folders}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-pages">
<h2>页面</h2>
{pages}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-resources">
<h2>资源</h2>
{resources}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
<h2>最近访问的文件夹</h2>
{recent_folders}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
<h2>最近访问的页面</h2>
{recent_pages}
</section>
</section>"#,
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() {
"<p>没有子文件夹。</p>".to_string()
} else {
folders.join("")
},
pages = if pages.is_empty() {
"<p>没有 Markdown 页面。</p>".to_string()
} else {
pages.join("")
},
resources = if resources.is_empty() {
"<p>没有资源。</p>".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#"<div class="mnote-navigation-page__notice" data-testid="mnote-navigation-guard-notice" data-mnote-route-guard-target="{}">{}<span>{}</span></div>"#,
escape_html(target),
escape_html(message),
escape_html(target),
)
}
fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String {
if records.is_empty() {
return "<p>暂无最近访问。</p>".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::<Vec<_>>()
.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#"<a class="{class_name}" data-navigation-item-kind="{}" data-local-relative-path="{}"{page_attributes} href="{}">{}</a>"#,
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::<Vec<_>>(),
})
.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<RootEntryQuery>,
) -> Result<Response, WebError> {
let mut location = String::from("/vault");
let mut parts: Vec<String> = 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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
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! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
vault_nav_href={vault_nav_href.clone()}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}" data-mnote-page="vault">
{}
</body>
</html>"#,
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! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={String::new()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-page="vault" data-mnote-vault-needs-folder="1">
{}
</body>
</html>"#,
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#"<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="{workspace_id}" data-root-uri="{root_uri_esc}" data-status="active">
<header class="mnote-vault-header">
<div class="mnote-vault-header-main">
<h1>密码箱</h1>
<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>
</div>
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create">新建</button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
<span class="mnote-vault-sr-only">插入密文</span>
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
<option value="">插入密文…</option>
</select>
</label>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false">已删除</button>
</div>
</div>
</header>
<div class="mnote-vault-body">
<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>
<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>
</div>
<script type="application/json" id="__MNOTE_VAULT_BOOTSTRAP__">{bootstrap_json}</script>
<script src="{runtime_src}" defer></script>
</section>"#,
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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
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! {
<crate::ssr::pages::layout::PageLayout
current_nav="trash"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"垃圾箱".to_string()}
enable_tree_live={false}
>
<div inner_html={trash_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>本地文件夹垃圾箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
{}
</body>
</html>"#,
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! {
<crate::ssr::pages::layout::PageLayout
current_nav="trash"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"垃圾箱".to_string()}
>
<div inner_html={trash_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>垃圾箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
{}
</body>
</html>"#,
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#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
} else {
resource_rows
};
format!(
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-workspace-id="{workspace_id}" data-trash-fetch-path="/trash">
<header class="mnote-trash-header">
<h1>垃圾箱</h1>
<p>默认删除的页面会先进入这里;彻底删除会永久移除。</p>
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
</header>
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
<div class="mnote-trash-section-title">
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
<button type="button" data-trash-action="empty-documents"{empty_disabled}>清空页面垃圾箱</button>
</div>
{document_rows}
</section>
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
<div class="mnote-trash-section-title">
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
<button type="button" data-trash-action="empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
</div>
{resource_body}
<p class="mnote-trash-note">资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。</p>
</section>
</section>
<script>
(function() {{
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
if (!root) return;
var workspaceId = root.getAttribute('data-workspace-id') || '';
function setStatus(message, failed) {{
var status = root.querySelector('[data-trash-status]');
if (!status) return;
status.textContent = message || '';
status.setAttribute('data-type', failed ? 'error' : 'success');
}}
function refreshTrashWorkbenchFromServer(reason) {{
if (!workspaceId) return Promise.resolve(false);
var url = new URL(root.getAttribute('data-trash-fetch-path') || '/trash', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
url.searchParams.set('liveRefreshReason', reason || 'tree-event');
return fetch(url.toString(), {{
method: 'GET',
headers: {{ 'x-mnote-trash-live-refresh': '1' }}
}}).then(function(response) {{
return response.text().then(function(html) {{
if (!response.ok) throw new Error('trash_live_refresh_failed_' + response.status);
var parsed = new DOMParser().parseFromString(html, 'text/html');
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
root.innerHTML = nextRoot.innerHTML;
root.setAttribute('data-live-refresh-reason', reason || 'tree-event');
root.setAttribute('data-live-refresh-at', String(Date.now()));
return true;
}});
}}).catch(function(error) {{
setStatus(error && error.message ? error.message : String(error), true);
return false;
}});
}}
function startTrashWorkbenchLiveRefresh() {{
if (!workspaceId || !('EventSource' in window)) return;
var url = new URL('/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
var source = new EventSource(url.toString());
root.__mnoteTrashEventSource = source;
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
source.addEventListener(kind, function() {{
refreshTrashWorkbenchFromServer(kind);
}});
}});
source.onerror = function() {{
root.setAttribute('data-live-refresh-error', 'eventsource_error');
}};
}}
function readJson(response) {{
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
return payload;
}});
}}
function decrement(selector) {{
var count = root.querySelector(selector);
if (!count) return;
var nextCount = Math.max(0, Number(count.textContent || '0') - 1);
count.textContent = String(nextCount);
}}
function postJson(url, body) {{
return fetch(url, {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify(body || {{}})
}}).then(readJson);
}}
function patchJson(url, body) {{
return fetch(url, {{
method: 'PATCH',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify(body || {{}})
}}).then(readJson);
}}
function runResourceAction(kind, action, resourceId, documentId) {{
if (kind === 'media') {{
return action === 'restore'
? postJson('/api/media/batch', {{ action: 'restore', assetIds: [resourceId] }})
: postJson('/api/media/purge', {{ assetId: resourceId }});
}}
if (kind === 'mindmap') {{
if (!documentId) return Promise.reject(new Error('mindmap_document_id_required'));
return patchJson('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(resourceId), {{ action: action }});
}}
if (kind === 'table') {{
return postJson(action === 'restore' ? '/api/tables/restore' : '/api/tables/purge', {{ tableId: resourceId }});
}}
return Promise.reject(new Error('resource_kind_unsupported'));
}}
root.addEventListener('click', function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
var documentId = button.getAttribute('data-document-id') || '';
if (action === 'empty-documents') {{
if (!window.confirm('清空页面垃圾箱后无法恢复,确定继续吗?')) return;
button.disabled = true;
fetch('/api/documents/empty-trash', {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify({{ workspaceId: workspaceId }})
}}).then(function(response) {{
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
if (!response.ok) throw new Error((payload && payload.message) || 'trash_empty_failed_' + response.status);
root.querySelectorAll('[data-trash-row="document"]').forEach(function(row) {{ row.remove(); }});
var count = root.querySelector('[data-trash-document-count]');
if (count) count.textContent = '0';
setStatus('已清空页面垃圾箱', false);
}});
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (action === 'empty-resources') {{
if (!window.confirm('清空资源垃圾箱后无法恢复,确定继续吗?')) return;
button.disabled = true;
Promise.all([
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
postJson('/api/mindmap-trash/empty', {{ workspaceId: workspaceId }}),
postJson('/api/tables/empty-trash', {{ workspaceId: workspaceId }})
]).then(function() {{
root.querySelectorAll('[data-trash-row="resource"]').forEach(function(row) {{ row.remove(); }});
var count = root.querySelector('[data-trash-resource-count]');
if (count) count.textContent = '0';
setStatus('已清空资源垃圾箱', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (action === 'resource-restore' || action === 'resource-purge') {{
var resourceId = button.getAttribute('data-resource-id') || '';
var kind = button.getAttribute('data-resource-kind') || '';
var resourceDocumentId = button.getAttribute('data-document-id') || '';
if (!resourceId) return;
if (action === 'resource-purge' && !window.confirm('彻底删除资源后无法恢复,确定继续吗?')) return;
button.disabled = true;
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
var row = button.closest('[data-trash-row]');
if (row) row.remove();
decrement('[data-trash-resource-count]');
setStatus(action === 'resource-restore' ? '已恢复资源' : '已彻底删除资源', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (!documentId) return;
if (action === 'purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
button.disabled = true;
fetch('/api/tree/commands', {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify({{
action: action,
workspaceId: workspaceId,
documentId: documentId
}})
}}).then(function(response) {{
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
if (!response.ok) throw new Error((payload && payload.message) || 'trash_action_failed_' + response.status);
var row = button.closest('[data-trash-row]');
if (row) row.remove();
decrement('[data-trash-document-count]');
setStatus(action === 'restore' ? '已恢复页面' : '已彻底删除页面', false);
}});
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
}});
startTrashWorkbenchLiveRefresh();
}})();
</script>"#,
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, crate::routes::local_folder_source::LocalTrashEntry>,
) -> 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#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-filetree-row-id="{filetree_row_id}">
<div class="mnote-trash-row-main">
<span class="mnote-trash-kind">{kind}</span>
<span class="mnote-trash-title">{original}</span>
<span class="mnote-trash-meta">回收站:{trash_path} · {deleted_at}</span>
</div>
<div class="mnote-trash-actions">
<button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
<button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
</div>
</article>"#,
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#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
} else {
document_rows.join("")
};
let resource_body = if resource_rows.is_empty() {
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
} else {
resource_rows.join("")
};
format!(
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
<header class="mnote-trash-header">
<h1>本地文件夹垃圾箱</h1>
<p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
</header>
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
<div class="mnote-trash-section-title">
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
<button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
</div>
{document_body}
</section>
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
<div class="mnote-trash-section-title">
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
<button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
</div>
{resource_body}
</section>
</section>
<script>
(function() {{
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
if (!root) return;
var workspaceId = root.getAttribute('data-workspace-id') || '';
var rootUri = root.getAttribute('data-root-uri') || '';
function setStatus(message, failed) {{
var status = root.querySelector('[data-trash-status]');
if (!status) return;
status.textContent = message || '';
status.setAttribute('data-type', failed ? 'error' : 'success');
}}
function readJson(response) {{
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
return payload;
}});
}}
function postJson(url, body) {{
return fetch(url, {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify(body || {{}})
}}).then(readJson);
}}
function refresh() {{
var url = new URL('/trash', window.location.origin);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
.then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
.then(function(result) {{
if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
var parsed = new DOMParser().parseFromString(result.html, 'text/html');
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
root.innerHTML = nextRoot.innerHTML;
return true;
}})
.catch(function(error) {{
setStatus(error && error.message ? error.message : String(error), true);
return false;
}});
}}
root.addEventListener('click', function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
var entryId = button.getAttribute('data-trash-entry-id') || '';
var documentId = button.getAttribute('data-document-id') || entryId;
var kind = button.getAttribute('data-resource-kind') || '';
if (action === 'local-restore' || action === 'local-purge') {{
if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
button.disabled = true;
postJson('/api/tree/commands', {{
action: action === 'local-restore' ? 'restore' : 'purge',
sourceKind: 'local_folder',
rootUri: rootUri,
workspaceId: workspaceId,
documentId: entryId
}}).then(function() {{
if (action === 'local-restore') {{
var row = button.closest('[data-trash-row="local"]');
var filetreeRowId = row ? (row.getAttribute('data-filetree-row-id') || '') : '';
if (filetreeRowId && window.localStorage) {{
window.localStorage.setItem('mnote.pendingLocalFolderRestoreFiletreeRowId', filetreeRowId);
}}
if (filetreeRowId) {{
window.name = 'mnote.pendingLocalFolderRestoreFiletreeRowId=' + encodeURIComponent(filetreeRowId);
}}
}}
return refresh();
}}).then(function() {{
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
}}
if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
var selector = action === 'local-empty-documents'
? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
if (rows.length === 0) return;
if (!window.confirm('清空后无法恢复,确定继续吗?')) return;
button.disabled = true;
rows.reduce(function(chain, row) {{
return chain.then(function() {{
var entryId = row.getAttribute('data-trash-entry-id') || '';
return postJson('/api/tree/commands', {{
action: 'purge',
sourceKind: 'local_folder',
rootUri: rootUri,
workspaceId: workspaceId,
documentId: entryId
}});
}});
}}, Promise.resolve()).then(function() {{
return refresh();
}}).then(function() {{
setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
}}
}});
}})();
</script>"#,
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#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.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#"<article class="mnote-trash-row" data-trash-row="document" data-document-id="{id}">
<div class="mnote-trash-row-main">
<a href="/documents/{id}?workspaceId={workspace_id}" class="mnote-trash-title">{title}</a>
<span class="mnote-trash-meta">{deleted_at}</span>
</div>
<div class="mnote-trash-actions">
<button type="button" data-trash-action="restore" data-document-id="{id}">恢复</button>
<button type="button" data-trash-action="purge" data-document-id="{id}">彻底删除</button>
</div>
</article>"#,
id = escape_html(id),
workspace_id = escape_html(workspace_id),
title = escape_html(title),
deleted_at = escape_html(deleted_at),
))
})
.collect::<Vec<_>>()
.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#"<article class="mnote-trash-row" data-trash-row="resource" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">
<div class="mnote-trash-row-main">
<span class="mnote-trash-kind">{label}</span>
<span class="mnote-trash-title">{title}</span>
<span class="mnote-trash-meta">{deleted_at}</span>
</div>
<div class="mnote-trash-actions">
<button type="button" data-trash-action="resource-restore" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">恢复</button>
<button type="button" data-trash-action="resource-purge" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">彻底删除</button>
</div>
</article>"#,
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::<Vec<_>>()
.join("")
}
#[allow(dead_code)]
pub async fn legacy_next_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
if !state.config().enable_legacy_next_compat {
return Err(WebError::service_unavailable_code(
"legacy_next_compat_disabled",
"Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
return Err(WebError::service_unavailable_code(
"legacy_next_upstream_missing",
"未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
};
let path_and_query = request
.uri()
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}"))
.map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
let method = request.method().clone();
let headers = request.headers().clone();
let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024)
.await
.map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?;
let upstream_origin = upstream_origin(&upstream_url);
let mut upstream_request = client.request(method, upstream_url);
for (name, value) in headers.iter() {
if is_hop_by_hop_header(name.as_str()) || name == header::HOST {
continue;
}
if name == header::ORIGIN {
upstream_request = upstream_request.header(name, upstream_origin.as_str());
continue;
}
if name == header::REFERER {
let normalized_referer = normalize_legacy_referer(value, &upstream_origin);
upstream_request = upstream_request.header(name, normalized_referer);
continue;
}
upstream_request = upstream_request.header(name, value);
}
let upstream_response = upstream_request
.body(body.to_vec())
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"legacy_next_proxy_error",
format!("legacy Next 请求失败: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
})?;
let status = upstream_response.status();
let upstream_headers = upstream_response.headers().clone();
let body = upstream_response.bytes().await.map_err(|error| {
WebError::bad_gateway_code(
"legacy_next_proxy_body_error",
format!("legacy Next 响应读取失败: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
})?;
let mut response = Response::builder()
.status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?;
for (name, value) in upstream_headers.iter() {
if is_hop_by_hop_header(name.as_str()) {
continue;
}
response.headers_mut().append(name, value.clone());
}
stamp_gateway_headers(response.headers_mut(), true);
Ok(response)
}
#[allow(dead_code)]
pub async fn legacy_next_websocket_proxy(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
uri: Uri,
) -> Result<Response, WebError> {
if !state.config().enable_legacy_next_compat {
return Err(WebError::service_unavailable_code(
"legacy_next_compat_disabled",
"Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
return Err(WebError::service_unavailable_code(
"legacy_next_upstream_missing",
"未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 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<String, WebError> {
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<dyn std::error::Error + Send + Sync>> {
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<String, WebError> {
if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
{
return Ok(workspace_id.to_string());
}
let bootstrap = execute_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<String> {
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<String> {
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<Response, WebError> {
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::<Vec<_>>(),
}))
.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<String> {
normalize_optional_id(requested_page_id)
.or_else(|| normalize_optional_id(recent_page_id))
.or_else(|| normalize_optional_id(projection_active_page_id))
.or_else(|| normalize_optional_id(first_page_id))
.map(ToOwned::to_owned)
}
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
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<String> {
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<String>,
) -> 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<String>,
query_fixtures_json: Option<String>,
) -> 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#"<html><body><button>测试账号快速登录</button></body></html>"#)
})
.post(|| async { "auth-post-ok" }),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
async fn spawn_legacy_origin_checked_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("legacy listener");
let addr = listener.local_addr().expect("legacy addr");
let expected_origin = format!("http://{addr}");
let app = axum::Router::new().route(
"/api/auth",
post(move |headers: HeaderMap| {
let expected_origin = expected_origin.clone();
async move {
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if origin != expected_origin {
return (StatusCode::FORBIDDEN, "Invalid origin");
}
(StatusCode::OK, "ok")
}
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
async fn spawn_legacy_cookie_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("legacy listener");
let addr = listener.local_addr().expect("legacy addr");
let app = axum::Router::new().route(
"/api/auth",
post(|| async {
let mut response = "ok".into_response();
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"),
);
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_static(
"__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly",
),
);
response
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
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#"<body data-mnote-web-owner="mnote-web""#));
assert!(html.contains(r#"data-mnote-shell="workspace""#));
assert!(html.contains(r#"data-mnote-actor-id="user_real""#));
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
assert!(html.contains(r#"data-testid="wolai-topbar""#));
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
assert!(html.contains("星标置顶"));
assert!(html.contains("我的页面"));
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(!html.contains("欢迎使用 MNOTE 知识管理平台"));
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
}
#[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 触发 reveallazy 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#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
));
assert!(!html.contains(
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
));
assert!(!html.contains(
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
));
assert!(
include_str!("../../browser/document-resource-tab-runtime.js")
.contains("openResourceInActiveTab")
);
assert!(!html.contains(r#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth?next=%2F")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="workspace""#));
}
#[tokio::test]
async fn root_entry_initializes_default_local_workspace_page() {
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-default-local-workspace");
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
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");
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");
let expected_root = base
.join("users")
.join("user_real")
.join("workspaces")
.join("my-space");
assert!(
!expected_root.join("初始化的新页面").exists(),
"默认工作区不应创建'初始化的新页面'目录"
);
assert!(!html.contains("初始化的新页面"));
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
// Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault).
// Leptos HTML-escapes `&` as `&amp;` in attribute values — match fragments, not full attribute.
let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#)
&& html.contains("/vault?sourceKind=local_folder")
&& html.contains("rootUri=");
assert!(
vault_href_ok,
"vault nav SSR href must include sourceKind+rootUri: {}",
html.lines()
.find(|line| line.contains("mnote-nav-vault"))
.unwrap_or("(no vault nav line)")
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/vault")
.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,
"bare /vault must not 503 via retired cloud workspace bootstrap"
);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
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-page="vault""#)
|| html.contains(r#"data-testid="mnote-vault-workbench""#)
|| html.contains("密码箱"),
"bare vault should render password vault shell: {}",
&html[..html.len().min(500)]
);
assert!(
html.contains(r#"data-mnote-vault-needs-folder="1""#)
|| html.contains("needsLocalFolder"),
"bare vault should signal needs local folder"
);
}
#[tokio::test]
async fn root_entry_local_folder_accepts_gzip_encoding() {
let root = temp_root("mnote-root-local-folder-gzip");
std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!("/?sourceKind=local_folder&rootUri={root_uri}"))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.header(header::ACCEPT_ENCODING, "gzip, br")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
// CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK.
assert!(
encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"),
"unexpected content-encoding: {encoding}"
);
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert!(response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html"));
}
#[tokio::test]
async fn favicon_is_handled_by_gateway_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/favicon.ico")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn auth_api_signup_sets_control_plane_session_cookie_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"new-user@example.com","name":"new-user","password":"MnoteE2E123!","flow":"signUp"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let cookies = response.headers().get_all(header::SET_COOKIE);
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values.iter().any(|value| value.contains("mnote_session=")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_type=user")));
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["userId"], "new-user");
assert_eq!(payload["email"], "new-user@example.com");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn auth_api_signin_accepts_control_plane_username_after_signup() {
let app = app_with_config("http://127.0.0.1:3100".into(), false);
let signup = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","name":"mnote-e2e","password":"MnoteE2E123!","flow":"signUp"}}}"#,
))
.expect("request"),
)
.await
.expect("signup response");
assert_eq!(signup.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"account":"mnote-e2e","password":"MnoteE2E123!","flow":"signIn"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
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["userId"], "mnote-e2e");
assert_eq!(payload["email"], "mnote.e2e@example.com");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn auth_api_requires_email_for_signup() {
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some("http://127.0.0.1:9".into()),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
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["code"], "auth_signup_email_required");
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱"));
}
#[tokio::test]
async fn auth_api_signout_clears_control_plane_session_cookie() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.header("cookie", "mnote_session=raw-session-token")
.body(Body::from(r#"{"action":"auth:signOut"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cookies = response.headers().get_all(header::SET_COOKIE);
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
}
#[tokio::test]
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.uri("/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
None
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="auth""#));
assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录"));
}
#[tokio::test]
async fn auth_entry_redirects_authenticated_viewer_to_root() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/auth")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/")
);
}
#[tokio::test]
async fn auth_api_no_longer_proxies_to_legacy_next_for_origin_normalization() {
let legacy_base_url = spawn_legacy_origin_checked_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("origin", "http://127.0.0.1:3000")
.header("referer", "http://127.0.0.1:3000/auth")
.body(Body::from(r#"{"action":"auth:signIn"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
}
#[tokio::test]
async fn auth_api_no_longer_uses_legacy_next_cookie_proxy() {
let legacy_base_url = spawn_legacy_cookie_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let cookies = response.headers().get_all("set-cookie");
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthJWT=")));
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=")));
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
}
#[tokio::test]
async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() {
let legacy_base_url = spawn_legacy_unmatched_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.uri("/unmigrated")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
None
);
}
}