chore: land tree view-state, vault, Pi module split, and repo hygiene
Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
This commit is contained in:
@@ -4,9 +4,10 @@ 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_context, load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot,
|
||||
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
||||
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::{
|
||||
@@ -15,8 +16,8 @@ use crate::routes::web_shell::{
|
||||
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_sidebar_tree_html,
|
||||
render_local_sidebar_tree_html_from_snapshot,
|
||||
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::{
|
||||
@@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry(
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
if !is_local_access_policy_admin_context(&context) {
|
||||
if !current_actor_is_local_admin(&state, &context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_admin_required",
|
||||
@@ -240,7 +241,7 @@ pub async fn admin_ai_entry(
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
if !is_local_access_policy_admin_context(&context) {
|
||||
if !current_actor_is_local_admin(&state, &context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_admin_required",
|
||||
@@ -267,7 +268,7 @@ pub async fn settings_entry(
|
||||
ai_management_response(
|
||||
&state,
|
||||
&context,
|
||||
is_local_access_policy_admin_context(&context),
|
||||
current_actor_is_local_admin(&state, &context),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -410,10 +411,15 @@ pub async fn root_entry(
|
||||
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(root_uri, 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(root_uri)?
|
||||
load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())?
|
||||
};
|
||||
let workspace_id = page_tree_snapshot
|
||||
.dataset
|
||||
@@ -452,12 +458,19 @@ pub async fn root_entry(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let file_tree_html = render_local_file_tree_html_scoped(
|
||||
root_uri,
|
||||
selected_active_page_id.as_deref(),
|
||||
restore_focus_row_id,
|
||||
file_tree_scope,
|
||||
)?;
|
||||
// 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,
|
||||
@@ -480,7 +493,8 @@ pub async fn root_entry(
|
||||
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
|
||||
})?
|
||||
.to_string();
|
||||
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
|
||||
let snapshot =
|
||||
load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
@@ -511,8 +525,12 @@ pub async fn root_entry(
|
||||
&snapshot,
|
||||
selected_active_page_id.as_deref(),
|
||||
);
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
|
||||
// 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,
|
||||
@@ -593,7 +611,11 @@ pub async fn root_entry(
|
||||
.active_page_title
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
|
||||
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") {
|
||||
@@ -618,6 +640,10 @@ pub async fn root_entry(
|
||||
} 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
|
||||
@@ -625,11 +651,13 @@ pub async fn root_entry(
|
||||
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()}
|
||||
/>
|
||||
})
|
||||
};
|
||||
@@ -708,6 +736,7 @@ document.body.appendChild(s);
|
||||
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!(
|
||||
@@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String {
|
||||
.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>
|
||||
<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>,
|
||||
@@ -1147,7 +1474,7 @@ pub async fn trash_entry(
|
||||
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_html(root_uri, None, 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": "我的空间" }],
|
||||
@@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
|
||||
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())
|
||||
}
|
||||
@@ -3232,6 +3569,104 @@ mod tests {
|
||||
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,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
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)
|
||||
@@ -3780,10 +4215,105 @@ mod tests {
|
||||
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 `&` 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::hermes_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)
|
||||
|
||||
Reference in New Issue
Block a user