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:
lix-2026
2026-05-23 23:38:42 +08:00
parent 42fb58310c
commit 5f97800489
110 changed files with 5344 additions and 889 deletions
+21 -5
View File
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_page_tree_snapshot, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
@@ -311,7 +311,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)?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = snapshot
.dataset
@@ -614,7 +614,7 @@ pub async fn trash_entry(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
@@ -1780,6 +1780,7 @@ fn handle_sqlite_auth_action(
&resolved.user.id,
resolved.user.email.as_deref().unwrap_or_default(),
&resolved.user.display_name,
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
))
}
@@ -1809,18 +1810,20 @@ fn build_sqlite_auth_response(
user_id: &str,
email: &str,
name: &str,
actor_type: &str,
) -> Response {
let mut response = axum::Json(json!({
"ok": true,
"userId": user_id,
"email": email,
"name": name,
"authMode": "sqliteSession"
"authMode": "sqliteSession",
"actorType": actor_type
}))
.into_response();
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type);
if !email.trim().is_empty() {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email);
}
@@ -1835,6 +1838,19 @@ fn build_sqlite_auth_response(
response
}
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
user_id,
fallback_role,
) {
"admin".to_string()
} else {
fallback_role.to_string()
}
}
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
+2 -2
View File
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access, load_local_folder_file_tree_snapshot,
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot,
};
use crate::routes::query_support::resolve_effective_workspace_id;
@@ -85,7 +85,7 @@ async fn project_projection(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let snapshot = if projection == KernelProjectionKind::FileTree {
load_local_folder_file_tree_snapshot(root_uri)?
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access,
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
@@ -36,8 +36,9 @@ pub async fn local_folder_events(
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let canonical_root =
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
build_tree_live_stream(state, context, canonical_root, query.root_uri).await?
@@ -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");
@@ -1,6 +1,7 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
@@ -134,7 +135,14 @@ fn find_first_h1(body: &str) -> Option<String> {
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
pub fn markdown_to_blocks_with_attachment_paths(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
}
fn markdown_options() -> Options<'static> {
@@ -149,6 +157,13 @@ fn markdown_options() -> Options<'static> {
}
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new())
}
fn parse_markdown_attachment_link_with_paths(
trimmed: &str,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let value = trimmed
.strip_prefix('!')
.unwrap_or(trimmed)
@@ -170,7 +185,9 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
}
let target_path = std::path::Path::new(target);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") {
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
{
return None;
}
let fallback_name = target_path
@@ -186,20 +203,27 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
Some((name.to_string(), target.to_string()))
}
fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
fn parse_markdown_ast_document(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> MarkdownAstDocument {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut blocks = Vec::new();
for node in root.children() {
append_ast_block(node, &mut blocks);
append_ast_block(node, &mut blocks, attachment_paths);
}
MarkdownAstDocument { blocks }
}
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_block<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
match node.data.borrow().value.clone() {
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths),
NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading {
level: heading.level,
content: collect_inline_children(node),
@@ -212,7 +236,12 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}),
NodeValue::List(list) => {
for item in node.children() {
append_ast_list_item(item, list.list_type == ListType::Ordered, blocks);
append_ast_list_item(
item,
list.list_type == ListType::Ordered,
blocks,
attachment_paths,
);
}
}
NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)),
@@ -233,7 +262,11 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_paragraph<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
@@ -242,11 +275,13 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node) {
if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
blocks.push(MarkdownBlock::Media { name, source_path });
let content = merge_adjacent_inline_nodes(remaining);
if !content.is_empty() {
@@ -257,17 +292,22 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node)));
}
fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_list_item<'a>(
node: &'a AstNode<'a>,
ordered: bool,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
let (is_task, checked) = match node.data.borrow().value.clone() {
NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()),
NodeValue::Item(_) => (false, false),
_ => return append_ast_block(node, blocks),
_ => return append_ast_block(node, blocks, attachment_paths),
};
let mut content = Vec::new();
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks),
_ => append_ast_block(child, blocks, attachment_paths),
}
}
@@ -404,13 +444,16 @@ fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline
merged
}
fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_attachment_media(first)
link_attachment_media(first, attachment_paths)
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
@@ -437,10 +480,11 @@ fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
let mut children = node.children();
let first = children.next()?;
let (name, source_path) = link_attachment_media(first)?;
let (name, source_path) = link_attachment_media(first, attachment_paths)?;
let second = children.next()?;
if !matches!(
second.data.borrow().value,
@@ -455,11 +499,17 @@ fn paragraph_leading_attachment_media<'a>(
Some((name, source_path, remaining))
}
fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn link_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
parse_markdown_attachment_link_with_paths(
&format!("[{}]({})", collect_plain_text(node), link.url),
attachment_paths,
)
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
@@ -3,8 +3,8 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access, read_local_mindmap_data,
write_local_mindmap_data,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
read_local_mindmap_data, write_local_mindmap_data,
};
use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_convex,
@@ -151,7 +151,7 @@ pub async fn get_mindmap(
}
if is_local_folder_source(params.source_kind.as_deref()) {
let root_uri = local_root_uri(params.root_uri.as_deref(), None)?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let data = read_local_mindmap_data(root_uri, document_id, mindmap_id)
.map_err(|error| error.with_context(&context))?;
@@ -228,7 +228,7 @@ pub async fn apply_mindmap_command(
.or(params.source_kind.as_deref());
if is_local_folder_source(local_source_kind) {
let root_uri = local_root_uri(params.root_uri.as_deref(), body.root_uri.as_deref())?;
ensure_local_workspace_access(&context, root_uri)
ensure_local_workspace_write_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
if command_name == Some("mindmap.command.apply") {
let current = read_local_mindmap_data(root_uri, document_id, mindmap_id)
@@ -1268,7 +1268,9 @@ mod tests {
std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write md");
let markdown_path = root.join("Page").join("Page.md");
let original_markdown = "# Page\n\n[map](map.mindmap.json)\n";
std::fs::write(&markdown_path, original_markdown).expect("write md");
std::fs::write(
root.join("Page").join("map.mindmap.json"),
r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#,
@@ -1313,6 +1315,11 @@ mod tests {
.join("trash")
.join("map.mindmap.json")
.exists());
assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
original_markdown,
"mindmap archive 只移动资源文件,不应改写正文引用"
);
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:Page/map.mindmap.json"));
@@ -1354,6 +1361,11 @@ mod tests {
"tree.resource.restore"
);
assert!(root.join("Page").join("map.mindmap.json").exists());
assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after restore"),
original_markdown,
"mindmap restore 只恢复资源文件,不应改写正文引用"
);
let delete_again = app()
.oneshot(
+19 -8
View File
@@ -179,8 +179,10 @@ pub async fn documents(
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
local_search_index::query_local_search_index(
&root_path,
root_uri,
@@ -227,6 +229,7 @@ pub async fn documents(
}
pub async fn refresh_local_index(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<LocalSearchIndexRefreshRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -241,8 +244,10 @@ pub async fn refresh_local_index(
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let refreshed = local_search_index::refresh_local_search_index(
&root_path,
root_uri,
@@ -268,6 +273,7 @@ pub async fn refresh_local_index(
}
pub async fn local_index_backlinks(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -294,8 +300,10 @@ pub async fn local_index_backlinks(
)
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let backlinks = local_search_index::query_local_backlinks(
&root_path,
root_uri,
@@ -322,6 +330,7 @@ pub async fn local_index_backlinks(
}
pub async fn local_index_tags(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -336,8 +345,10 @@ pub async fn local_index_tags(
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
+165 -3
View File
@@ -78,10 +78,10 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: resolved.user.id,
user_id: resolved.user.id.clone(),
email: resolved.user.email.unwrap_or_default(),
name: resolved.user.display_name,
actor_type: "user".to_string(),
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
auth_mode: "sqliteSession",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
@@ -109,7 +109,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
state.config().dev_user_id.clone()
};
let actor_type = if has_forwarded_actor {
context.auth.actor_type
effective_actor_type_for_user(actor_id, &context.auth.actor_type)
} else {
"devFallback".to_string()
};
@@ -139,6 +139,19 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
}
}
fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
user_id,
fallback_role,
) {
"admin".to_string()
} else {
fallback_role.to_string()
}
}
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context
.auth
@@ -376,6 +389,155 @@ mod tests {
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn session_returns_admin_for_sqlite_admin_user() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("liaibo".into()),
email: Some("liaibo@yeah.net".into()),
username: "liaibo".into(),
display_name: "liaibo".into(),
role: Some("admin".into()),
password_hash: None,
})
.expect("upsert admin user");
state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "liaibo".into(),
token_hash: session_token_hash("raw-admin-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create admin session");
let response = build_app(state)
.oneshot(
Request::builder()
.uri("/api/auth/session")
.header("cookie", "mnote_session=raw-admin-session-token")
.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: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn session_returns_admin_for_local_access_policy_admin() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
"mnote-session-access-policy-admin-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&policy_root);
std::fs::create_dir_all(&policy_root).expect("create policy root");
let policy_file = policy_root.join("access-policy.json");
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
.expect("write access policy");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("liaibo".into()),
email: Some("liaibo@yeah.net".into()),
username: "liaibo".into(),
display_name: "liaibo".into(),
role: None,
password_hash: None,
})
.expect("upsert policy admin user");
state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "liaibo".into(),
token_hash: session_token_hash("raw-policy-admin-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create policy admin session");
let response = build_app(state)
.oneshot(
Request::builder()
.uri("/api/auth/session")
.header("cookie", "mnote_session=raw-policy-admin-session-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&policy_root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
let response = app()
+178 -6
View File
@@ -6,10 +6,10 @@ use crate::routes::command_support::{
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access,
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri,
local_workspace_id_from_root_uri, LocalAccessMode,
};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
@@ -6084,10 +6084,11 @@ fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<V
}
pub async fn local_folder_watch(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderWatchQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
ensure_local_workspace_read_access(&context, &query.root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let revision = local_folder_watch_revision(&query.root_uri)?;
Ok(json_response(
@@ -6185,7 +6186,7 @@ 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)
ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri)
.map_err(|error| error.with_context(&effective_context))?;
(
local_workspace_id_from_root_uri(root_uri)?,
@@ -6808,8 +6809,13 @@ 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))?;
ensure_local_workspace_access_with_state(
&state,
&context,
root_uri,
LocalAccessMode::Write,
)
.map_err(|error| error.with_context(&context))?;
let execution = execute_local_tree_command_with_sort(
root_uri,
action,
@@ -6932,6 +6938,7 @@ mod tests {
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -7223,6 +7230,88 @@ mod tests {
assert!(html.contains("data-document-id=\"local-md:README.md\""));
}
#[tokio::test]
async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-sqlite-read-grant-{}",
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"), "# Shared\n").expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "owner_user");
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.into()),
email: Some(format!("{user_id}@example.com")),
username: user_id.into(),
display_name: user_id.into(),
role: role.map(str::to_string),
password_hash: None,
})
.expect("upsert grant user");
}
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "shujuan".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: "admin".into(),
created_by: Some("liaibo".into()),
})
.expect("grant sqlite read");
let response = build_app(state)
.oneshot(
Request::builder()
.uri(format!(
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "shujuan")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
let root = std::env::temp_dir().join(format!(
@@ -7778,6 +7867,89 @@ mod tests {
assert_eq!(payload["code"], "local_workspace_access_denied");
}
#[tokio::test]
async fn tree_command_local_folder_allows_sqlite_directory_write_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-tree-sqlite-write-grant-{}",
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 state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.into()),
email: Some(format!("{user_id}@example.com")),
username: user_id.into(),
display_name: user_id.into(),
role: role.map(str::to_string),
password_hash: None,
})
.expect("upsert grant user");
}
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "shujuan".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "write".into(),
recursive: true,
capabilities: vec![],
source: "admin".into(),
created_by: Some("liaibo".into()),
})
.expect("grant sqlite write");
let response = build_app(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "shujuan")
.header("x-mnote-actor-type", "user")
.body(Body::from(format!(
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"#
)))
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tree_shell_embeds_renderer_input_contract() {
let filetree_response = app()
File diff suppressed because it is too large Load Diff