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:
@@ -510,16 +510,17 @@ pub async fn user_access_scopes(
|
||||
/// `GET /api/ai-admin/access-scopes`
|
||||
///
|
||||
/// Admin-only variant. Validates that the current actor has admin
|
||||
/// privileges via `is_local_access_policy_admin_context` before
|
||||
/// returning directory grants.
|
||||
/// privileges from the active control-plane session before
|
||||
/// returning **all** directory grants (not only the admin actor's own).
|
||||
/// Source of truth matches `/api/admin/access-policy` → `list_directory_grants()`.
|
||||
pub async fn admin_access_scopes(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<AccessScopesQuery>,
|
||||
) -> Result<Json<AccessScopesResponse>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
let _actor_id = ensure_authenticated(&context)?;
|
||||
|
||||
if !local_folder_source::is_local_access_policy_admin_context(&context) {
|
||||
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_admin_access_forbidden",
|
||||
@@ -528,8 +529,13 @@ pub async fn admin_access_scopes(
|
||||
.with_context(&context));
|
||||
}
|
||||
|
||||
// S3: admin SoT is the full grant table, not actor-scoped grants.
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
|
||||
let allowed_roots =
|
||||
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
|
||||
filter_directory_grants_to_access_scopes(grants, query.workspace_id.as_deref());
|
||||
Ok(Json(AccessScopesResponse {
|
||||
allowed_roots,
|
||||
source_of_truth: SOURCE_OF_TRUTH,
|
||||
@@ -551,7 +557,7 @@ pub async fn admin_receipts(
|
||||
Query(query): Query<ReceiptQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
if !local_folder_source::is_local_access_policy_admin_context(&context) {
|
||||
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_admin_access_forbidden",
|
||||
@@ -629,7 +635,7 @@ pub async fn admin_directory_access_requests(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let requests = list_directory_access_requests(&state, None)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
@@ -645,7 +651,7 @@ pub async fn approve_directory_access_request(
|
||||
Json(_body): Json<DirectoryAccessDecisionBody>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let request = find_pending_directory_access_request(&state, &request_id)?;
|
||||
let user_id = request
|
||||
.get("userId")
|
||||
@@ -720,7 +726,7 @@ pub async fn reject_directory_access_request(
|
||||
Json(body): Json<DirectoryAccessDecisionBody>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let _ = find_pending_directory_access_request(&state, &request_id)?;
|
||||
let metadata = json!({
|
||||
"requestId": request_id,
|
||||
@@ -901,7 +907,7 @@ pub async fn admin_get_settings(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let global_owner = global_policy_owner_id(&actor_id);
|
||||
|
||||
let policy = state
|
||||
@@ -939,7 +945,7 @@ pub async fn admin_put_settings(
|
||||
Json(body): Json<UpsertAiPolicyBody>,
|
||||
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let global_owner = global_policy_owner_id(&actor_id);
|
||||
|
||||
// ── Validate provider secretRefs ──────────────────────────────────
|
||||
@@ -1036,7 +1042,7 @@ pub async fn admin_list_users(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
let users = state
|
||||
.control_plane()
|
||||
.list_users(500)
|
||||
@@ -1049,7 +1055,7 @@ pub async fn admin_list_users(
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"displayName": user.display_name,
|
||||
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
|
||||
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
"updatedAt": user.updated_at,
|
||||
@@ -1069,7 +1075,7 @@ pub async fn admin_get_user_settings(
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
ensure_known_user(&state, &user_id)?;
|
||||
let global_owner = global_policy_owner_id(&actor_id);
|
||||
let global_policy = load_policy_value(&state, &global_owner, None);
|
||||
@@ -1092,7 +1098,7 @@ pub async fn admin_put_user_settings(
|
||||
Json(body): Json<UserAiPolicyBody>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = ensure_authenticated(&context)?;
|
||||
ensure_admin(&context)?;
|
||||
ensure_admin(&state, &context)?;
|
||||
ensure_known_user(&state, &user_id)?;
|
||||
let global_owner = global_policy_owner_id(&actor_id);
|
||||
if user_id == global_owner {
|
||||
@@ -1567,12 +1573,30 @@ fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
|
||||
servers
|
||||
}
|
||||
|
||||
fn policy_removed_default_ids(model_policy: &Value, key: &str) -> std::collections::HashSet<String> {
|
||||
model_policy
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(|value| value.trim().to_string()))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
|
||||
let mut skills = default_skill_registry();
|
||||
let configured = policy_map::<SkillConfig>(model_policy, "skills");
|
||||
for (id, config) in configured {
|
||||
skills.insert(id, config);
|
||||
}
|
||||
// S2: omit built-in defaults the admin explicitly deleted (tombstones).
|
||||
for id in policy_removed_default_ids(model_policy, "removedDefaultSkills") {
|
||||
skills.remove(&id);
|
||||
}
|
||||
skills
|
||||
}
|
||||
|
||||
@@ -1582,6 +1606,9 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConf
|
||||
for (id, config) in configured {
|
||||
servers.insert(id, config);
|
||||
}
|
||||
for id in policy_removed_default_ids(model_policy, "removedDefaultMcpServers") {
|
||||
servers.remove(&id);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
@@ -1594,6 +1621,9 @@ fn effective_pi_extension_registry(model_policy: &Value) -> HashMap<String, PiEx
|
||||
}
|
||||
extensions.insert(id, config);
|
||||
}
|
||||
for id in policy_removed_default_ids(model_policy, "removedDefaultPiExtensions") {
|
||||
extensions.remove(&id);
|
||||
}
|
||||
extensions
|
||||
}
|
||||
|
||||
@@ -1693,6 +1723,11 @@ fn is_configured_admin_user(user_id: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_admin_user_for_display(user_id: &str, stored_role: &str) -> bool {
|
||||
local_folder_source::is_local_access_policy_admin_actor(user_id, stored_role)
|
||||
|| is_configured_admin_user(user_id)
|
||||
}
|
||||
|
||||
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
|
||||
load_model_policy_and_quota(state, actor_id, workspace_id).0
|
||||
}
|
||||
@@ -2238,8 +2273,8 @@ fn filter_directory_grants_to_access_scopes(
|
||||
|
||||
/// Ensures admin auth. Reuses the existing admin check from
|
||||
/// `local_folder_source::is_local_access_policy_admin_context`.
|
||||
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
|
||||
if !local_folder_source::is_local_access_policy_admin_context(context) {
|
||||
fn ensure_admin(state: &AppState, context: &RequestContext) -> Result<(), WebError> {
|
||||
if !crate::routes::gateway::current_actor_is_local_admin(state, context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_admin_access_forbidden",
|
||||
@@ -2634,13 +2669,22 @@ fn merge_policy_with_existing(
|
||||
// The admin settings UI edits this registry as the desired full
|
||||
// state. Re-merging deleted rows back from the persisted policy
|
||||
// makes "删除" a no-op, so presence of this section means replace.
|
||||
//
|
||||
// S2: also write tombstones for built-in defaults omitted from the
|
||||
// payload, so effective_*_registry does not rehydrate ghosts.
|
||||
if let Some(ref skills) = body.skills {
|
||||
let mut merged = serde_json::Map::new();
|
||||
for (k, v) in skills {
|
||||
let val = serde_json::to_value(v).unwrap_or(json!({}));
|
||||
merged.insert(k.clone(), val);
|
||||
}
|
||||
let removed_defaults: Vec<String> = default_skill_registry()
|
||||
.keys()
|
||||
.filter(|id| !merged.contains_key(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
model_policy["skills"] = Value::Object(merged);
|
||||
model_policy["removedDefaultSkills"] = json!(removed_defaults);
|
||||
}
|
||||
|
||||
// ── Replace MCP servers map ─────────────────────────────────────
|
||||
@@ -2653,7 +2697,13 @@ fn merge_policy_with_existing(
|
||||
let val = serde_json::to_value(v).unwrap_or(json!({}));
|
||||
merged.insert(k.clone(), val);
|
||||
}
|
||||
let removed_defaults: Vec<String> = default_mcp_server_registry()
|
||||
.keys()
|
||||
.filter(|id| !merged.contains_key(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
model_policy["mcpServers"] = Value::Object(merged);
|
||||
model_policy["removedDefaultMcpServers"] = json!(removed_defaults);
|
||||
}
|
||||
|
||||
if let Some(ref extensions) = body.pi_extensions {
|
||||
@@ -2662,7 +2712,13 @@ fn merge_policy_with_existing(
|
||||
let val = serde_json::to_value(v).unwrap_or(json!({}));
|
||||
merged.insert(k.clone(), val);
|
||||
}
|
||||
let removed_defaults: Vec<String> = default_pi_extension_registry()
|
||||
.keys()
|
||||
.filter(|id| !merged.contains_key(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
model_policy["piExtensions"] = Value::Object(merged);
|
||||
model_policy["removedDefaultPiExtensions"] = json!(removed_defaults);
|
||||
}
|
||||
|
||||
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
|
||||
@@ -3253,6 +3309,79 @@ mod tests {
|
||||
assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true);
|
||||
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
|
||||
assert!(parsed["piExtensions"].as_object().unwrap().is_empty());
|
||||
// S2: omitting a built-in default from the replace payload tombs it.
|
||||
let removed_skills = parsed["removedDefaultSkills"]
|
||||
.as_array()
|
||||
.expect("removedDefaultSkills");
|
||||
assert!(
|
||||
removed_skills.iter().any(|v| v.as_str() == Some("vpn")),
|
||||
"vpn default skill should be tombstoned when omitted: {parsed}"
|
||||
);
|
||||
let removed_mcp = parsed["removedDefaultMcpServers"]
|
||||
.as_array()
|
||||
.expect("removedDefaultMcpServers");
|
||||
assert!(
|
||||
removed_mcp
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("context7")),
|
||||
"context7 default MCP should be tombstoned when omitted: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// S2: deleted default skills/MCP must not reappear via effective registries.
|
||||
#[test]
|
||||
fn effective_registries_honor_removed_default_tombstones() {
|
||||
let policy = json!({
|
||||
"skills": {
|
||||
"custom-only": {
|
||||
"name": "Custom Only",
|
||||
"enabled": true,
|
||||
"description": "",
|
||||
"source": "/tmp/custom/SKILL.md",
|
||||
"riskLevel": "low",
|
||||
"requiredScopes": []
|
||||
}
|
||||
},
|
||||
"removedDefaultSkills": ["vpn", "chrome-bridge"],
|
||||
"mcpServers": {
|
||||
"custom-mcp": {
|
||||
"name": "Custom MCP",
|
||||
"enabled": true,
|
||||
"url": "",
|
||||
"transport": "stdio",
|
||||
"command": "custom-mcp",
|
||||
"networkPolicy": "deny-all",
|
||||
"secretRefs": [],
|
||||
"facadeOnly": true,
|
||||
"sandbox": true,
|
||||
"description": "",
|
||||
"riskLevel": "medium",
|
||||
"requiredScopes": []
|
||||
}
|
||||
},
|
||||
"removedDefaultMcpServers": ["context7", "codegraph"]
|
||||
});
|
||||
let skills = effective_skill_registry(&policy);
|
||||
assert!(skills.contains_key("custom-only"));
|
||||
assert!(
|
||||
!skills.contains_key("vpn"),
|
||||
"tombstoned default skill must not rehydrate"
|
||||
);
|
||||
assert!(!skills.contains_key("chrome-bridge"));
|
||||
// Untombstoned defaults still present.
|
||||
assert!(skills.contains_key("context7") || skills.contains_key("searxng"));
|
||||
|
||||
let mcps = effective_mcp_registry(&policy);
|
||||
assert!(mcps.contains_key("custom-mcp"));
|
||||
assert!(!mcps.contains_key("context7"));
|
||||
assert!(!mcps.contains_key("codegraph"));
|
||||
|
||||
// Admin projection must match effective (no ghost defaults).
|
||||
let admin = project_admin_settings(&policy, &json!({}), None);
|
||||
assert!(!admin.skills.contains_key("vpn"));
|
||||
assert!(admin.skills.contains_key("custom-only"));
|
||||
assert!(!admin.mcp_servers.contains_key("context7"));
|
||||
assert!(admin.mcp_servers.contains_key("custom-mcp"));
|
||||
}
|
||||
|
||||
// ─── Admin projection ────────────────────────────────────────────────
|
||||
@@ -3479,4 +3608,27 @@ mod tests {
|
||||
};
|
||||
assert!(validate_user_policy_body(&body, &global).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_user_display_role_includes_access_policy_admins() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let policy_root = std::env::temp_dir().join(format!(
|
||||
"mnote-ai-settings-display-admin-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
std::fs::create_dir_all(&policy_root).expect("create policy root");
|
||||
let policy_file = policy_root.join("access-policy.json");
|
||||
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
|
||||
.expect("write access policy");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
|
||||
assert!(is_admin_user_for_display("liaibo", "user"));
|
||||
assert!(!is_admin_user_for_display("shujuan", "user"));
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,9 @@ use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_snapshot, load_local_folder_page_tree_snapshot_with_reveal,
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
@@ -121,7 +123,21 @@ async fn project_projection(
|
||||
load_local_folder_file_tree_snapshot(&root_uri)
|
||||
}
|
||||
} else if let Some(parent_relative_path) = parent_relative_path.as_deref() {
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
|
||||
// Children fetch stays shallow; only reveal when root_node_id is present.
|
||||
if root_node_id.is_some() {
|
||||
load_local_folder_page_tree_scope_snapshot_with_reveal(
|
||||
&root_uri,
|
||||
parent_relative_path,
|
||||
root_node_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
|
||||
}
|
||||
} else if root_node_id.is_some() {
|
||||
load_local_folder_page_tree_snapshot_with_reveal(
|
||||
&root_uri,
|
||||
root_node_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(&root_uri)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,21 @@ fn parse_markdown_attachment_link_with_paths(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let target_path = std::path::Path::new(target);
|
||||
if page_reference_from_target(target, label).is_some()
|
||||
&& !attachment_paths.contains(target)
|
||||
&& !is_local_markdown_asset_href(target)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let target_without_fragment = target
|
||||
.split_once('#')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(target);
|
||||
let target_path_part = target_without_fragment
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(target_without_fragment);
|
||||
let target_path = std::path::Path::new(target_path_part);
|
||||
let extension = target_path.extension().and_then(|value| value.to_str())?;
|
||||
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
|
||||
&& !attachment_paths.contains(target)
|
||||
@@ -320,7 +334,40 @@ fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usiz
|
||||
|
||||
fn should_collect_attachment_href(raw_href: &str) -> bool {
|
||||
let trimmed = raw_href.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("mailto:") {
|
||||
return false;
|
||||
}
|
||||
page_reference_from_target(trimmed, "").is_none() || is_local_markdown_asset_href(trimmed)
|
||||
}
|
||||
|
||||
fn is_local_markdown_asset_href(raw_href: &str) -> bool {
|
||||
let trimmed = raw_href
|
||||
.trim()
|
||||
.strip_prefix('<')
|
||||
.and_then(|value| value.strip_suffix('>'))
|
||||
.unwrap_or(raw_href.trim());
|
||||
let without_fragment = trimmed
|
||||
.split_once('#')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(trimmed);
|
||||
let without_query = without_fragment
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(without_fragment);
|
||||
let normalized = without_query
|
||||
.strip_prefix("./")
|
||||
.unwrap_or(without_query)
|
||||
.replace('\\', "/");
|
||||
let extension = Path::new(&normalized)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
if !matches!(extension.as_deref(), Some("md" | "markdown")) {
|
||||
return false;
|
||||
}
|
||||
normalized
|
||||
.split('/')
|
||||
.any(|segment| segment == ".assets" || segment.ends_with(".assets"))
|
||||
}
|
||||
|
||||
fn build_attachment_ref(
|
||||
@@ -686,6 +733,9 @@ fn append_ast_paragraph<'a>(
|
||||
blocks.push(MarkdownBlock::PageReference { title, source_path });
|
||||
return;
|
||||
}
|
||||
if append_paragraph_attachment_sequence(node, blocks, attachment_paths) {
|
||||
return;
|
||||
}
|
||||
if let Some((name, source_path, remaining)) =
|
||||
paragraph_leading_attachment_media(node, attachment_paths)
|
||||
{
|
||||
@@ -716,6 +766,9 @@ fn append_ast_list_item<'a>(
|
||||
if children.next().is_none()
|
||||
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
|
||||
{
|
||||
if append_paragraph_attachment_sequence(paragraph, blocks, attachment_paths) {
|
||||
return;
|
||||
}
|
||||
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
|
||||
blocks.push(MarkdownBlock::PageReference { title, source_path });
|
||||
return;
|
||||
@@ -724,13 +777,49 @@ fn append_ast_list_item<'a>(
|
||||
}
|
||||
}
|
||||
let mut content = Vec::new();
|
||||
let mut emitted_child_blocks = false;
|
||||
for child in node.children() {
|
||||
match child.data.borrow().value.clone() {
|
||||
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
|
||||
_ => append_ast_block(child, blocks, attachment_paths),
|
||||
NodeValue::Paragraph => {
|
||||
let mut paragraph_blocks = Vec::new();
|
||||
if append_paragraph_attachment_sequence(
|
||||
child,
|
||||
&mut paragraph_blocks,
|
||||
attachment_paths,
|
||||
) {
|
||||
if !content.is_empty() {
|
||||
push_list_item_content_block(blocks, ordered, is_task, checked, content);
|
||||
content = Vec::new();
|
||||
}
|
||||
blocks.extend(paragraph_blocks);
|
||||
emitted_child_blocks = true;
|
||||
} else {
|
||||
content.extend(collect_inline_children(child));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !content.is_empty() {
|
||||
push_list_item_content_block(blocks, ordered, is_task, checked, content);
|
||||
content = Vec::new();
|
||||
}
|
||||
append_ast_block(child, blocks, attachment_paths);
|
||||
emitted_child_blocks = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !content.is_empty() || !emitted_child_blocks {
|
||||
push_list_item_content_block(blocks, ordered, is_task, checked, content);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_list_item_content_block(
|
||||
blocks: &mut Vec<MarkdownBlock>,
|
||||
ordered: bool,
|
||||
is_task: bool,
|
||||
checked: bool,
|
||||
content: Vec<MarkdownInline>,
|
||||
) {
|
||||
if is_task {
|
||||
blocks.push(MarkdownBlock::Todo { checked, content });
|
||||
} else if ordered {
|
||||
@@ -876,6 +965,39 @@ fn paragraph_attachment_media<'a>(
|
||||
link_attachment_media(first, attachment_paths)
|
||||
}
|
||||
|
||||
fn append_paragraph_attachment_sequence<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
blocks: &mut Vec<MarkdownBlock>,
|
||||
attachment_paths: &BTreeSet<String>,
|
||||
) -> bool {
|
||||
let mut parsed = Vec::<MarkdownBlock>::new();
|
||||
for child in node.children() {
|
||||
match &child.data.borrow().value {
|
||||
NodeValue::SoftBreak | NodeValue::LineBreak => continue,
|
||||
NodeValue::Text(text) if text.as_ref().trim().is_empty() => continue,
|
||||
NodeValue::Image(link) => parsed.push(MarkdownBlock::Image {
|
||||
alt: collect_plain_text(child).trim().to_string(),
|
||||
source_path: link.url.clone(),
|
||||
}),
|
||||
NodeValue::Link(_) => {
|
||||
if let Some((name, source_path)) = link_attachment_media(child, attachment_paths) {
|
||||
parsed.push(MarkdownBlock::Media { name, source_path });
|
||||
} else if let Some((title, source_path)) = link_page_reference(child) {
|
||||
parsed.push(MarkdownBlock::PageReference { title, source_path });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if parsed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
blocks.extend(parsed);
|
||||
true
|
||||
}
|
||||
|
||||
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
@@ -982,6 +1104,9 @@ fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
|
||||
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
|
||||
let target = normalized_markdown_link_target(target)?;
|
||||
if let Some(source_path) = local_markdown_relative_path_from_documents_target(target) {
|
||||
return Some(page_reference_title_and_path(&source_path, label));
|
||||
}
|
||||
if target.is_empty()
|
||||
|| target.starts_with("http://")
|
||||
|| target.starts_with("https://")
|
||||
@@ -1004,20 +1129,59 @@ fn page_reference_from_target(target: &str, label: &str) -> Option<(String, Stri
|
||||
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
|
||||
return None;
|
||||
}
|
||||
let fallback_title = std::path::Path::new(path_part)
|
||||
Some(page_reference_title_and_path(path_part, label))
|
||||
}
|
||||
|
||||
fn page_reference_title_and_path(source_path: &str, label: &str) -> (String, String) {
|
||||
let fallback_title = std::path::Path::new(source_path)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("页面")
|
||||
.trim();
|
||||
let title = label.trim();
|
||||
Some((
|
||||
(
|
||||
if title.is_empty() {
|
||||
fallback_title.to_string()
|
||||
} else {
|
||||
title.to_string()
|
||||
},
|
||||
path_part.to_string(),
|
||||
))
|
||||
source_path.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_documents_target(target: &str) -> Option<String> {
|
||||
let normalized = target.trim();
|
||||
let path_and_query = if normalized.starts_with("/documents/") {
|
||||
normalized
|
||||
} else if let Some((_, rest)) = normalized.split_once("://") {
|
||||
let slash = rest.find('/')?;
|
||||
&rest[slash..]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let path_part = path_and_query
|
||||
.split_once('#')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(path_and_query)
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(path_and_query);
|
||||
let segment = path_part.strip_prefix("/documents/")?;
|
||||
let decoded_segment = percent_decode_lossy(segment);
|
||||
let local_id = decoded_segment.strip_prefix("local-md:")?;
|
||||
let relative_path = percent_decode_lossy(&local_id.replace('~', "%"))
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
if relative_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let extension = std::path::Path::new(&relative_path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())?;
|
||||
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
|
||||
return None;
|
||||
}
|
||||
Some(relative_path)
|
||||
}
|
||||
|
||||
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
|
||||
@@ -1363,6 +1527,28 @@ mod tests {
|
||||
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_consecutive_attachment_links_parse_as_blocks() {
|
||||
let blocks = markdown_to_blocks(
|
||||
"[身份证](.assets/身份证_李爱波.pdf)\n[本科证书](.assets/本科证书证明.pdf)\n\n",
|
||||
);
|
||||
let items = blocks.as_array().expect("blocks array");
|
||||
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0]["type"].as_str(), Some("media"));
|
||||
assert_eq!(
|
||||
items[0]["props"]["sourcePath"].as_str(),
|
||||
Some(".assets/身份证_李爱波.pdf")
|
||||
);
|
||||
assert_eq!(items[1]["type"].as_str(), Some("media"));
|
||||
assert_eq!(
|
||||
items[1]["props"]["sourcePath"].as_str(),
|
||||
Some(".assets/本科证书证明.pdf")
|
||||
);
|
||||
assert_eq!(items[2]["type"].as_str(), Some("image"));
|
||||
assert_eq!(items[2]["props"]["src"].as_str(), Some(".assets/photo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
|
||||
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
|
||||
@@ -1411,6 +1597,46 @@ mod tests {
|
||||
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_runtime_local_documents_url_parses_as_page_reference_block() {
|
||||
let blocks = markdown_to_blocks(
|
||||
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
|
||||
);
|
||||
let first = blocks
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("first block");
|
||||
|
||||
assert_eq!(first["type"].as_str(), Some("page_reference"));
|
||||
assert_eq!(first["props"]["title"].as_str(), Some("爱斯特完结项目"));
|
||||
assert_eq!(
|
||||
first["props"]["sourcePath"].as_str(),
|
||||
Some("liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_runtime_local_documents_url_is_not_attachment_ref() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ref-attachment-filter-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let owner_dir = root.join("liaibo的个人空间/项目/完结项目");
|
||||
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
|
||||
let owner = owner_dir.join("完结项目.md");
|
||||
|
||||
let refs = parse_markdown_attachment_refs(
|
||||
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
|
||||
&owner.display().to_string(),
|
||||
&format!("file://{}", root.display()),
|
||||
);
|
||||
|
||||
assert!(
|
||||
refs.is_empty(),
|
||||
"内部页面链接不能进入 attachmentRefs: {refs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_attachment_refs_parse_standard_href_variants() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -42,6 +42,9 @@ mod tree;
|
||||
mod tree_view_state;
|
||||
mod ui_debug;
|
||||
pub(crate) mod ui_preferences;
|
||||
mod vault;
|
||||
mod vault_path;
|
||||
mod vault_store;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
@@ -75,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/health", get(health::health))
|
||||
.route("/", get(gateway::root_entry))
|
||||
.route("/trash", get(gateway::trash_entry))
|
||||
.route("/vault", get(gateway::vault_entry))
|
||||
.route("/files", get(gateway::files_redirect_to_vault))
|
||||
.route("/favicon.ico", get(gateway::favicon))
|
||||
.route("/settings", get(gateway::settings_entry))
|
||||
.route(
|
||||
@@ -370,6 +375,64 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
|
||||
get(web_shell::document_editor_adapter_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
|
||||
get(web_shell::vault_workbench_runtime_asset),
|
||||
)
|
||||
.route("/api/vault/ensure", post(vault::ensure))
|
||||
.route("/api/vault/reindex", post(vault::reindex))
|
||||
.route("/api/vault/list", get(vault::list))
|
||||
.route("/api/vault/items", post(vault::create_item))
|
||||
.route(
|
||||
"/api/vault/items/{id}",
|
||||
get(vault::get_item)
|
||||
.patch(vault::update_item)
|
||||
.delete(vault::delete_item),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/items/{id}/restore",
|
||||
post(vault::restore_item),
|
||||
)
|
||||
.route("/api/vault/items/{id}/purge", post(vault::purge_item))
|
||||
.route(
|
||||
"/api/vault/items/{id}/reveal",
|
||||
post(vault::reveal_item),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/items/{id}/resolve",
|
||||
post(vault::resolve_item),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/items/{id}/share-to-ai",
|
||||
post(vault::share_to_ai),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/items/{id}/unshare-from-ai",
|
||||
post(vault::unshare_from_ai),
|
||||
)
|
||||
.route("/api/vault/ai/list", get(vault::list_ai))
|
||||
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
|
||||
.route(
|
||||
"/api/vault/ai/items/{id}/resolve",
|
||||
post(vault::resolve_ai_item),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/ai/items/{id}/login",
|
||||
post(vault::login_ai_item),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/ai/items/{id}/session",
|
||||
post(vault::put_ai_session),
|
||||
)
|
||||
.route("/api/vault/cipher-book", get(vault::list_cipher_book))
|
||||
.route(
|
||||
"/api/vault/cipher-book/{key}",
|
||||
put(vault::put_cipher_key).delete(vault::delete_cipher_key),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/cipher-book/{key}/reveal",
|
||||
post(vault::reveal_cipher_key),
|
||||
)
|
||||
.route("/api/search/documents", post(search::documents))
|
||||
.route(
|
||||
"/api/search/local-index/refresh",
|
||||
@@ -859,6 +922,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/documents/buffer-state/dirty",
|
||||
post(documents::mark_buffer_dirty),
|
||||
)
|
||||
.route(
|
||||
"/api/documents/buffer-state/dirty:0",
|
||||
post(documents::mark_buffer_dirty),
|
||||
)
|
||||
.route("/api/documents/purge", post(documents::purge))
|
||||
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
@@ -1624,6 +1691,7 @@ mod tests {
|
||||
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
|
||||
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
|
||||
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
|
||||
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
|
||||
] {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Pi Page AI 常量与 schema 名(产品口径,非 spike)。
|
||||
|
||||
pub(super) const PI_LAB_VERSION: &str = "1.0.0";
|
||||
pub(super) const PI_LAB_PROVIDER: &str = "pi";
|
||||
pub(super) const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
|
||||
pub(super) const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
|
||||
pub(super) const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
|
||||
pub(super) const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
|
||||
|
||||
pub(super) const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
|
||||
pub(super) const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
|
||||
pub(super) const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
|
||||
pub(super) const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
|
||||
pub(super) const PI_LAB_MAX_SESSIONS: usize = 16;
|
||||
pub(super) const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
|
||||
pub(super) const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
|
||||
pub(super) const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
|
||||
pub(super) const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
|
||||
pub(super) const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
|
||||
pub(super) const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"hashline_edit",
|
||||
];
|
||||
pub(super) const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
|
||||
pub(super) const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
|
||||
pub(super) const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub(super) const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const PI_LAB_PROFILE: &str = "pi_lab";
|
||||
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Pi Rust Page AI route module.
|
||||
//!
|
||||
//! - [`constants`]:版本 / schema / profile 常量
|
||||
//! - [`runtime`]:HTTP handlers、RPC、session 热缓存与桥接
|
||||
//!
|
||||
//! 会话元数据权威源:Turso/libSQL control-plane。
|
||||
//! 进程内 `HashMap` 仅为热缓存;重启后由 `hydrate_session_from_control_plane` 恢复元数据。
|
||||
|
||||
mod constants;
|
||||
mod runtime;
|
||||
|
||||
pub use runtime::*;
|
||||
+508
-53
@@ -1,7 +1,8 @@
|
||||
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。
|
||||
//! Pi Rust Page AI — MNote 托管的生产 Page AI 后端。
|
||||
//!
|
||||
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 与 Pi TS 已退役到 recycle 边界。
|
||||
//! 默认启用(`MNOTE_PAGE_AI_PI_LAB`);OpenHub 与 Pi TS 已退役到 recycle 边界。
|
||||
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
|
||||
//! 会话元数据 / run / tool event 持久化到 Turso/libSQL control-plane;进程内表仅作热缓存。
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
@@ -35,52 +36,13 @@ use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
|
||||
const PI_LAB_PROVIDER: &str = "pi";
|
||||
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
|
||||
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
|
||||
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
|
||||
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
|
||||
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
|
||||
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
|
||||
const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
|
||||
const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
|
||||
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
|
||||
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
|
||||
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
|
||||
const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
|
||||
|
||||
const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
|
||||
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
|
||||
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
|
||||
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
|
||||
const PI_LAB_MAX_SESSIONS: usize = 16;
|
||||
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
|
||||
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
|
||||
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
|
||||
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
|
||||
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
|
||||
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"hashline_edit",
|
||||
];
|
||||
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
|
||||
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
|
||||
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
|
||||
const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const PI_LAB_PROFILE: &str = "pi_lab";
|
||||
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
|
||||
use super::constants::*;
|
||||
|
||||
static PI_LAB_SESSIONS: LazyLock<StdMutex<HashMap<String, PiLabSession>>> =
|
||||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
|
||||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
/// 热缓存;权威查询走 control-plane `list_ai_tool_events` / `list_ai_file_patches`。
|
||||
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
|
||||
LazyLock::new(|| StdMutex::new(Vec::new()));
|
||||
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
|
||||
@@ -297,6 +259,9 @@ pub struct PiLabSendRequest {
|
||||
pub folder_path: Option<String>,
|
||||
pub context_refs: Option<Vec<String>>,
|
||||
pub selected_context: Option<Value>,
|
||||
/// S7: optional agent target package (mnote.agent_target_package.v1) from Page AI host.
|
||||
#[serde(default)]
|
||||
pub target_package: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -787,6 +752,8 @@ fn resolve_file_path(
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&relative_path)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let target =
|
||||
resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?;
|
||||
return Ok((target, Some(root_uri.to_string()), Some(relative_path)));
|
||||
@@ -800,6 +767,18 @@ fn resolve_file_path(
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
// Absolute-path tools: deny any path whose workspace-relative form hits vault.
|
||||
let normalized_abs = path.replace('\\', "/");
|
||||
if let Some(idx) = normalized_abs.find("/.mnote/vault") {
|
||||
let tail = &normalized_abs[idx + 1..]; // drop leading '/'
|
||||
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(tail)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
} else if crate::routes::vault_path::is_vault_sensitive_relative_path(&normalized_abs) {
|
||||
return Err(crate::routes::vault_path::vault_path_denied_error(
|
||||
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let allowed = active_allowed_roots(state, context)?;
|
||||
let target = canonical_or_parent(&requested);
|
||||
let allowed_root = allowed
|
||||
@@ -1545,11 +1524,19 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
| "mnote.reference.open"
|
||||
| "mnote.vault.list"
|
||||
| "mnote.vault.get"
|
||||
| "mnote.tool_receipt.write" => "allow".into(),
|
||||
// plan: no secret resolve/login
|
||||
_ => "deny".into(),
|
||||
},
|
||||
Some("auto_edit") => match tool_name {
|
||||
"mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(),
|
||||
"mnote.vault.list"
|
||||
| "mnote.vault.get"
|
||||
| "mnote.vault.resolve"
|
||||
| "mnote.vault.login"
|
||||
| "mnote.vault.session" => "allow".into(),
|
||||
"mnote.codex_rescue.request" => "ask".into(),
|
||||
_ => base_policy.into(),
|
||||
},
|
||||
@@ -1564,12 +1551,52 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
|
||||
"mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => {
|
||||
"ask".into()
|
||||
}
|
||||
"mnote.vault.list"
|
||||
| "mnote.vault.get"
|
||||
| "mnote.vault.resolve"
|
||||
| "mnote.vault.login"
|
||||
| "mnote.vault.session" => "allow".into(),
|
||||
_ => base_policy.into(),
|
||||
},
|
||||
_ => match tool_name {
|
||||
"mnote.vault.list"
|
||||
| "mnote.vault.get"
|
||||
| "mnote.vault.resolve"
|
||||
| "mnote.vault.login"
|
||||
| "mnote.vault.session" => "allow".into(),
|
||||
_ => base_policy.into(),
|
||||
},
|
||||
_ => base_policy.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip secret values before control-plane receipt persistence.
|
||||
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
|
||||
if tool_name != "mnote.vault.resolve"
|
||||
&& tool_name != "mnote.vault.login"
|
||||
&& tool_name != "mnote.vault.session"
|
||||
{
|
||||
return payload.clone();
|
||||
}
|
||||
let mut safe = payload.clone();
|
||||
if let Some(obj) = safe.as_object_mut() {
|
||||
if obj.contains_key("value") {
|
||||
obj.insert("value".into(), Value::String("[redacted]".into()));
|
||||
}
|
||||
if obj.contains_key("cookieHeader") {
|
||||
obj.insert("cookieHeader".into(), Value::String("[redacted]".into()));
|
||||
}
|
||||
if let Some(secret) = obj.get_mut("secret") {
|
||||
if let Some(s) = secret.as_object_mut() {
|
||||
if s.contains_key("value") {
|
||||
s.insert("value".into(), Value::String("[redacted]".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
obj.insert("receiptRedacted".into(), Value::Bool(true));
|
||||
}
|
||||
safe
|
||||
}
|
||||
|
||||
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
|
||||
session
|
||||
.runtime_policy_snapshot
|
||||
@@ -2321,6 +2348,36 @@ fn pi_lab_tool_definitions() -> Vec<PiLabToolDefinition> {
|
||||
label: "MNote Codex rescue",
|
||||
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_vault_list",
|
||||
mnote_name: "mnote.vault.list",
|
||||
label: "MNote AI vault list",
|
||||
description: "List credentials in the shared AI password book (L0 metadata only, no secret plaintext). Multi-agent single credential pool. Optional status=active|deleted. Do NOT use generic file tools on .mnote/vault.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_vault_get",
|
||||
mnote_name: "mnote.vault.get",
|
||||
label: "MNote AI vault get",
|
||||
description: "Get one AI password-book item by id (secrets masked). Params: id. Use mnote.vault.resolve to obtain password/apikey/token plaintext for automation.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_vault_resolve",
|
||||
mnote_name: "mnote.vault.resolve",
|
||||
label: "MNote AI vault resolve",
|
||||
description: "Resolve a secret field from the shared AI password book with cipher-book expansion. Params: id, field=password|apikey|token. Prefer mnote.vault.login for site login (session reuse). Do not paste value into chat.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_vault_login",
|
||||
mnote_name: "mnote.vault.login",
|
||||
label: "MNote AI vault login",
|
||||
description: "ONE-SHOT multi-agent login for AI password book. Params: id, optional forceRefresh. Reuses saved session if fresh; otherwise api_first password login and saves session. If Cloudflare/captcha: returns human_required — human uses chrome-bridge/Paseo browser then mnote.vault.session. Prefer this over list+resolve+browser for logins. Do not paste cookieHeader/password into chat.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_vault_session",
|
||||
mnote_name: "mnote.vault.session",
|
||||
label: "MNote AI vault session write-back",
|
||||
description: "Write browser-captured cookies into AI password book after human Cloudflare/captcha login. Params: id, cookieHeader, optional expiresAt, source=human_bridge|browser. Next mnote.vault.login will reuse them.",
|
||||
},
|
||||
PiLabToolDefinition {
|
||||
pi_name: "mnote_tool_receipt_write",
|
||||
mnote_name: "mnote.tool_receipt.write",
|
||||
@@ -3028,6 +3085,96 @@ fn publish_event(session_id: &str, kind: &str, payload: Value) {
|
||||
let _ = PI_LAB_EVENT_TX.send(event);
|
||||
}
|
||||
|
||||
fn status_from_persisted(status: &str) -> PiLabSessionStatus {
|
||||
match status {
|
||||
"turn_running" => PiLabSessionStatus::Idle,
|
||||
"runtime_running" => PiLabSessionStatus::Idle,
|
||||
"aborted" => PiLabSessionStatus::Aborted,
|
||||
"error" => PiLabSessionStatus::Error,
|
||||
_ => PiLabSessionStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 control-plane 恢复会话元数据到热缓存(不自动拉起子进程)。
|
||||
fn hydrate_session_from_control_plane(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Option<PiLabSession>, WebError> {
|
||||
if session_id.trim().is_empty() || is_pi_lab_warmup_session_id(session_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
if get_session(session_id).is_some() {
|
||||
return Ok(get_session(session_id));
|
||||
}
|
||||
let run_id = pi_run_id(session_id);
|
||||
let Some(run) = state
|
||||
.control_plane()
|
||||
.find_ai_runtime_run(user_id, &run_id)
|
||||
.map_err(|e| WebError::internal(format!("hydrate Pi session 失败: {e}")))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
|
||||
return Ok(None);
|
||||
}
|
||||
if run.user_id != user_id {
|
||||
return Ok(None);
|
||||
}
|
||||
let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
|
||||
let str_field = |key: &str| -> Option<String> {
|
||||
runtime
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let now = now_ms();
|
||||
let session = PiLabSession {
|
||||
session_id: run.session_id.clone(),
|
||||
mnote_user_id: run.user_id.clone(),
|
||||
bridge_token: generate_bridge_token(),
|
||||
status: status_from_persisted(&run.status),
|
||||
provider_session_id: str_field("providerSessionId")
|
||||
.unwrap_or_else(|| run.session_id.clone()),
|
||||
pi_session_dir: str_field("piSessionDir").unwrap_or_default(),
|
||||
pi_session_file: str_field("piSessionFile"),
|
||||
root_uri: str_field("rootUri"),
|
||||
workspace_id: run
|
||||
.workspace_id
|
||||
.clone()
|
||||
.or_else(|| str_field("workspaceId")),
|
||||
page_path: run.document_id.clone().or_else(|| str_field("pagePath")),
|
||||
page_title: run.title.clone().or_else(|| str_field("pageTitle")),
|
||||
model_provider: str_field("modelProvider"),
|
||||
model_id: str_field("modelId"),
|
||||
thinking_level: str_field("thinkingLevel"),
|
||||
allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(),
|
||||
runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(),
|
||||
runtime_pid: None,
|
||||
runtime_mode: str_field("runtimeMode").unwrap_or_else(|| "real".to_string()),
|
||||
runtime_error: None,
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
message_count: runtime
|
||||
.get("messageCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
if session.pi_session_dir.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
upsert_session(session.clone());
|
||||
let _ = persist_append_event(
|
||||
state,
|
||||
&session,
|
||||
"session_hydrated",
|
||||
&json!({"source": "control_plane", "runId": run.run_id}),
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
fn upsert_session(session: PiLabSession) {
|
||||
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
|
||||
sessions.insert(session.session_id.clone(), session);
|
||||
@@ -3219,11 +3366,25 @@ fn get_session_for_context(
|
||||
context: &RequestContext,
|
||||
session_id: &str,
|
||||
) -> Result<PiLabSession, WebError> {
|
||||
let session = get_session(session_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
|
||||
})?;
|
||||
ensure_session_owner(state, context, &session)?;
|
||||
Ok(session)
|
||||
if let Some(session) = get_session(session_id) {
|
||||
ensure_session_owner(state, context, &session)?;
|
||||
return Ok(session);
|
||||
}
|
||||
// S6: process restart / cold cache — hydrate metadata from control-plane
|
||||
// so send/events/status can resume the same sessionId without a full start.
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
if let Ok(Some(session)) =
|
||||
hydrate_session_from_control_plane(state, actor_id, session_id)
|
||||
{
|
||||
ensure_session_owner(state, context, &session)?;
|
||||
return Ok(session);
|
||||
}
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_session_not_found",
|
||||
"Pi Lab session 不存在",
|
||||
))
|
||||
}
|
||||
|
||||
fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> {
|
||||
@@ -4668,12 +4829,153 @@ impl PiLabToolFacade {
|
||||
"mnote.knowledge_rag.section_context",
|
||||
"mnote.knowledge_rag.open_reference",
|
||||
"mnote.reference.open",
|
||||
"mnote.vault.list",
|
||||
"mnote.vault.get",
|
||||
"mnote.vault.resolve",
|
||||
"mnote.vault.login",
|
||||
"mnote.vault.session",
|
||||
"mnote.codex_rescue.request",
|
||||
"mnote.tool_receipt.write"
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
fn vault_list(&self, params: Value) -> Result<Value, WebError> {
|
||||
let status = params
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
|
||||
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
|
||||
crate::routes::vault::list_ai_vault_items(status)
|
||||
}
|
||||
|
||||
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
|
||||
let id = string_param(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
|
||||
})?;
|
||||
crate::routes::vault::get_ai_vault_item(&id)
|
||||
}
|
||||
|
||||
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
|
||||
let id = string_param(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.resolve 需要 id",
|
||||
)
|
||||
})?;
|
||||
let field = string_param(¶ms, "field").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_field_required",
|
||||
"mnote.vault.resolve 需要 field=password|apikey|token",
|
||||
)
|
||||
})?;
|
||||
let actor = {
|
||||
let id = self.context.auth.actor_id.trim();
|
||||
if id.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
};
|
||||
if actor == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_auth_required",
|
||||
"密码箱 resolve 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::resolve_ai_vault_secret(
|
||||
&id,
|
||||
&field,
|
||||
&actor,
|
||||
Some(self.context.trace.request_id.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn vault_login(&self, params: Value) -> Result<Value, WebError> {
|
||||
let id = string_param(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.login 需要 id",
|
||||
)
|
||||
})?;
|
||||
let force = bool_param(¶ms, "forceRefresh")
|
||||
.or_else(|| bool_param(¶ms, "force_refresh"))
|
||||
.unwrap_or(false);
|
||||
let actor = {
|
||||
let id = self.context.auth.actor_id.trim();
|
||||
if id.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
};
|
||||
if actor == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_auth_required",
|
||||
"密码箱 login 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::login_ai_vault_credential(
|
||||
&id,
|
||||
force,
|
||||
&actor,
|
||||
Some(self.context.trace.request_id.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn vault_session(&self, params: Value) -> Result<Value, WebError> {
|
||||
let id = string_param(¶ms, "id")
|
||||
.or_else(|| string_param(¶ms, "credentialId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_id_required",
|
||||
"mnote.vault.session 需要 id",
|
||||
)
|
||||
})?;
|
||||
let cookie = string_param(¶ms, "cookieHeader")
|
||||
.or_else(|| string_param(¶ms, "cookie_header"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_cookie_required",
|
||||
"mnote.vault.session 需要 cookieHeader",
|
||||
)
|
||||
})?;
|
||||
let expires = string_param(¶ms, "expiresAt")
|
||||
.or_else(|| string_param(¶ms, "expires_at"));
|
||||
let source = string_param(¶ms, "source").unwrap_or_else(|| "human_bridge".into());
|
||||
let actor = {
|
||||
let id = self.context.auth.actor_id.trim();
|
||||
if id.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
};
|
||||
if actor == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_auth_required",
|
||||
"密码箱 session 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::put_ai_vault_session(
|
||||
&id,
|
||||
&cookie,
|
||||
expires.as_deref(),
|
||||
&source,
|
||||
&actor,
|
||||
Some(self.context.trace.request_id.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
|
||||
let (target, root_uri, relative_path) = resolve_file_path(
|
||||
&self.state,
|
||||
@@ -5205,6 +5507,11 @@ async fn execute_tool(
|
||||
}
|
||||
"mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await,
|
||||
"mnote.reference.open" => facade.reference_open(params.clone()).await,
|
||||
"mnote.vault.list" => facade.vault_list(params.clone()),
|
||||
"mnote.vault.get" => facade.vault_get(params.clone()),
|
||||
"mnote.vault.resolve" => facade.vault_resolve(params.clone()),
|
||||
"mnote.vault.login" => facade.vault_login(params.clone()),
|
||||
"mnote.vault.session" => facade.vault_session(params.clone()),
|
||||
"mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await,
|
||||
"mnote.tool_receipt.write" => Ok(json!({
|
||||
"requestedReceipt": params,
|
||||
@@ -5286,7 +5593,14 @@ async fn execute_tool(
|
||||
before_file_version.clone(),
|
||||
after_file_version.clone(),
|
||||
);
|
||||
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
|
||||
// Never persist vault secret values into control-plane receipts / journals.
|
||||
let receipt_safe_payload = redact_vault_tool_payload(&tool_name, &payload);
|
||||
let receipt_payload = write_receipt(
|
||||
&facade.state,
|
||||
receipt,
|
||||
&receipt_safe_payload,
|
||||
citation_count,
|
||||
);
|
||||
let elapsed_ms = now_ms().saturating_sub(started) as u64;
|
||||
if let Some(session) = session.as_ref() {
|
||||
let tool_event_id = receipt_payload
|
||||
@@ -5467,6 +5781,17 @@ pub async fn status(
|
||||
let runtime_error = current_session
|
||||
.as_ref()
|
||||
.and_then(|session| session.runtime_error.clone());
|
||||
// S1: status defaults must match effective AI policy (management surface truth),
|
||||
// not only env hardcodes. Frontend checkStatus previously overwrote effective
|
||||
// defaults with these fields.
|
||||
let effective_policy =
|
||||
ai_settings::load_effective_ai_runtime_policy(&state, &actor_id, None);
|
||||
let (status_default_provider, status_default_model_id) = match effective_policy
|
||||
.resolve_requested_model(None, None)
|
||||
{
|
||||
Ok(resolved) => (resolved.provider, resolved.model_id),
|
||||
Err(_) => (default_model_provider(), default_model_id()),
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": PI_LAB_SCHEMA_STATUS,
|
||||
@@ -5480,8 +5805,9 @@ pub async fn status(
|
||||
"runtimeAvailable": runtime_available,
|
||||
"runtimeInstallHint": runtime_install_hint,
|
||||
"runtimeError": runtime_error,
|
||||
"defaultModelProvider": default_model_provider(),
|
||||
"defaultModelId": default_model_id(),
|
||||
"defaultModelProvider": status_default_provider,
|
||||
"defaultModelId": status_default_model_id,
|
||||
"defaultModel": format!("{status_default_provider}/{status_default_model_id}"),
|
||||
"defaultThinkingLevel": default_thinking_level(),
|
||||
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
|
||||
"advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty),
|
||||
@@ -5590,6 +5916,7 @@ pub async fn bootstrap(
|
||||
folder_path: None,
|
||||
context_refs: None,
|
||||
selected_context: None,
|
||||
target_package: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
@@ -5614,6 +5941,9 @@ pub async fn start(
|
||||
let mut requested_session = requested_session;
|
||||
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
|
||||
if let Some(existing_session_id) = request.session_id.as_deref() {
|
||||
if get_session(existing_session_id).is_none() {
|
||||
let _ = hydrate_session_from_control_plane(&state, &actor_id, existing_session_id)?;
|
||||
}
|
||||
if let Some(mut existing_session) = get_session(existing_session_id) {
|
||||
ensure_session_owner(&state, &context, &existing_session)?;
|
||||
if session_runtime_is_usable(&existing_session)
|
||||
@@ -5888,6 +6218,8 @@ pub async fn send(
|
||||
"folderPath": request.folder_path,
|
||||
"contextRefs": request.context_refs,
|
||||
"selectedContext": request.selected_context,
|
||||
// S7: host-built agent target package (optional; client also enforces dirty gate).
|
||||
"targetPackage": request.target_package,
|
||||
},
|
||||
});
|
||||
if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) {
|
||||
@@ -9463,7 +9795,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() {
|
||||
let source = include_str!("page_ai_pi.rs");
|
||||
let source = include_str!("runtime.rs");
|
||||
assert!(!source.contains(".arg(\"--no-prompt-templates\")"));
|
||||
assert!(!source.contains(".arg(\"--no-context-files\")"));
|
||||
}
|
||||
@@ -9836,6 +10168,129 @@ mod tests {
|
||||
assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed");
|
||||
}
|
||||
|
||||
/// S1: admin/user AI policy defaultModel must apply on the next Pi start when
|
||||
/// the client does not pass modelProvider/modelId (management surface is truth).
|
||||
#[tokio::test]
|
||||
async fn start_without_model_uses_effective_default_and_picks_up_policy_change() {
|
||||
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
|
||||
let state = test_state();
|
||||
let actor_id = "pi_default_model_s1_user";
|
||||
let root = temp_root("mnote-pi-default-model-s1-root");
|
||||
let root_uri = grant_directory(&state, actor_id, &root, "write");
|
||||
upsert_ai_policy(
|
||||
&state,
|
||||
actor_id,
|
||||
json!({
|
||||
"defaultModel": "omniroute/pi-fast",
|
||||
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
|
||||
}),
|
||||
);
|
||||
let app = build_app(state.clone());
|
||||
|
||||
let (status_a, payload_a) = request_json(
|
||||
app.clone(),
|
||||
"/api/page-ai/pi/start",
|
||||
actor_id,
|
||||
json!({
|
||||
"sessionId": "pi_lab_s1_default_a",
|
||||
"rootUri": root_uri,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status_a, StatusCode::OK, "start A: {payload_a}");
|
||||
assert_eq!(payload_a["session"]["modelProvider"], "omniroute");
|
||||
assert_eq!(payload_a["session"]["modelId"], "pi-fast");
|
||||
assert_eq!(
|
||||
payload_a["session"]["runtimePolicySnapshot"]["defaultModel"],
|
||||
"omniroute/pi-fast"
|
||||
);
|
||||
|
||||
// Simulate management-surface defaultModel change (same control-plane policy).
|
||||
upsert_ai_policy(
|
||||
&state,
|
||||
actor_id,
|
||||
json!({
|
||||
"defaultModel": "omniroute/pi-reason",
|
||||
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
|
||||
}),
|
||||
);
|
||||
|
||||
let (status_b, payload_b) = request_json(
|
||||
app,
|
||||
"/api/page-ai/pi/start",
|
||||
actor_id,
|
||||
json!({
|
||||
"sessionId": "pi_lab_s1_default_b",
|
||||
"rootUri": root_uri,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status_b, StatusCode::OK, "start B: {payload_b}");
|
||||
assert_eq!(payload_b["session"]["modelProvider"], "omniroute");
|
||||
assert_eq!(
|
||||
payload_b["session"]["modelId"],
|
||||
"pi-reason",
|
||||
"next start without explicit model must pick up new defaultModel"
|
||||
);
|
||||
assert_eq!(
|
||||
payload_b["session"]["runtimePolicySnapshot"]["defaultModel"],
|
||||
"omniroute/pi-reason"
|
||||
);
|
||||
}
|
||||
|
||||
/// S1: /status defaultModel* must mirror effective policy, not only env hardcodes.
|
||||
#[tokio::test]
|
||||
async fn status_default_model_matches_effective_ai_policy() {
|
||||
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
|
||||
// Env hardcode must not win over policy when policy is present.
|
||||
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL", "env-stale-model");
|
||||
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER", "omniroute");
|
||||
let state = test_state();
|
||||
let actor_id = "pi_status_default_s1_user";
|
||||
// Policy FK requires the user row first (grant_directory also upserts user).
|
||||
let _root = grant_directory(
|
||||
&state,
|
||||
actor_id,
|
||||
&temp_root("mnote-pi-status-default-s1-root"),
|
||||
"read",
|
||||
);
|
||||
upsert_ai_policy(
|
||||
&state,
|
||||
actor_id,
|
||||
json!({
|
||||
"defaultModel": "omniroute/pi-reason",
|
||||
"allowedModels": ["omniroute/pi-reason", "omniroute/pi-fast"]
|
||||
}),
|
||||
);
|
||||
let app = build_app(state);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/page-ai/pi/status")
|
||||
.header("x-mnote-actor-id", actor_id)
|
||||
.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 payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["defaultModelProvider"], "omniroute");
|
||||
assert_eq!(
|
||||
payload["defaultModelId"], "pi-reason",
|
||||
"status must not report env-stale default when policy exists: {payload}"
|
||||
);
|
||||
assert_eq!(payload["defaultModel"], "omniroute/pi-reason");
|
||||
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL");
|
||||
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_session_mcp_cache_copies_shared_cache() {
|
||||
let root = temp_root("mnote-pi-shared-mcp-cache-root");
|
||||
@@ -421,6 +421,41 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Map a page-tree row's storage relative path to the directory scope used for lazy expand.
|
||||
/// - Nested bundle `Folder/Folder.md` → `Folder`
|
||||
/// - Sibling page `Folder.md` (+ `Folder/`) → `Folder`
|
||||
/// - Page-group / directory row → itself
|
||||
pub(crate) fn page_tree_expand_relative_path(relative_path: &str) -> String {
|
||||
let rp = relative_path
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.replace('\\', "/");
|
||||
if rp.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let lower = rp.to_ascii_lowercase();
|
||||
if !lower.ends_with(".md") {
|
||||
return rp;
|
||||
}
|
||||
let stem = &rp[..rp.len().saturating_sub(3)];
|
||||
if let Some((parent, file)) = rp.rsplit_once('/') {
|
||||
let file_stem = file
|
||||
.strip_suffix(".md")
|
||||
.or_else(|| file.strip_suffix(".MD"))
|
||||
.or_else(|| file.strip_suffix(".Md"))
|
||||
.unwrap_or(file);
|
||||
let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent);
|
||||
if parent_name == file_stem {
|
||||
// Nested bundle: markdown lives inside a same-named folder.
|
||||
return parent.to_string();
|
||||
}
|
||||
// Sibling page under a parent directory.
|
||||
return stem.to_string();
|
||||
}
|
||||
// Root-level sibling page.
|
||||
stem.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
|
||||
let mut rows: Vec<PageTreeRenderRow> = projection
|
||||
.get("items")
|
||||
@@ -441,6 +476,47 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
if row_kind != "document" {
|
||||
return None;
|
||||
}
|
||||
let expandable = item
|
||||
.get("expandable")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
item.get("childCount")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let relative_path = item
|
||||
.get("expandRelativePath")
|
||||
.or_else(|| item.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item.get("resourceMeta")
|
||||
.and_then(|meta| meta.get("workspacePath"))
|
||||
.and_then(|path| path.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.or_else(|| {
|
||||
item.get("resourceMeta")
|
||||
.and_then(|meta| meta.pointer("/extra/source/relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
let expand_relative_path = if expandable {
|
||||
relative_path
|
||||
.as_deref()
|
||||
.map(page_tree_expand_relative_path)
|
||||
.filter(|value| !value.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(PageTreeRenderRow {
|
||||
node_id: node_id.to_string(),
|
||||
parent_node_id: item
|
||||
@@ -458,15 +534,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
.unwrap_or("无标题")
|
||||
.to_string(),
|
||||
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
|
||||
expandable: item
|
||||
.get("expandable")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
item.get("childCount")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
expandable,
|
||||
expanded: item
|
||||
.get("expandedByDefault")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -480,6 +548,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
|| !node_id.starts_with("local-dir:"),
|
||||
expand_relative_path,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
//! Shared vault path deny predicate for general file surfaces.
|
||||
//!
|
||||
//! Any workspace-relative path under `.mnote/vault` must be rejected on
|
||||
//! open/stat/resource/local_file surfaces. Vault content is only reachable
|
||||
//! via `/api/vault/*`.
|
||||
|
||||
use crate::error::WebError;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
/// Workspace-root-relative path that points at the password vault system space.
|
||||
///
|
||||
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments
|
||||
/// (`..`) are rejected before classification (callers usually already do this).
|
||||
pub fn normalize_workspace_relative_path(rel: &str) -> String {
|
||||
let mut value = rel.trim().replace('\\', "/");
|
||||
while value.starts_with("./") {
|
||||
value = value[2..].to_string();
|
||||
}
|
||||
value = value.trim_start_matches('/').to_string();
|
||||
while value.contains("//") {
|
||||
value = value.replace("//", "/");
|
||||
}
|
||||
if value.ends_with('/') && value != "/" {
|
||||
value.pop();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Returns true when `rel` is `.mnote/vault` or any path under it.
|
||||
pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
|
||||
let n = normalize_workspace_relative_path(rel);
|
||||
if n.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if n
|
||||
.split('/')
|
||||
.any(|segment| segment == ".." || segment.is_empty())
|
||||
{
|
||||
// Escape / empty segments are not vault matches; callers reject escape.
|
||||
return false;
|
||||
}
|
||||
n == ".mnote/vault" || n.starts_with(".mnote/vault/")
|
||||
}
|
||||
|
||||
/// 403 with stable code for general file surfaces that hit vault paths.
|
||||
pub fn vault_path_denied_error(message: impl Into<String>) -> WebError {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"vault_path_denied",
|
||||
message.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject vault-relative paths before reading or writing general local files.
|
||||
pub fn deny_if_vault_sensitive_relative_path(rel: &str) -> Result<(), WebError> {
|
||||
if is_vault_sensitive_relative_path(rel) {
|
||||
return Err(vault_path_denied_error(
|
||||
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_vault_root_and_children() {
|
||||
assert!(is_vault_sensitive_relative_path(".mnote/vault"));
|
||||
assert!(is_vault_sensitive_relative_path(".mnote/vault/"));
|
||||
assert!(is_vault_sensitive_relative_path(".mnote/vault/entries/x.md"));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
"./.mnote/vault/entries/x.md"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote\\vault\\entries\\x.md"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/trash/entries/x.md"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_non_vault_paths() {
|
||||
assert!(!is_vault_sensitive_relative_path(""));
|
||||
assert!(!is_vault_sensitive_relative_path("notes/a.md"));
|
||||
assert!(!is_vault_sensitive_relative_path(".mnote/trash/a.md"));
|
||||
assert!(!is_vault_sensitive_relative_path(".mnote/index/search-index.json"));
|
||||
assert!(!is_vault_sensitive_relative_path("mnote/vault/x.md"));
|
||||
assert!(!is_vault_sensitive_relative_path(".mnote/vault-backup/x.md"));
|
||||
assert!(!is_vault_sensitive_relative_path("个人/密码/a.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_helper_returns_stable_code() {
|
||||
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/entries/x.md")
|
||||
.expect_err("must deny");
|
||||
assert_eq!(err.code(), "vault_path_denied");
|
||||
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
||||
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,9 @@ use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot_with_reveal,
|
||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
|
||||
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
|
||||
load_local_folder_file_tree_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_scope_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
@@ -21,12 +22,12 @@ use crate::routes::snapshot_support::{
|
||||
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
|
||||
use crate::ssr::pages::document::DocumentPage;
|
||||
use crate::tree_shell::filetree_renderer::{
|
||||
render_initial_filetree_html, FileTreeInitialRenderInput,
|
||||
render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput,
|
||||
};
|
||||
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
|
||||
use crate::workspace_shell::{
|
||||
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
WorkspaceShellProjection,
|
||||
apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html,
|
||||
render_workspace_shell_sidebar_html, WorkspaceShellProjection,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
@@ -261,6 +262,7 @@ pub async fn document_page_shell(
|
||||
file_tree_scope,
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id));
|
||||
let page_subtree_json =
|
||||
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
|
||||
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
||||
@@ -300,6 +302,10 @@ pub async fn document_page_shell(
|
||||
);
|
||||
let pi_lab_loader_script =
|
||||
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
|
||||
let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context(
|
||||
primary_source_kind,
|
||||
primary_root_uri,
|
||||
);
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -308,6 +314,7 @@ pub async fn document_page_shell(
|
||||
sidebar_tree_html={sidebar_tree_html}
|
||||
workspace_name={workspace_name}
|
||||
workspace_sidebar_html={workspace_sidebar_html}
|
||||
breadcrumb_html={breadcrumb_html}
|
||||
page_subtree_json={page_subtree_json}
|
||||
page_options_json={page_options_json}
|
||||
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
|
||||
@@ -318,6 +325,7 @@ pub async fn document_page_shell(
|
||||
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
|
||||
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
|
||||
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
||||
vault_nav_href={vault_nav_href}
|
||||
/>
|
||||
});
|
||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||
@@ -766,6 +774,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
let saving = false;
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
|
||||
input.value = readLastSavedTitle();
|
||||
autosize(input);
|
||||
setStatus(input, 'saved');
|
||||
return;
|
||||
}
|
||||
const title = input.value.trim() || '无标题';
|
||||
const currentTarget = resolveTitleTarget(input);
|
||||
autosize(input);
|
||||
@@ -820,6 +834,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
|
||||
input.value = readLastSavedTitle();
|
||||
autosize(input);
|
||||
setStatus(input, 'saved');
|
||||
return;
|
||||
}
|
||||
autosize(input);
|
||||
setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty');
|
||||
});
|
||||
@@ -3103,6 +3123,20 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn vault_workbench_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/vault-workbench-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn leptos_tiptap_manifest() -> Response {
|
||||
let manifest = json!({
|
||||
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
|
||||
@@ -3603,9 +3637,13 @@ pub(crate) fn render_local_sidebar_tree_html_scoped(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
|
||||
load_local_folder_page_tree_scope_snapshot_with_reveal(
|
||||
root_uri,
|
||||
scope,
|
||||
active_document_id,
|
||||
)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
||||
};
|
||||
Ok(render_local_sidebar_tree_html_from_snapshot(
|
||||
&snapshot,
|
||||
@@ -3633,6 +3671,12 @@ pub(crate) fn render_local_file_tree_html(
|
||||
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
|
||||
}
|
||||
|
||||
/// Fast path for home SSR: keep FileTree shell structure without scanning the folder.
|
||||
/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`.
|
||||
pub(crate) fn render_local_file_tree_pending_shell_html() -> String {
|
||||
render_filetree_pending_shell_html()
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_file_tree_html_scoped(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
@@ -5320,8 +5364,10 @@ mod tests {
|
||||
));
|
||||
assert!(session_runtime.contains("new EventSource(url.toString())"));
|
||||
assert!(session_runtime.contains("localFolderEventRegistry"));
|
||||
assert!(session_runtime
|
||||
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
||||
assert!(session_runtime.contains("if (!response.ok) {"));
|
||||
assert!(session_runtime.contains(
|
||||
"if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))"
|
||||
));
|
||||
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
|
||||
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
|
||||
assert!(session_runtime.contains("targetSession.views.size === 0"));
|
||||
@@ -5707,7 +5753,8 @@ mod tests {
|
||||
assert!(runtime.contains("legacyInlineContentToTiptap"));
|
||||
assert!(runtime.contains("legacyStylesToTiptapMarks"));
|
||||
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
|
||||
assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
|
||||
assert!(runtime.contains("const sourcePath = firstNonEmptyText("));
|
||||
assert!(runtime.contains("block?.props?.sourcePath"));
|
||||
assert!(runtime.contains("marks.push({ type: 'bold' })"));
|
||||
assert!(runtime.contains("marks.push({ type: 'italic' })"));
|
||||
assert!(runtime.contains("marks.push({ type: 'underline' })"));
|
||||
|
||||
Reference in New Issue
Block a user