feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -5,7 +5,8 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
||||
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
|
||||
write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
@@ -46,6 +47,10 @@ pub struct DocumentSaveRequest {
|
||||
pub root_uri: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub expected_file_version: Option<String>,
|
||||
pub base_content_hash: Option<String>,
|
||||
pub content_format: Option<String>,
|
||||
pub editor_source: Option<String>,
|
||||
pub editor_document: Option<Value>,
|
||||
pub content: Value,
|
||||
pub tiptap_document: Option<Value>,
|
||||
@@ -408,6 +413,7 @@ async fn proxy_next_documents_save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -514,6 +520,38 @@ pub async fn content(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<core_protocol::PageBodyWriteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_body_write_source_unsupported",
|
||||
"page.body.write 当前只支持 local_folder 本地写入",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_uri = body.root_uri.trim();
|
||||
if root_uri.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = write_local_markdown_page_body(&body)?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -536,12 +574,29 @@ pub async fn save(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let result = save_local_markdown_page(
|
||||
root_uri,
|
||||
document_id,
|
||||
body.conflict_detection_key.as_deref(),
|
||||
&body.content,
|
||||
)?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let expected_file_version = body
|
||||
.expected_file_version
|
||||
.as_deref()
|
||||
.or(body.conflict_detection_key.as_deref());
|
||||
let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: body.workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "editorBlocks".into()),
|
||||
content: body.content.clone(),
|
||||
editor_source: body
|
||||
.editor_source
|
||||
.clone()
|
||||
.or_else(|| Some("documents/save-compat".into())),
|
||||
})?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
@@ -578,6 +633,7 @@ pub async fn save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -766,6 +822,8 @@ pub async fn title(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_markdown_title(root_uri, document_id, title)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
@@ -859,6 +917,8 @@ pub async fn options(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
@@ -1311,6 +1371,11 @@ mod tests {
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_test",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:local-stable";
|
||||
|
||||
let title_response = app()
|
||||
@@ -1319,6 +1384,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/title")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1340,6 +1407,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1367,6 +1436,15 @@ mod tests {
|
||||
.await
|
||||
.expect("save response");
|
||||
assert_eq!(save_response.status(), StatusCode::OK);
|
||||
let save_body = to_bytes(save_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("save body");
|
||||
let save_payload: Value = serde_json::from_slice(&save_body).expect("save json");
|
||||
assert_eq!(
|
||||
save_payload["result"]["canonicalCommand"],
|
||||
"page.body.write"
|
||||
);
|
||||
assert_eq!(save_payload["result"]["compatCommand"], "page.body.save");
|
||||
|
||||
let options_response = app()
|
||||
.oneshot(
|
||||
@@ -1374,6 +1452,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/options")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1406,4 +1486,82 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_documents_save_rejects_stale_expected_file_version() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-documents-expected-file-version-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_test",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:expected-file-version";
|
||||
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
|
||||
&root_uri,
|
||||
document_id,
|
||||
)
|
||||
.expect("aggregate");
|
||||
let stale_file_version = aggregate
|
||||
.body
|
||||
.conflict_detection_key
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.to_string();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n",
|
||||
)
|
||||
.expect("external write");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"expectedFileVersion": stale_file_version,
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "Editor" }]
|
||||
}
|
||||
],
|
||||
"blockCount": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("save response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("# External"));
|
||||
assert!(!markdown.contains("# Editor"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
@@ -153,6 +156,53 @@ pub async fn auth_entry(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn admin_access_policy_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
if !is_local_access_policy_admin_context(&context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_admin_required",
|
||||
"只有管理员可以访问目录授权页面",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let policy_path = local_access_policy_path_display();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::admin::AdminAccessPolicyPage workspace_name={workspace_name} policy_path={policy_path} />
|
||||
});
|
||||
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="admin" data-mnote-actor-id="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn root_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -178,6 +228,20 @@ pub async fn root_entry(
|
||||
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
||||
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let should_render_local_first_landing = !is_local_folder
|
||||
&& query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
&& query
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
&& requested_page_id.is_none();
|
||||
let (
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
@@ -195,6 +259,7 @@ pub async fn root_entry(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)?;
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
@@ -239,6 +304,26 @@ pub async fn root_entry(
|
||||
Some("local_folder".to_string()),
|
||||
Some(root_uri.to_string()),
|
||||
)
|
||||
} else if should_render_local_first_landing {
|
||||
let workspace_id = "local-first-entry".to_string();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
);
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
@@ -303,6 +388,7 @@ 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 render_workspace_entry = || {
|
||||
crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage
|
||||
@@ -312,6 +398,7 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
active_page_id={active_page_id.clone()}
|
||||
active_page_title={active_page_title.clone()}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
})
|
||||
};
|
||||
@@ -360,6 +447,7 @@ pub async fn root_entry(
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
page_subtree_json={page_subtree_json}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
});
|
||||
let body_extra = format!(
|
||||
@@ -387,13 +475,14 @@ pub async fn root_entry(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&html_title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content,
|
||||
body_extra
|
||||
))
|
||||
@@ -1769,6 +1858,111 @@ mod tests {
|
||||
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
||||
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_shows_admin_access_policy_entry_only_for_admin_actor() {
|
||||
let admin_response =
|
||||
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "admin_real")
|
||||
.header("x-mnote-actor-type", "admin")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("admin response");
|
||||
|
||||
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||
let admin_body = to_bytes(admin_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
|
||||
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
|
||||
let user_response =
|
||||
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("user response");
|
||||
|
||||
assert_eq!(user_response.status(), StatusCode::OK);
|
||||
let user_body = to_bytes(user_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_access_policy_entry_requires_admin_actor() {
|
||||
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin/access-policy")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("user response");
|
||||
assert_eq!(user_response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let admin_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin/access-policy")
|
||||
.header("x-mnote-actor-id", "admin_real")
|
||||
.header("x-mnote-actor-type", "admin")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("admin response");
|
||||
|
||||
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(admin_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
||||
let root =
|
||||
@@ -1780,6 +1974,11 @@ mod tests {
|
||||
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -170,6 +170,31 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) {
|
||||
let error = WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_shared_read_write_forbidden",
|
||||
"共享只读 AI 上下文不能执行写工具",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message(),
|
||||
"permissionLevel": "shared_read"
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -301,6 +326,27 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
|
||||
let direct = input
|
||||
.arg_string("permissionLevel")
|
||||
.or_else(|| input.arg_string("permission_level"));
|
||||
if matches!(direct.as_deref(), Some("shared_read")) {
|
||||
return true;
|
||||
}
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("permissionLevel")
|
||||
.or_else(|| value.get("permission_level"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.as_deref()
|
||||
== Some("shared_read")
|
||||
}
|
||||
|
||||
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
||||
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
||||
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
@@ -882,6 +928,47 @@ mod tests {
|
||||
.any(|rule| rule["required"] == json!(["full_content"])));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/hermes/tools/mnote/manifest")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.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");
|
||||
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
||||
let markdown_edit = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.doc.markdown_edit")
|
||||
.expect("markdown edit tool");
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
.expect("page save tool");
|
||||
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容"));
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff"));
|
||||
assert_eq!(
|
||||
page_save["annotations"]["requiresWritePermission"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_describes_block_tools_selection_scope() {
|
||||
let response = app()
|
||||
@@ -1129,6 +1216,8 @@ mod tests {
|
||||
assert_eq!(payload["toolName"], "mnote.doc.fetch");
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
assert_eq!(payload["result"]["revision"], json!(7));
|
||||
assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7"));
|
||||
assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7"));
|
||||
assert_eq!(
|
||||
payload["result"]["blocks"][0]["blockId"],
|
||||
json!("heading_1")
|
||||
@@ -1140,6 +1229,90 @@ mod tests {
|
||||
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_2",
|
||||
"sessionId": "sess_scope_read",
|
||||
"runId": "run_scope_read",
|
||||
"toolCallId": "call_scope_read",
|
||||
"traceId": "trace_scope_read",
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["doc_1"],
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_ai_scope_read_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_2",
|
||||
"sessionId": "sess_page_scope_read",
|
||||
"runId": "run_page_scope_read",
|
||||
"toolCallId": "call_page_scope_read",
|
||||
"traceId": "trace_page_scope_read",
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["doc_1"],
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_ai_scope_read_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
|
||||
let response = app()
|
||||
@@ -2116,6 +2289,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-page-save-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.save",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_page_save_local",
|
||||
"runId": "run_page_save_local",
|
||||
"toolCallId": "call_page_save_local",
|
||||
"traceId": "trace_page_save_local",
|
||||
"idempotencyKey": "idem_page_save_local",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
|
||||
]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(payload["result"]["commandName"], "page.body.write");
|
||||
let saved = fs::read_to_string(root.join("README.md")).expect("read");
|
||||
assert!(saved.contains("本地 page.save 写入"), "{saved}");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
||||
let response = app()
|
||||
@@ -2264,6 +2501,74 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-markdown-edit-shared-read-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_shared_read_md",
|
||||
"runId": "run_shared_read_md",
|
||||
"toolCallId": "call_shared_read_md",
|
||||
"traceId": "trace_shared_read_md",
|
||||
"idempotencyKey": "idem_shared_read_md",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
},
|
||||
"operations": [{"search": "原文", "replace": "不应写入"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_shared_read_write_forbidden")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("README.md")).expect("read"),
|
||||
"原文\n"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() {
|
||||
// 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。
|
||||
@@ -2406,6 +2711,165 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_markdown_precondition",
|
||||
"runId": "run_markdown_precondition",
|
||||
"toolCallId": "call_markdown_precondition",
|
||||
"traceId": "trace_markdown_precondition",
|
||||
"idempotencyKey": "idem_markdown_precondition",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "第二段", "replace": "测试123"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.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");
|
||||
let command_payload =
|
||||
&payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"];
|
||||
assert_eq!(command_payload["revision"], 7);
|
||||
assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-markdown-edit-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("README.assets")).expect("create asset dir");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n\n",
|
||||
)
|
||||
.expect("write markdown");
|
||||
fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("initialize workspace");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "local-ws-test",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_local_folder_md",
|
||||
"runId": "run_local_folder_md",
|
||||
"toolCallId": "call_local_folder_md",
|
||||
"traceId": "trace_local_folder_md",
|
||||
"idempotencyKey": "idem_local_folder_md",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
if status != StatusCode::OK {
|
||||
panic!("status={status} headers={headers:?} body={text}");
|
||||
}
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(
|
||||
payload["result"]["applyResult"]["commandName"],
|
||||
"page.body.write"
|
||||
);
|
||||
let saved = fs::read_to_string(root.join("README.md")).expect("read markdown");
|
||||
assert!(saved.contains("第一段已由 AI 修改"));
|
||||
assert!(saved.contains("README.assets/photo.png"));
|
||||
assert!(!saved.contains("/api/media"));
|
||||
assert!(!saved.contains("assetId"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_rejects_no_applied_operations() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_markdown_noop",
|
||||
"runId": "run_markdown_noop",
|
||||
"toolCallId": "call_markdown_noop",
|
||||
"traceId": "trace_markdown_noop",
|
||||
"idempotencyKey": "idem_markdown_noop",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_markdown_edit_no_operations_applied")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() {
|
||||
let response = app()
|
||||
@@ -2576,4 +3040,67 @@ mod tests {
|
||||
assert_eq!(payload["result"]["dryRun"], true);
|
||||
assert_eq!(payload["result"]["artifactType"], "summary");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-artifact-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Local\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.artifact.create_summary",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_artifact_local",
|
||||
"runId": "run_artifact_local",
|
||||
"toolCallId": "call_artifact_local",
|
||||
"traceId": "trace_artifact_local",
|
||||
"idempotencyKey": "idem_artifact_local",
|
||||
"dryRun": false,
|
||||
"args": {"summary": "本地摘要"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
let artifact_path = root
|
||||
.join(".mnote")
|
||||
.join("artifacts")
|
||||
.join("summary_local-md_README.md.json");
|
||||
let artifact = fs::read_to_string(&artifact_path).expect("artifact");
|
||||
assert!(artifact.contains("本地摘要"), "{artifact}");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::decode_local_id_segment;
|
||||
use crate::routes::local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
@@ -9,7 +11,6 @@ use futures_util::stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
@@ -31,14 +32,8 @@ pub async fn local_folder_events(
|
||||
),
|
||||
WebError,
|
||||
> {
|
||||
let root = parse_file_root_uri(&query.root_uri)?;
|
||||
let canonical_root = root.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
@@ -105,17 +100,6 @@ pub async fn local_folder_events(
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_file_root_uri(root_uri: &str) -> Result<PathBuf, WebError> {
|
||||
let trimmed = root_uri.trim();
|
||||
let Some(path) = trimmed.strip_prefix("file://") else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_invalid",
|
||||
"本地文件夹 rootUri 必须是 file:// URI",
|
||||
));
|
||||
};
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
let trimmed = document_id.trim();
|
||||
let encoded = trimmed.strip_prefix("local-md:")?;
|
||||
@@ -156,10 +140,4 @@ mod tests {
|
||||
Some("docs/README.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_file_root_uri_requires_file_scheme() {
|
||||
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
|
||||
assert!(parse_file_root_uri("/tmp/example").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,11 @@ mod tree;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title,
|
||||
update_local_page_options, write_local_markdown_page_body,
|
||||
};
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
use axum::Router;
|
||||
@@ -42,6 +47,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/", get(gateway::root_entry))
|
||||
.route("/trash", get(gateway::trash_entry))
|
||||
.route("/favicon.ico", get(gateway::favicon))
|
||||
.route(
|
||||
"/admin/access-policy",
|
||||
get(gateway::admin_access_policy_entry),
|
||||
)
|
||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||
.route("/search", get(search::shell))
|
||||
.route(
|
||||
@@ -88,6 +97,22 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/validate-root",
|
||||
post(local_folder_source::validate_local_access_root),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/grants",
|
||||
post(local_folder_source::create_local_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_local_access_grant),
|
||||
)
|
||||
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
||||
.route(
|
||||
"/api/page-ai/block-edit-workflow",
|
||||
@@ -134,6 +159,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
.route("/api/documents/options", post(documents::options))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
.route("/api/page-body/write", post(documents::page_body_write))
|
||||
.route(
|
||||
"/api/documents/runtime/transform",
|
||||
post(editor::transform_runtime_snapshot),
|
||||
@@ -160,6 +186,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/local-folder/events",
|
||||
get(local_folder_events::local_folder_events),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/workspaces/default",
|
||||
post(local_folder_source::create_default_local_workspace),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/assets/upload",
|
||||
post(local_folder_source::upload_local_markdown_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/files/open",
|
||||
get(local_folder_source::open_local_file),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/runtime/reduce",
|
||||
post(tree::reduce_tree_shell_runtime),
|
||||
|
||||
@@ -67,7 +67,8 @@ pub async fn block_edit_workflow(
|
||||
// 退役 direct_block_edit_operations:不再走正则抠「」的本地快路径。
|
||||
// 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。
|
||||
let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
|
||||
let markdown_operations = extract_markdown_operations_from_model_text(&model_output)?;
|
||||
let markdown_plan = extract_markdown_plan_from_model_text(&model_output)?;
|
||||
let markdown_operations = markdown_plan.operations.clone();
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
run_id = %run_id,
|
||||
@@ -116,6 +117,8 @@ pub async fn block_edit_workflow(
|
||||
tool_name: "mnote.doc.markdown_edit".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
document_id: Some(document_id.clone()),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
actor_id: Some(actor_id),
|
||||
profile: Some(profile),
|
||||
session_id: Some(session_id),
|
||||
@@ -153,7 +156,9 @@ pub async fn block_edit_workflow(
|
||||
"operations": markdown_operations,
|
||||
"applyResult": apply_result,
|
||||
"toolExecution": tool_response,
|
||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||
"message": markdown_plan
|
||||
.summary
|
||||
.unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()),
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
"apply": apply_ms
|
||||
@@ -162,21 +167,35 @@ pub async fn block_edit_workflow(
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
|
||||
struct MarkdownEditPlan {
|
||||
operations: Vec<Value>,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
|
||||
let parsed = parse_model_json(text)?;
|
||||
if let Some(content) = parsed
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return extract_markdown_operations_from_model_text(content);
|
||||
return extract_markdown_plan_from_model_text(content);
|
||||
}
|
||||
let summary = parsed
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
|
||||
// 新格式:直接是 search/replace 对
|
||||
if operations
|
||||
.iter()
|
||||
.any(|op| op.get("search").is_some() || op.get("replace").is_some())
|
||||
{
|
||||
return Ok(operations.clone());
|
||||
return Ok(MarkdownEditPlan {
|
||||
operations: operations.clone(),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
// 旧格式(block ops):转换为 search/replace 对
|
||||
let converted: Vec<Value> = operations
|
||||
@@ -204,7 +223,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
|
||||
})
|
||||
.collect();
|
||||
if !converted.is_empty() {
|
||||
return Ok(converted);
|
||||
return Ok(MarkdownEditPlan {
|
||||
operations: converted,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
@@ -330,7 +352,7 @@ async fn call_block_edit_model(
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。禁止输出解释文字。\n\n示例:用户说\"把第一段改成你好\",若 page_text 第一段是\"旧内容\",则输出:{\"operations\":[{\"search\":\"旧内容\",\"replace\":\"你好\"}],\"summary\":\"替换了第一段\"}"
|
||||
"content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。summary 要简短回答用户的读取/检查要求和写入结果;如果用户要求读取某段,summary 必须包含你从 page_text 读取到的原文。禁止输出解释文字。\n\n示例:用户说\"检查第一段并把第二段改成测试123\",若 page_text 第一段是\"第一段\",则输出:{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
@@ -684,6 +706,28 @@ mod tests {
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_read_and_edit_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind read-and-edit mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_operations_from_fenced_model_json() {
|
||||
let operations = extract_operations_from_model_text(
|
||||
@@ -846,4 +890,78 @@ mod tests {
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_read_and_edit_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-read-summary-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "检查你是否能读取到本页第一段,同时请修改第二段为:测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_read_summary",
|
||||
"runId": "run_page_ai_read_summary",
|
||||
"traceId": "trace_page_ai_read_summary",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.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!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段"));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123"));
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
execute_local_tree_command, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri,
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access, execute_local_tree_command,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
@@ -6122,6 +6122,8 @@ pub async fn local_folder_watch(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderWatchQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&query.root_uri)?;
|
||||
Ok(json_response(
|
||||
&context,
|
||||
@@ -6218,6 +6220,8 @@ pub async fn tree_shell(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&effective_context, root_uri)
|
||||
.map_err(|error| error.with_context(&effective_context))?;
|
||||
(
|
||||
local_workspace_id_from_root_uri(root_uri)?,
|
||||
if mode == "filetree" {
|
||||
@@ -6839,6 +6843,8 @@ pub async fn tree_command(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let execution = execute_local_tree_command(
|
||||
root_uri,
|
||||
action,
|
||||
@@ -6959,7 +6965,7 @@ mod tests {
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -6984,6 +6990,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7196,12 +7226,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7236,12 +7269,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7277,12 +7313,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7311,6 +7350,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let create_response = app()
|
||||
.oneshot(
|
||||
@@ -7498,6 +7538,7 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let asset_id = "local:asset:docs/photo.png";
|
||||
|
||||
let delete_response = app()
|
||||
@@ -7608,6 +7649,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -7653,6 +7695,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_rejects_non_owner_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-tree-owner-denied-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
init_local_workspace(&root, "owner_user");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "other_user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("error json");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "local_workspace_access_denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_shell_embeds_renderer_input_contract() {
|
||||
let filetree_response = app()
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::routes::documents::{
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
@@ -200,6 +201,7 @@ pub async fn document_page_shell(
|
||||
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
|
||||
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
|
||||
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
|
||||
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
||||
/>
|
||||
});
|
||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||
@@ -327,21 +329,27 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
page_aggregate_script_id: &str,
|
||||
pane_role: &str,
|
||||
) -> String {
|
||||
let normalized_source_kind = source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace");
|
||||
let save_endpoint = if normalized_source_kind == "local_folder" {
|
||||
"/api/page-body/write"
|
||||
} else {
|
||||
"/api/documents/save"
|
||||
};
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"paneRole": pane_role,
|
||||
"sourceKind": source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace"),
|
||||
"sourceKind": normalized_source_kind,
|
||||
"rootUri": root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(""),
|
||||
"pageAggregateScriptId": page_aggregate_script_id,
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"saveEndpoint": save_endpoint,
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
"editorHostKind": "leptos_tiptap_island",
|
||||
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||
@@ -1023,20 +1031,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
if (!text) return [];
|
||||
const styles = {};
|
||||
const marks = [];
|
||||
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
|
||||
if (mark?.type === 'bold') styles.bold = true;
|
||||
if (mark?.type === 'italic') styles.italic = true;
|
||||
if (mark?.type === 'underline') styles.underline = true;
|
||||
if (mark?.type === 'strike') styles.strike = true;
|
||||
if (mark?.type === 'code') styles.code = true;
|
||||
if (mark?.type === 'bold') {
|
||||
styles.bold = true;
|
||||
marks.push('bold');
|
||||
}
|
||||
if (mark?.type === 'italic') {
|
||||
styles.italic = true;
|
||||
marks.push('italic');
|
||||
}
|
||||
if (mark?.type === 'underline') {
|
||||
styles.underline = true;
|
||||
marks.push('underline');
|
||||
}
|
||||
if (mark?.type === 'strike') {
|
||||
styles.strike = true;
|
||||
marks.push('strike');
|
||||
}
|
||||
if (mark?.type === 'code') {
|
||||
styles.code = true;
|
||||
marks.push('code');
|
||||
}
|
||||
if (mark?.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
|
||||
return [{
|
||||
payload: { type: 'text', text, ...(marks.length ? { marks } : {}) },
|
||||
attrs: Object.keys(styles).length ? { styles } : {},
|
||||
type: 'text',
|
||||
text,
|
||||
...(Object.keys(styles).length ? { styles } : {}),
|
||||
}];
|
||||
}
|
||||
if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }];
|
||||
if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }];
|
||||
return inlineTextNodes(child);
|
||||
});
|
||||
}
|
||||
@@ -1133,9 +1163,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
const payload = node.payload && typeof node.payload === 'object' ? node.payload : {};
|
||||
const text = typeof payload.text === 'string'
|
||||
? payload.text
|
||||
: payload.type === 'hard_break'
|
||||
? '\n'
|
||||
: typeof node.text === 'string'
|
||||
? node.text
|
||||
: '';
|
||||
if (!text) return null;
|
||||
return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) };
|
||||
const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
|
||||
const styles = attrs.styles && typeof attrs.styles === 'object'
|
||||
? attrs.styles
|
||||
: node.styles && typeof node.styles === 'object'
|
||||
? node.styles
|
||||
: null;
|
||||
const marks = Array.isArray(payload.marks) ? payload.marks : [];
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
...(styles ? { styles } : {}),
|
||||
...(marks.length ? { marks } : {}),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: '',
|
||||
}));
|
||||
@@ -1144,7 +1193,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? body.conflictDetectionKey
|
||||
: typeof body?.conflict_detection_key === 'string'
|
||||
? body.conflict_detection_key
|
||||
: null;
|
||||
: typeof body?.fileVersion === 'string'
|
||||
? body.fileVersion
|
||||
: typeof body?.file_version === 'string'
|
||||
? body.file_version
|
||||
: null;
|
||||
|
||||
const revisionFromConflictKey = (value) => {
|
||||
const match = String(value || '').match(/:(\d+)$/);
|
||||
@@ -1206,7 +1259,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind: descriptor.sourceKind || 'convex_workspace',
|
||||
rootUri: descriptor.rootUri || '',
|
||||
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__',
|
||||
saveEndpoint: '/api/documents/save',
|
||||
saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder'
|
||||
? '/api/page-body/write'
|
||||
: '/api/documents/save',
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
@@ -1469,7 +1524,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
};
|
||||
|
||||
const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim();
|
||||
const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const sessionPlainText = (session) => {
|
||||
const liveText = sessionViews(session)
|
||||
.map((view) => normalizePlainText(currentEditorText(view)))
|
||||
.find((text) => text);
|
||||
if (liveText) return liveText;
|
||||
return normalizePlainText(flattenText(session.currentTiptapDocument));
|
||||
};
|
||||
|
||||
const sessionHasRecentExternalSignal = (session) => (
|
||||
session.sourceKind === 'local_folder'
|
||||
@@ -1484,6 +1547,196 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
&& (Date.now() - session.lastUserInputAt) < 1500
|
||||
);
|
||||
|
||||
const fetchLatestSessionAggregate = async (session) => {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
sourceKind: session.sourceKind,
|
||||
workspaceId: session.workspaceId,
|
||||
rootUri: session.rootUri,
|
||||
}).toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status);
|
||||
const payload = await response.json();
|
||||
const nextAggregate = payload?.result;
|
||||
if (!nextAggregate || typeof nextAggregate !== 'object') {
|
||||
throw new Error('conflict_latest_missing_aggregate');
|
||||
}
|
||||
return nextAggregate;
|
||||
};
|
||||
|
||||
const aggregatePlainText = (aggregate) => {
|
||||
const body = aggregate?.body || {};
|
||||
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove());
|
||||
});
|
||||
};
|
||||
|
||||
const applyAggregateSnapshotToSession = (session, nextAggregate, source) => {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
session.revision = nextRevision;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
session.readOnly = Boolean(nextPermissions.readOnly);
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.lastUserInputAt = 0;
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
};
|
||||
|
||||
const openConflictDiffPanel = async (session, panel) => {
|
||||
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
if (!(diffPanel instanceof HTMLElement)) return;
|
||||
diffPanel.hidden = false;
|
||||
diffPanel.replaceChildren();
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'mnote-conflict-diff-status';
|
||||
loading.textContent = '正在读取磁盘版本...';
|
||||
diffPanel.appendChild(loading);
|
||||
try {
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
diffPanel.replaceChildren();
|
||||
const current = document.createElement('pre');
|
||||
current.setAttribute('data-testid', 'mnote-conflict-current-text');
|
||||
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
|
||||
const disk = document.createElement('pre');
|
||||
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
|
||||
disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)';
|
||||
const currentTitle = document.createElement('h3');
|
||||
currentTitle.textContent = '当前编辑器版本';
|
||||
const diskTitle = document.createElement('h3');
|
||||
diskTitle.textContent = '磁盘版本';
|
||||
const currentBox = document.createElement('section');
|
||||
currentBox.append(currentTitle, current);
|
||||
const diskBox = document.createElement('section');
|
||||
diskBox.append(diskTitle, disk);
|
||||
diffPanel.append(currentBox, diskBox);
|
||||
} catch (error) {
|
||||
loading.textContent = error instanceof Error ? error.message : String(error);
|
||||
diffPanel.replaceChildren(loading);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptDiskVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
|
||||
};
|
||||
|
||||
const keepCurrentEditorVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
const liveText = normalizePlainText(currentEditorText(hydrateView));
|
||||
if (liveText) {
|
||||
session.currentTiptapDocument = hydrateMindmapAttrsFromDom(
|
||||
textToTiptapDocument(liveText),
|
||||
hydrateView.runtimeDescriptor.root,
|
||||
);
|
||||
}
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
}
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.saving = false;
|
||||
session.dirty = true;
|
||||
clearSessionConflictSurface(session);
|
||||
await persistSession(session);
|
||||
};
|
||||
|
||||
const renderSessionConflictSurface = (session, message) => {
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'mnote-editor-conflict-panel';
|
||||
panel.setAttribute('data-testid', 'mnote-editor-conflict-panel');
|
||||
panel.setAttribute('role', 'status');
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = '文件冲突';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message || externalConflictMessage;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'mnote-conflict-meta';
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'mnote-conflict-actions';
|
||||
const acceptDisk = document.createElement('button');
|
||||
acceptDisk.type = 'button';
|
||||
acceptDisk.textContent = '接受磁盘版本';
|
||||
acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk');
|
||||
const keepCurrent = document.createElement('button');
|
||||
keepCurrent.type = 'button';
|
||||
keepCurrent.textContent = '保留当前编辑器版本';
|
||||
keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current');
|
||||
const openDiff = document.createElement('button');
|
||||
openDiff.type = 'button';
|
||||
openDiff.textContent = '打开 diff';
|
||||
openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff');
|
||||
actions.append(acceptDisk, keepCurrent, openDiff);
|
||||
const diffPanel = document.createElement('div');
|
||||
diffPanel.className = 'mnote-conflict-diff-panel';
|
||||
diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel');
|
||||
diffPanel.hidden = true;
|
||||
panel.append(heading, text, meta, actions, diffPanel);
|
||||
|
||||
acceptDisk.addEventListener('click', () => {
|
||||
acceptDiskVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
keepCurrent.addEventListener('click', () => {
|
||||
keepCurrentEditorVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
openDiff.addEventListener('click', () => {
|
||||
openConflictDiffPanel(session, panel);
|
||||
});
|
||||
|
||||
const header = host.querySelector('.document-shell-header');
|
||||
if (header && header.parentNode) {
|
||||
header.parentNode.insertBefore(panel, header.nextSibling);
|
||||
} else {
|
||||
host.prepend(panel);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
session.hasExternalConflict = true;
|
||||
@@ -1492,6 +1745,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
||||
renderSessionConflictSurface(session, message || externalConflictMessage);
|
||||
};
|
||||
|
||||
const queueSessionSave = (session) => {
|
||||
@@ -1521,21 +1775,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
try {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const response = await fetch(session.saveEndpoint || '/api/documents/save', {
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const savePayload = {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
};
|
||||
if (session.sourceKind !== 'local_folder') {
|
||||
savePayload.conflictDetectionKey = session.conflictDetectionKey;
|
||||
}
|
||||
const response = await fetch(saveEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
conflictDetectionKey: session.conflictDetectionKey,
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
}),
|
||||
body: JSON.stringify(savePayload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result || result.ok !== true) {
|
||||
@@ -1550,6 +1811,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
|
||||
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
|
||||
}
|
||||
if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) {
|
||||
session.conflictDetectionKey = saved.fileVersion.trim();
|
||||
}
|
||||
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
@@ -1912,6 +2176,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
|
||||
conflictDetectionKey,
|
||||
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
||||
readOnly: Boolean(permissions.readOnly),
|
||||
dirty: false,
|
||||
@@ -2633,6 +2898,8 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
}
|
||||
|
||||
@@ -2937,7 +3204,7 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2970,6 +3237,19 @@ mod tests {
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
@@ -2982,6 +3262,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
|
||||
@@ -3137,6 +3441,18 @@ mod tests {
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["projectionSource"],
|
||||
"editorDocument"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
|
||||
"editor_1"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
|
||||
"来自 editorDocument 的正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3246,12 +3562,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3297,12 +3616,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3325,6 +3647,7 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
@@ -3349,6 +3672,10 @@ mod tests {
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(html.contains("mnote-editor-conflict-panel"));
|
||||
assert!(html.contains("mnote-conflict-accept-disk"));
|
||||
assert!(html.contains("mnote-conflict-keep-current"));
|
||||
assert!(html.contains("mnote-conflict-open-diff"));
|
||||
assert!(!html.contains(
|
||||
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
||||
));
|
||||
@@ -3420,12 +3747,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3495,6 +3825,11 @@ mod tests {
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(html.contains("payload: { type: 'text', text"));
|
||||
assert!(html.contains("typeof payload.text === 'string'"));
|
||||
assert!(html.contains("payload.type === 'hard_break'"));
|
||||
assert!(html.contains("typeof body?.fileVersion === 'string'"));
|
||||
assert!(html.contains("expectedFileVersion: session.conflictDetectionKey"));
|
||||
assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
|
||||
assert!(html.contains("blockType: 'mindmap'"));
|
||||
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
|
||||
|
||||
Reference in New Issue
Block a user