chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes - preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs - refresh design governance docs, Reasonix task templates, and bug records - retire root .mcp.json local MCP config
This commit is contained in:
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -132,7 +132,7 @@ struct LocalShareGrant {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LocalAccessMode {
|
||||
pub(crate) enum LocalAccessMode {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
@@ -490,7 +490,7 @@ fn ensure_local_workspace_access_for_actor_with_mode(
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_access_with_state(
|
||||
pub(crate) fn ensure_local_workspace_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -525,18 +525,7 @@ fn ensure_local_workspace_access_with_state(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_read_access_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
root_uri,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_read_access_with_state(
|
||||
pub(crate) fn ensure_local_workspace_read_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -544,6 +533,14 @@ fn ensure_local_workspace_read_access_with_state(
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_write_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_path_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -587,6 +584,17 @@ pub(crate) fn ensure_local_path_read_access(
|
||||
Ok(canonical_target)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_read_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_read_access_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
root_uri,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_local_workspace_access(
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
@@ -598,14 +606,6 @@ pub(crate) fn ensure_local_workspace_access(
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_local_workspace_write_access_with_state(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
root_uri: &str,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
|
||||
}
|
||||
|
||||
fn local_access_policy_path() -> PathBuf {
|
||||
std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE)
|
||||
.ok()
|
||||
@@ -759,6 +759,10 @@ fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError>
|
||||
pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let actor_type = context.auth.actor_type.trim();
|
||||
is_local_access_policy_admin_actor(actor_id, actor_type)
|
||||
}
|
||||
|
||||
pub(crate) fn is_local_access_policy_admin_actor(actor_id: &str, actor_type: &str) -> bool {
|
||||
if actor_id.is_empty()
|
||||
|| actor_id == "anonymous"
|
||||
|| actor_type.is_empty()
|
||||
@@ -1629,6 +1633,7 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
|
||||
json!({
|
||||
"id": grant.id,
|
||||
"userId": grant.user_id,
|
||||
"workspaceId": grant.workspace_id,
|
||||
"rootUri": grant.root_uri,
|
||||
"rootPath": grant.root_path,
|
||||
"permission": grant.permission,
|
||||
@@ -1643,6 +1648,16 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
|
||||
grant.source.trim() == "auto"
|
||||
&& grant.permission.trim() == "write"
|
||||
&& grant.recursive
|
||||
&& grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str())
|
||||
&& grant.workspace_id.is_some()
|
||||
&& grant.root_uri.starts_with("local://users/")
|
||||
&& grant.root_uri.ends_with("/workspaces/my-space")
|
||||
}
|
||||
|
||||
fn sqlite_access_policy_payload(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -1689,7 +1704,10 @@ fn sqlite_user_access_policy_payload(
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
|
||||
let grant_values = grants
|
||||
.iter()
|
||||
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
|
||||
.filter(|grant| {
|
||||
grant.user_id.trim() == actor_id
|
||||
|| grant.created_by.as_deref().map(str::trim) == Some(actor_id)
|
||||
})
|
||||
.map(control_plane_grant_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
@@ -1871,6 +1889,13 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
"目录授权不存在或不属于当前用户",
|
||||
)
|
||||
})?;
|
||||
if is_default_workspace_auto_grant(grant) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_system_grant_readonly",
|
||||
"默认空间的系统授权不能撤销",
|
||||
));
|
||||
}
|
||||
if grant.created_by.as_deref().map(str::trim) != Some(actor_id) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -1933,6 +1958,26 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
"必须提供 grantId",
|
||||
));
|
||||
}
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.find_directory_grants(DirectoryGrantLookup {
|
||||
grant_id: Some(grant_id.to_string()),
|
||||
user_id: None,
|
||||
root_uri: None,
|
||||
include_revoked: false,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
|
||||
if grants
|
||||
.first()
|
||||
.map(is_default_workspace_auto_grant)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_system_grant_readonly",
|
||||
"默认空间的系统授权不能撤销",
|
||||
));
|
||||
}
|
||||
state
|
||||
.control_plane()
|
||||
.revoke_directory_grant(grant_id, None)
|
||||
@@ -2710,7 +2755,16 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
})?;
|
||||
let parsed = parse_markdown_page(&markdown, &markdown_file.file_name);
|
||||
let title = parsed.title;
|
||||
let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body);
|
||||
let attachment_paths = uploaded_asset_markdown_relative_paths_for_document(
|
||||
&canonical_root,
|
||||
&metadata,
|
||||
document_id,
|
||||
&markdown_file.path,
|
||||
);
|
||||
let content = crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
|
||||
&parsed.body,
|
||||
&attachment_paths,
|
||||
);
|
||||
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
|
||||
let page_subtree = markdown_page_subtree(document_id, &title, &content);
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
@@ -3122,7 +3176,28 @@ pub async fn read_local_resource(
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let content = if is_markdown_file(&file_name) {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
let metadata = load_local_folder_metadata(&root)?;
|
||||
let target_relative_path = normalize_relative_path(&root, &target)?;
|
||||
let attachment_paths = metadata
|
||||
.uploaded_assets
|
||||
.get(&target_relative_path)
|
||||
.map(|entry| {
|
||||
uploaded_asset_markdown_relative_paths_for_document(
|
||||
&root,
|
||||
&metadata,
|
||||
&entry.document_id,
|
||||
&target,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if attachment_paths.is_empty() {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
} else {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
|
||||
&text,
|
||||
&attachment_paths,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
text_to_editor_blocks(&text, &file_name)
|
||||
};
|
||||
@@ -6437,6 +6512,26 @@ fn find_markdown_by_page_id(
|
||||
walk(root, root, metadata, document_id)
|
||||
}
|
||||
|
||||
fn uploaded_asset_markdown_relative_paths_for_document(
|
||||
root: &Path,
|
||||
metadata: &LocalFolderMetadata,
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> BTreeSet<String> {
|
||||
let Some(markdown_dir) = markdown_path.parent() else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
metadata
|
||||
.uploaded_assets
|
||||
.values()
|
||||
.filter(|entry| entry.document_id == document_id)
|
||||
.filter_map(|entry| {
|
||||
let path = root.join(&entry.relative_path);
|
||||
normalize_markdown_relative_asset_path(markdown_dir, &path).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
let mut item = json!({
|
||||
"rowId": row.row_id,
|
||||
@@ -7125,7 +7220,7 @@ fn editor_blocks_to_markdown_with_rewrite(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let inline_markdown = block_content_value(&block)
|
||||
.map(inline_nodes_to_markdown)
|
||||
.map(|content| inline_nodes_to_markdown(content, local_file_context))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
@@ -7319,7 +7414,11 @@ fn rewrite_local_open_url_to_markdown_relative(
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
) -> Option<String> {
|
||||
let (root, markdown_path) = local_file_context?;
|
||||
let parsed = Url::parse(value).ok()?;
|
||||
let parsed = if value.starts_with('/') {
|
||||
Url::parse(&format!("http://localhost{}", value)).ok()?
|
||||
} else {
|
||||
Url::parse(value).ok()?
|
||||
};
|
||||
if parsed.path() != "/api/local-folder/files/open" {
|
||||
return None;
|
||||
}
|
||||
@@ -7605,7 +7704,7 @@ fn editor_block_table_to_markdown(block: &Value) -> String {
|
||||
.map(|cell| {
|
||||
let cell_text = cell
|
||||
.get("content")
|
||||
.map(inline_nodes_to_markdown)
|
||||
.map(|content| inline_nodes_to_markdown(content, None))
|
||||
.unwrap_or_default()
|
||||
.replace('\n', " ")
|
||||
.replace('|', r"\|")
|
||||
@@ -7715,29 +7814,33 @@ fn block_content_value(block: &Value) -> Option<&Value> {
|
||||
// 过渡实现:手写行内节点→Markdown 回写函数。从 editor block 的 content 数组中
|
||||
// 逐个节点提取 text + styles,按 legacy 样式格式输出为内联 Markdown。
|
||||
// AST + 中间 IR 迁移 complete 后应统一走 MarkdownInline→Markdown 反向映射。
|
||||
fn inline_nodes_to_markdown(value: &Value) -> String {
|
||||
fn inline_nodes_to_markdown(value: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
|
||||
if let Some(text) = value.as_str() {
|
||||
return escape_markdown_inline_text(text);
|
||||
}
|
||||
if let Some(array) = value.as_array() {
|
||||
return array
|
||||
.iter()
|
||||
.map(inline_node_to_markdown)
|
||||
.map(|node| inline_node_to_markdown(node, local_file_context))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
}
|
||||
if let Some(object) = value.as_object() {
|
||||
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||
return markdown_text_with_styles(text, &inline_styles_from_object(object));
|
||||
return markdown_text_with_styles(
|
||||
text,
|
||||
&inline_styles_from_object(object),
|
||||
local_file_context,
|
||||
);
|
||||
}
|
||||
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
|
||||
return inline_nodes_to_markdown(content);
|
||||
return inline_nodes_to_markdown(content, local_file_context);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn inline_node_to_markdown(node: &Value) -> String {
|
||||
fn inline_node_to_markdown(node: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
|
||||
if let Some(text) = node.as_str() {
|
||||
return escape_markdown_inline_text(text);
|
||||
}
|
||||
@@ -7747,10 +7850,14 @@ fn inline_node_to_markdown(node: &Value) -> String {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
return markdown_text_with_styles(text, &inline_styles_from_object(object));
|
||||
return markdown_text_with_styles(
|
||||
text,
|
||||
&inline_styles_from_object(object),
|
||||
local_file_context,
|
||||
);
|
||||
}
|
||||
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
|
||||
return inline_nodes_to_markdown(content);
|
||||
return inline_nodes_to_markdown(content, local_file_context);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
@@ -7802,7 +7909,11 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
|
||||
Value::Object(styles)
|
||||
}
|
||||
|
||||
fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
|
||||
fn markdown_text_with_styles(
|
||||
text: &str,
|
||||
styles: &Value,
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
) -> String {
|
||||
let mut value = escape_markdown_inline_text(text);
|
||||
let link = styles
|
||||
.get("link")
|
||||
@@ -7838,6 +7949,8 @@ fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
|
||||
value = format!("~~{value}~~");
|
||||
}
|
||||
if let Some(href) = link {
|
||||
let href =
|
||||
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
|
||||
value = format!("[{}]({href})", value.replace(']', r"\]"));
|
||||
}
|
||||
value
|
||||
@@ -7995,14 +8108,14 @@ mod tests {
|
||||
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
|
||||
open_local_file, record_shared_cache, record_sync_pending_change,
|
||||
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
|
||||
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
|
||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
|
||||
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
};
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
@@ -8010,7 +8123,7 @@ mod tests {
|
||||
use axum::http::{HeaderMap, Method, StatusCode};
|
||||
use axum::Json;
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -8452,6 +8565,193 @@ fn main() {}
|
||||
assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_page_aggregate_preserves_uploaded_markdown_assets_as_media_blocks() {
|
||||
let root = temp_root("mnote-local-uploaded-md-assets-media");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
|
||||
let first = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes one\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload first md asset");
|
||||
let second = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes two\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload second md asset");
|
||||
assert_eq!(first["sourcePath"], "notes.md");
|
||||
assert_eq!(second["sourcePath"], "notes-1.md");
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]},
|
||||
{"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}},
|
||||
{"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}}
|
||||
]),
|
||||
)
|
||||
.expect("save uploaded md links");
|
||||
|
||||
let aggregate =
|
||||
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
|
||||
let media_paths = aggregate
|
||||
.body
|
||||
.content
|
||||
.as_array()
|
||||
.expect("blocks")
|
||||
.iter()
|
||||
.filter(|block| block["type"].as_str() == Some("media"))
|
||||
.map(|block| {
|
||||
block["props"]["sourcePath"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
|
||||
let root = temp_root("mnote-local-uploaded-md-inline-link");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
let asset = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload md asset");
|
||||
let href = format!(
|
||||
"http://127.0.0.1:3000/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
|
||||
root_uri
|
||||
);
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "notes.md",
|
||||
"marks": [{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": href,
|
||||
"class": "mnote-uploaded-attachment"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save inline attachment link");
|
||||
|
||||
assert_eq!(asset["sourcePath"], "notes.md");
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||
assert!(saved.contains("[notes.md](notes.md)"));
|
||||
assert!(!saved.contains("/api/local-folder/files/open"));
|
||||
|
||||
let aggregate =
|
||||
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
|
||||
let media = aggregate
|
||||
.body
|
||||
.content
|
||||
.as_array()
|
||||
.expect("blocks")
|
||||
.iter()
|
||||
.find(|block| block["type"].as_str() == Some("media"))
|
||||
.expect("uploaded md inline link should reload as media block");
|
||||
assert_eq!(media["props"]["sourcePath"], "notes.md");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
|
||||
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload md asset");
|
||||
let href = format!(
|
||||
"/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
|
||||
root_uri
|
||||
);
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
document_id,
|
||||
None,
|
||||
&json!([
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "notes.md",
|
||||
"marks": [{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": href,
|
||||
"class": "mnote-uploaded-attachment-row"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save relative open url");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||
assert!(saved.contains("[notes.md](notes.md)"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() {
|
||||
let root = temp_root("mnote-local-markdown-save-basic-blocks");
|
||||
@@ -9382,6 +9682,16 @@ fn main() {}
|
||||
assert_eq!(listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(listed["grants"][0]["permission"], "write");
|
||||
|
||||
let (_, Json(target_listed)) = get_user_access_policy(
|
||||
State(state.clone()),
|
||||
Extension(request_context("user_target", "user")),
|
||||
)
|
||||
.await
|
||||
.expect("target can list incoming directory grants");
|
||||
assert_eq!(target_listed["controlPlane"], "sqlite");
|
||||
assert_eq!(target_listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
|
||||
|
||||
let target_delete_error = delete_user_access_grant(
|
||||
State(state.clone()),
|
||||
Extension(request_context("user_target", "user")),
|
||||
@@ -9426,6 +9736,44 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&outside_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_access_policy_cannot_revoke_default_workspace_auto_grant() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("shujuan".into()),
|
||||
email: Some("shujuan@example.com".into()),
|
||||
username: "shujuan".into(),
|
||||
display_name: "shujuan".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.ensure_default_workspace("shujuan")
|
||||
.expect("default workspace");
|
||||
let grant_id = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.expect("list grants")
|
||||
.into_iter()
|
||||
.find(|grant| grant.user_id == "shujuan" && grant.source == "auto")
|
||||
.map(|grant| grant.id)
|
||||
.expect("default auto grant");
|
||||
|
||||
let error = delete_user_access_grant(
|
||||
State(state.clone()),
|
||||
Extension(request_context("shujuan", "user")),
|
||||
AxumPath(grant_id),
|
||||
)
|
||||
.await
|
||||
.expect_err("default grant must be readonly");
|
||||
assert_eq!(error.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
@@ -10012,6 +10360,115 @@ fn main() {}
|
||||
assert!(markdown.contains("资源正文"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_resource_read_preserves_uploaded_markdown_attachment_links() {
|
||||
let root = temp_root("mnote-local-resource-read-uploaded-md-links");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-md:Page~2FPage.md";
|
||||
let resource = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "resource.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Resource\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload resource markdown");
|
||||
let first = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes one\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload first linked markdown");
|
||||
let second = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
document_id,
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "notes.md".to_string(),
|
||||
content_type: "text/markdown".to_string(),
|
||||
bytes: b"# Notes two\n".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload second linked markdown");
|
||||
assert_eq!(resource["sourcePath"], "resource.md");
|
||||
assert_eq!(first["sourcePath"], "notes.md");
|
||||
assert_eq!(second["sourcePath"], "notes-1.md");
|
||||
std::fs::write(
|
||||
root.join("Page").join("resource.md"),
|
||||
"# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n",
|
||||
)
|
||||
.expect("write resource links");
|
||||
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("resource_reader".into()),
|
||||
email: Some("resource-reader@example.com".into()),
|
||||
username: "resource_reader".into(),
|
||||
display_name: "resource_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert resource reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "resource_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.display()
|
||||
.to_string(),
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant read");
|
||||
let context = request_context("resource_reader", "user");
|
||||
let (_, _, payload) = read_local_resource(
|
||||
State(state),
|
||||
Extension(context),
|
||||
Query(LocalResourceReadQuery {
|
||||
root_uri: root_uri.clone(),
|
||||
path: "Page/resource.md".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("read resource markdown");
|
||||
|
||||
let media_paths = payload["result"]["content"]
|
||||
.as_array()
|
||||
.expect("content blocks")
|
||||
.iter()
|
||||
.filter(|block| block["type"].as_str() == Some("media"))
|
||||
.map(|block| {
|
||||
block["props"]["sourcePath"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_folder_moves_directory_to_trash() {
|
||||
let root = temp_root("mnote-local-delete-folder");
|
||||
@@ -10383,6 +10840,64 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
|
||||
let policy_file = policy_root.join("missing-access-policy.json");
|
||||
let state = test_state();
|
||||
let canonical_root = root.canonicalize().expect("canonical root");
|
||||
let canonical_root_path = canonical_root.display().to_string();
|
||||
let root_uri = format!("file://{canonical_root_path}");
|
||||
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("legacy_sqlite_reader".into()),
|
||||
email: Some("legacy-sqlite-reader@example.com".into()),
|
||||
username: "legacy_sqlite_reader".into(),
|
||||
display_name: "legacy_sqlite_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "legacy_sqlite_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: canonical_root_path.clone(),
|
||||
root_path: canonical_root_path,
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
capabilities: vec![],
|
||||
source: "legacy-test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant legacy sqlite read");
|
||||
|
||||
let context = request_context("legacy_sqlite_reader", "user");
|
||||
let (_, _, bytes) = open_local_file(
|
||||
State(state),
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "README.txt".into(),
|
||||
download: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("legacy sqlite read grant can open local file");
|
||||
assert_eq!(bytes, b"hello legacy sqlite");
|
||||
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&policy_root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
|
||||
Reference in New Issue
Block a user