From a1b28a8e38955da4ec4234ae3988ec417e9e7de3 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 11:11:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=85=B1=E4=BA=AB=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=90=8C=E6=AD=A5=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现独立 share-grants 控制面,区分产品分享授权与本机目录授权。 收紧 shared AI session 的资源范围,并补充 shared cache、pending change 与冲突报告 sidecar。 更新当前架构与执行 checklist,记录 P4 已完成项和剩余 browser smoke。 --- CURRENT_ARCHITECTURE.md | 1 + design/01-05-current-priority-overview.md | 2 +- ...current-priority-execution-checklist-v1.md | 42 +- .../mnote-web/src/routes/hermes_client.rs | 675 ++++++++++- .../src/routes/local_folder_source.rs | 1038 ++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 21 + rust/crates/mnote-web/src/ssr/pages/admin.rs | 158 +++ 7 files changed, 1889 insertions(+), 48 deletions(-) diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md index a37d6ea4..cecd3114 100644 --- a/CURRENT_ARCHITECTURE.md +++ b/CURRENT_ARCHITECTURE.md @@ -89,6 +89,7 @@ 5. **分享与同步闭环** - 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。 + - share grant 是产品层共享授权,local access grant 是本机目录授权;显式分享不能自动扩大本机 filesystem root 权限。 - 控制面不可用时不得扩大本地缓存权限。 6. **插件资源模型** diff --git a/design/01-05-current-priority-overview.md b/design/01-05-current-priority-overview.md index b7489c83..07761a47 100644 --- a/design/01-05-current-priority-overview.md +++ b/design/01-05-current-priority-overview.md @@ -135,7 +135,7 @@ 2. VSCode-like 冲突处理 UI:接受磁盘版本、保留当前编辑器版本、打开 diff / 合并。 3. agent 写入审计:回收 changed files、diff summary、actor、workspace root、permission level、run id。 4. 本地索引:全文搜索、反链、页面引用、资源引用、标签,不依赖 Convex search 才能搜索本地 workspace。 -5. 分享与同步闭环:share grants、shared workspace cache、shared AI session、离线权限收敛和同步冲突报告。 +5. 分享与同步闭环:独立 share grants 控制面、shared workspace cache、shared AI session、离线权限收敛和同步冲突报告;share grant 不等于本机 local access grant,不能自动扩大本机目录读写权限。 6. 插件资源模型:simplemindmap / office 作为 Resource Tree 对象打开和保存,Markdown 只保留链接或嵌入引用。 7. 旧 Convex 数据迁移产品化:真实导出入口、迁移进度、备份、冲突报告和回滚策略。 diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 8a0c64d1..38c31b13 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -243,29 +243,45 @@ ### 5.1 分享权限 -- [ ] 定义 share grant 与 local access grant 的关系:分享不自动扩大本机文件系统权限。 -- [ ] share grant 支持 read / write / ai capability。 -- [ ] shared AI session 只能访问 share grant 允许的资源。 -- [ ] 管理员可查看和撤销 share grant。 +- [x] 定义 share grant 与 local access grant 的关系:share grant 是产品层共享授权,local access grant 是本机文件系统目录授权;分享不自动扩大本机 root 读写权限。 + - 实现:`share_grant_admin_api_creates_reads_and_revokes_without_local_access` 验证 share grant 不授予 target 用户本地 filesystem read/write access。 +- [x] 增加 canonical share grant 控制面:默认落在 `/mnt/Data1T/Mnote_data/control-plane/share-grants.json`,记录 `shareId`、owner、target、root、resource scope、permission、capabilities、生命周期和撤销状态。 + - 实现:新增 `GET/POST/DELETE /api/admin/share-grants`;支持 `MNOTE_SHARE_GRANTS_FILE` 覆盖测试路径。 +- [x] share grant 支持 `read` / `write` / `ai` capability,并可映射到 `AiAccessScope.permissionLevel=shared_read/shared_write`。 + - 实现:Hermes local ACP run 从 canonical share grant 生成 `shared_read/shared_write`。 +- [x] shared AI session 只能访问 share grant 允许的资源;`allowedResourceIds` / `shareContext` 必须来自 canonical share grant,不接受浏览器自报扩大范围。 + - 实现:`hermes_client_run_body_shared_scope_uses_share_grant_resources` 与 `acp_runtime_env_limits_shared_scope_to_granted_files` 覆盖 `shareContext`、`allowedResourceIds` 和文件级 allowed roots。 +- [x] 管理员可查看、创建和撤销 share grant;owner 后续可管理自己创建的 share grant。 + - 实现:管理员页面新增独立“分享授权”面板,与目录授权面板分离。 ### 5.2 同步缓存 -- [ ] 设计 shared workspace cache 目录。 -- [ ] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息。 -- [ ] 本地修改同步回云端前进行权限和版本校验。 -- [ ] 离线期间记录 pending changes,恢复在线后生成同步报告。 +- [x] 设计 shared workspace cache 目录:默认位于 `/mnt/Data1T/Mnote_data/shared/workspaces//`,workspace 内缓存 `.mnote/share-cache.json` 和 `.mnote/sync-state.json` 只保存授权、版本、来源和同步状态,不成为第二份正文真相。 + - 实现:新增 `/api/local-folder/shared-cache/record` 与 `/api/local-folder/sync/pending-change`,均只写 `.mnote` sidecar。 +- [x] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息:至少记录 `shareId`、`remoteVersion`、`baseVersion`、`permission`、`sourceActor`、`syncedAt`。 + - 实现:`.mnote/share-cache.json` 写入 `mnote.share_cache.v1`。 +- [x] 本地修改同步回云端前进行权限和版本校验:`shared_read` 永远不能上传 pending write;`shared_write` 必须校验 share grant 未撤销且 base version 未过期。 + - 验证:`shared_cache_and_sync_state_record_permissions_and_conflict_report` 覆盖 shared_read 拒绝 pending write、shared_write 写入 pending change、remote/base version 冲突返回 409。 +- [x] 离线期间记录 pending changes,恢复在线后生成同步报告;pending changes 只能来自已有授权 cache,离线不能新增或扩大授权。 + - 实现:pending changes 写入 `.mnote/sync-state.json`,写入前强制校验 canonical share grant。 ### 5.3 同步冲突报告 -- [ ] 同步冲突复用 P1 的冲突 UI。 -- [ ] 冲突报告包含本地版本、远端版本、base version、修改 actor。 -- [ ] 管理员或 owner 可导出冲突报告。 +- [x] 同步冲突复用 P1 的冲突 UI,并把冲突来源标记为 shared sync 而不是普通外部文件变更。 + - 实现:`/api/local-folder/sync/conflict-report` 生成 `source=shared_sync`、`suggestedActions=["accept_local","accept_remote","open_diff_merge"]` 的报告 envelope,供 P1 diff / merge UI 消费。 +- [x] 冲突报告包含本地版本、远端版本、base version、修改 actor、shareId 和 resource id。 + - 验证:`shared_cache_and_sync_state_record_permissions_and_conflict_report`。 +- [x] 管理员或 owner 可导出冲突报告;报告写入 control-plane 或 workspace `.mnote/sync-reports/`,不写入正文目录。 + - 实现:报告写入授权 workspace 的 `.mnote/sync-reports/`。 建议验证: -- [ ] 单测:share read 不允许写入。 -- [ ] 单测:shared AI session 不扩大 workspace root。 +- [x] 单测:share read 不允许写入。 + - 验证:`cargo test -p mnote-web shared_cache -- --nocapture`;`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`。 +- [x] 单测:shared AI session 不扩大 workspace root。 + - 验证:`cargo test -p mnote-web acp_runtime_env_limits_shared_scope_to_granted_files -- --nocapture`。 - [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。 + - 说明:当前已有 route / runtime 单测闭环;浏览器 smoke 仍需补脚本覆盖 UI 层拒写提示。 --- diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index a7914565..b649fe60 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -29,6 +29,8 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun"; const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent"; +const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json"; +const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE"; static HERMES_RUNTIME_REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -104,6 +106,58 @@ struct LocalAgentAuditSnapshot { files: BTreeMap, } +#[derive(Debug, Clone)] +struct LocalShareGrant { + share_id: String, + owner_id: Option, + target_user_id: Option, + root_uri: Option, + permission: String, + capabilities: HashSet, + allowed_resource_ids: Vec, + revoked: bool, +} + +impl LocalShareGrant { + fn permission_level(&self) -> &'static str { + if local_share_permission_allows_write(&self.permission, &self.capabilities) { + "shared_write" + } else { + "shared_read" + } + } + + fn share_context(&self) -> Value { + json!({ + "shareId": self.share_id, + "ownerId": self.owner_id, + "targetUserId": self.target_user_id, + "rootUri": self.root_uri, + "permission": self.permission, + "capabilities": self.capabilities.iter().cloned().collect::>() + }) + } + + fn allowed_file_paths(&self) -> Vec { + let Some(root_uri) = self.root_uri.as_deref() else { + return Vec::new(); + }; + let Some(root_path) = file_root_uri_to_permission_path(root_uri) else { + return Vec::new(); + }; + self.allowed_resource_ids + .iter() + .filter_map(|resource_id| local_resource_id_to_relative_path(resource_id)) + .map(|relative_path| { + FsPath::new(&root_path) + .join(relative_path) + .to_string_lossy() + .to_string() + }) + .collect() + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -380,18 +434,34 @@ pub async fn create_session( .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") })?; - crate::routes::ensure_local_workspace_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; let share_id = payload .share_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); - let permission_level = payload - .permission_level - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + let share_grant = if let Some(share_id) = share_id { + let grant = load_local_share_grant_for_actor( + share_id, + Some(&context.auth.actor_id), + Some(root_uri), + ) + .map_err(|error| error.with_context(&context))?; + Some(grant) + } else { + crate::routes::ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + None + }; + let permission_level = share_grant + .as_ref() + .map(LocalShareGrant::permission_level) + .or_else(|| { + payload + .permission_level + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + }) .unwrap_or(if share_id.is_some() { "shared_read" } else { @@ -424,6 +494,8 @@ pub async fn create_session( "visibility": visibility, "permissionLevel": permission_level, "shareId": share_id, + "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null), + "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_else(|| vec![document_id.to_string()]), "title": payload.title.as_deref().unwrap_or("当前页问答"), "profile": profile, "userId": session_store_user_id, @@ -442,6 +514,8 @@ pub async fn create_session( "sourceKind": "local_folder", "permissionLevel": permission_level, "shareId": share_id, + "shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null), + "allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_default(), "actorId": context.auth.actor_id, "userId": session_store_user_id, "traceId": trace_id, @@ -2828,6 +2902,9 @@ fn merge_acp_runtime_env( } fn local_ai_payload_permission_level(payload: &Value) -> &'static str { + if let Some(grant) = local_share_grant_for_payload(payload).ok().flatten() { + return grant.permission_level(); + } let raw = payload .get("permissionLevel") .or_else(|| payload.get("permission_level")) @@ -2835,7 +2912,11 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str { .map(str::trim) .unwrap_or("read_write") .to_ascii_lowercase(); - if raw == "read" || raw == "readonly" || raw == "read_only" || raw == "shared_read" { + if raw == "shared_read" { + "shared_read" + } else if raw == "shared_write" { + "shared_write" + } else if raw == "read" || raw == "readonly" || raw == "read_only" { "read_only" } else { "read_write" @@ -2843,7 +2924,261 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str { } fn local_ai_payload_is_read_only(payload: &Value) -> bool { - local_ai_payload_permission_level(payload) == "read_only" + matches!( + local_ai_payload_permission_level(payload), + "read_only" | "shared_read" + ) +} + +fn local_share_grants_path() -> PathBuf { + env::var(ENV_LOCAL_SHARE_GRANTS_FILE) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(LOCAL_SHARE_GRANTS_JSON)) +} + +fn local_resource_id_to_relative_path(resource_id: &str) -> Option { + let trimmed = resource_id.trim(); + let encoded = trimmed.strip_prefix("local-md:")?; + let bytes = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'~' { + if index + 2 >= bytes.len() { + return None; + } + let hex = &encoded[index + 1..index + 3]; + let byte = u8::from_str_radix(hex, 16).ok()?; + decoded.push(byte); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn local_share_permission_allows_write(permission: &str, capabilities: &HashSet) -> bool { + let permission = permission.trim().to_ascii_lowercase(); + let has_ai = capabilities + .iter() + .any(|capability| capability.trim().eq_ignore_ascii_case("ai")); + (permission == "write" || permission == "shared_write") && has_ai +} + +fn local_share_grant_for_payload(payload: &Value) -> Result, WebError> { + let share_id = payload + .get("shareId") + .or_else(|| payload.get("share_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(share_id) = share_id else { + return Ok(None); + }; + Ok(Some(load_local_share_grant_for_actor( + share_id, + payload.get("actorId").and_then(Value::as_str), + payload.get("rootUri").and_then(Value::as_str), + )?)) +} + +fn load_local_share_grant_for_actor( + share_id: &str, + actor_id: Option<&str>, + root_uri: Option<&str>, +) -> Result { + let path = local_share_grants_path(); + let content = fs::read_to_string(&path).map_err(|error| { + WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_required", + format!("共享 AI 会话需要 canonical share grant: {error}"), + ) + })?; + let payload: Value = serde_json::from_str(&content).map_err(|error| { + WebError::bad_request_code( + "local_share_grants_invalid", + format!("share-grants.json 格式非法: {error}"), + ) + })?; + let grants = if let Some(array) = payload.as_array() { + array + } else { + payload + .get("grants") + .and_then(Value::as_array) + .ok_or_else(|| { + WebError::bad_request_code( + "local_share_grants_invalid", + "share-grants.json 缺少 grants 数组", + ) + })? + }; + let Some(raw_grant) = grants.iter().find(|grant| { + grant + .get("shareId") + .or_else(|| grant.get("share_id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(share_id) + }) else { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_not_found", + "共享 AI 会话使用的 shareId 不存在", + )); + }; + let grant = parse_local_share_grant(raw_grant)?; + if grant.revoked { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_revoked", + "共享 AI 会话使用的 share grant 已撤销", + )); + } + if let (Some(target), Some(actor_id)) = (grant.target_user_id.as_deref(), actor_id) { + if target.trim() != actor_id.trim() { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_actor_mismatch", + "当前用户不是 share grant 的 target 用户", + )); + } + } + if let (Some(grant_root), Some(root_uri)) = (grant.root_uri.as_deref(), root_uri) { + if grant_root.trim() != root_uri.trim() { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_root_mismatch", + "请求 rootUri 与 canonical share grant 不一致", + )); + } + } + if !grant + .capabilities + .iter() + .any(|capability| capability.trim().eq_ignore_ascii_case("ai")) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_ai_capability_required", + "共享 AI 会话需要 share grant 包含 ai capability", + )); + } + Ok(grant) +} + +fn parse_local_share_grant(value: &Value) -> Result { + let share_id = value + .get("shareId") + .or_else(|| value.get("share_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_share_grant_invalid", "share grant 缺少 shareId") + })? + .to_string(); + let owner_id = value + .get("ownerId") + .or_else(|| value.get("ownerUserId")) + .or_else(|| value.get("owner_id")) + .or_else(|| value.get("owner_user_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let target_user_id = value + .get("targetUserId") + .or_else(|| value.get("target_user_id")) + .or_else(|| value.get("targetId")) + .or_else(|| value.get("target_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let root_uri = value + .get("rootUri") + .or_else(|| value.get("root_uri")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let permission = value + .get("permission") + .or_else(|| value.get("permissionLevel")) + .or_else(|| value.get("permission_level")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("read") + .to_ascii_lowercase(); + let capabilities = value + .get("capabilities") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .collect::>() + }) + .unwrap_or_default(); + let allowed_resource_ids = value + .get("allowedResourceIds") + .or_else(|| value.get("allowed_resource_ids")) + .or_else(|| value.get("resourceIds")) + .or_else(|| value.get("resource_ids")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let revoked = value + .get("revoked") + .and_then(Value::as_bool) + .unwrap_or(false) + || value + .get("active") + .and_then(Value::as_bool) + .map(|active| !active) + .unwrap_or(false) + || value + .get("revokedAt") + .or_else(|| value.get("revoked_at")) + .and_then(Value::as_str) + .map(str::trim) + .map(|revoked_at| !revoked_at.is_empty()) + .unwrap_or(false) + || value + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .map(|status| status.eq_ignore_ascii_case("revoked")) + .unwrap_or(false); + Ok(LocalShareGrant { + share_id, + owner_id, + target_user_id, + root_uri, + permission, + capabilities, + allowed_resource_ids, + revoked, + }) } fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option> { @@ -2885,21 +3220,44 @@ fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option Result Result Option { + payload + .get("shareId") + .or_else(|| payload.get("share_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String { payload .get("actorId") @@ -4320,8 +4709,18 @@ async fn persist_acp_runtime_run( WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; - crate::routes::ensure_local_workspace_access(context, root_uri) + let share_id = local_session_share_id(payload); + if let Some(share_id) = share_id.as_deref() { + load_local_share_grant_for_actor( + share_id, + payload.get("actorId").and_then(Value::as_str), + Some(root_uri), + ) .map_err(|error| error.with_context(context))?; + } else { + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + } let mut event = args.clone(); if let Value::Object(map) = &mut event { map.insert("eventType".into(), Value::String("run.started".into())); @@ -4330,12 +4729,17 @@ async fn persist_acp_runtime_run( Value::String("local_ai_session_jsonl".into()), ); } - append_local_ai_session_event(root_uri, ®istration.session_id, &event, None) - .map_err(|error| error.with_context(context))?; + append_local_ai_session_event( + root_uri, + ®istration.session_id, + &event, + share_id.as_deref(), + ) + .map_err(|error| error.with_context(context))?; return Ok(json!({ "ok": true, "persistence": "local_ai_session_jsonl", - "sessionStorage": "local_private", + "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" }, "sessionId": registration.session_id, "runId": run_id })); @@ -4428,8 +4832,18 @@ async fn persist_acp_runtime_event( WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") .with_context(context) })?; - crate::routes::ensure_local_workspace_access(context, root_uri) + let share_id = local_session_share_id(run_payload); + if let Some(share_id) = share_id.as_deref() { + load_local_share_grant_for_actor( + share_id, + run_payload.get("actorId").and_then(Value::as_str), + Some(root_uri), + ) .map_err(|error| error.with_context(context))?; + } else { + crate::routes::ensure_local_workspace_access(context, root_uri) + .map_err(|error| error.with_context(context))?; + } let mut event = args.clone(); if let Value::Object(map) = &mut event { map.insert( @@ -4437,12 +4851,17 @@ async fn persist_acp_runtime_event( Value::String("local_ai_session_jsonl".into()), ); } - append_local_ai_session_event(root_uri, ®istration.session_id, &event, None) - .map_err(|error| error.with_context(context))?; + append_local_ai_session_event( + root_uri, + ®istration.session_id, + &event, + share_id.as_deref(), + ) + .map_err(|error| error.with_context(context))?; return Ok(json!({ "ok": true, "persistence": "local_ai_session_jsonl", - "sessionStorage": "local_private", + "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" }, "sessionId": registration.session_id, "runId": run_id })); @@ -5672,18 +6091,47 @@ mod tests { #[tokio::test] async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() { + let _guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-{}", std::process::id() )); + let config_root = std::env::temp_dir().join(format!( + "mnote-local-ai-session-shared-config-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::create_dir_all(&config_root).expect("config root"); 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"); let root_uri = format!("file://{}", root.display()); + let share_grants_file = config_root.join("share-grants.json"); + fs::write( + &share_grants_file, + json!({ + "grants": [{ + "id": "share_grant_1", + "shareId": "share_1", + "ownerUserId": "user_1", + "targetUserId": "user_1", + "rootUri": root_uri, + "documentId": "local-md:README.md", + "allowedResourceIds": ["local-md:README.md"], + "permission": "write", + "capabilities": ["ai", "share"], + "createdAt": "1", + "active": true + }] + }) + .to_string(), + ) + .expect("share grants"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let response = app() .oneshot( @@ -5727,24 +6175,57 @@ mod tests { assert!(jsonl.contains("\"eventType\":\"session.created\"")); assert!(jsonl.contains("\"eventType\":\"audit.shared_write\"")); assert!(jsonl.contains("\"shareId\":\"share_1\"")); + assert!(jsonl.contains("\"allowedResourceIds\":[\"local-md:README.md\"]")); + assert!(jsonl.contains("\"shareContext\"")); + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&config_root); } #[tokio::test] async fn hermes_client_local_shared_read_session_write_is_forbidden() { + let _guard = env_lock().lock().expect("env lock"); let root = std::env::temp_dir().join(format!( "mnote-local-ai-session-shared-read-{}", std::process::id() )); + let config_root = std::env::temp_dir().join(format!( + "mnote-local-ai-session-shared-read-config-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&config_root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::create_dir_all(&config_root).expect("config root"); 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"); let root_uri = format!("file://{}", root.display()); + let share_grants_file = config_root.join("share-grants.json"); + fs::write( + &share_grants_file, + json!({ + "grants": [{ + "id": "share_grant_read_1", + "shareId": "share_1", + "ownerUserId": "user_1", + "targetUserId": "user_1", + "rootUri": root_uri, + "documentId": "local-md:README.md", + "allowedResourceIds": ["local-md:README.md"], + "permission": "read", + "capabilities": ["ai", "share"], + "createdAt": "1", + "active": true + }] + }) + .to_string(), + ) + .expect("share grants"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); let response = app() .oneshot( @@ -5775,7 +6256,9 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(!root.join("ai-sessions").join("shared").exists()); + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&config_root); } #[tokio::test] @@ -7082,6 +7565,74 @@ mod tests { assert!(!instructions.contains("\"pageText\"")); } + #[test] + fn hermes_client_run_body_shared_scope_uses_share_grant_resources() { + let _guard = env_lock().lock().expect("env lock"); + let config_root = std::env::temp_dir().join(format!( + "mnote-shared-run-body-config-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&config_root); + fs::create_dir_all(&config_root).expect("config root"); + let share_grants_file = config_root.join("share-grants.json"); + fs::write( + &share_grants_file, + json!({ + "grants": [{ + "id": "share_grant_scope_1", + "shareId": "share_scope_1", + "ownerUserId": "owner_1", + "targetUserId": "target_1", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", + "documentId": "local-md:docs~2FShared.md", + "allowedResourceIds": ["local-md:docs~2FShared.md"], + "permission": "write", + "capabilities": ["ai", "share"], + "createdAt": "1", + "active": true + }] + }) + .to_string(), + ) + .expect("share grants"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", "target_1".parse().unwrap()); + headers.insert("x-mnote-actor-type", "user".parse().unwrap()); + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &headers, + ); + let body = build_run_upstream_body( + &context, + json!({ + "workspaceId": "local-workspace-shared", + "documentId": "local-md:docs~2FShared.md", + "sessionId": "sess_shared_scope_1", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", + "shareId": "share_scope_1", + "actorId": "target_1", + "message": "编辑共享页面", + "traceId": "trace_shared_scope_1" + }), + ) + .expect("body"); + + let instructions = body["instructions"].as_str().expect("instructions"); + assert!(instructions.contains("\"permissionLevel\":\"shared_write\"")); + assert!(instructions.contains("\"shareContext\"")); + assert!(instructions.contains("\"shareId\":\"share_scope_1\"")); + assert!(instructions.contains("\"allowedResourceIds\":[\"local-md:docs~2FShared.md\"]")); + assert!(instructions.contains( + "\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/docs/Shared.md\"]" + )); + + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = fs::remove_dir_all(&config_root); + } + #[test] fn acp_runtime_env_limits_local_source_to_allowed_root() { let payload = json!({ @@ -7122,6 +7673,76 @@ mod tests { ); } + #[test] + fn acp_runtime_env_limits_shared_scope_to_granted_files() { + let _guard = env_lock().lock().expect("env lock"); + let config_root = std::env::temp_dir().join(format!( + "mnote-shared-acp-env-config-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&config_root); + fs::create_dir_all(&config_root).expect("config root"); + let share_grants_file = config_root.join("share-grants.json"); + fs::write( + &share_grants_file, + json!({ + "grants": [{ + "id": "share_grant_env_1", + "shareId": "share_env_1", + "ownerUserId": "owner_1", + "targetUserId": "target_1", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", + "allowedResourceIds": ["local-md:README.md"], + "permission": "read", + "capabilities": ["ai", "share"], + "createdAt": "1", + "active": true + }] + }) + .to_string(), + ) + .expect("share grants"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + let payload = json!({ + "workspaceId": "local-workspace-shared", + "documentId": "local-md:README.md", + "sessionId": "sess_shared_env_1", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space", + "shareId": "share_env_1", + "actorId": "target_1" + }); + + let env = acp_allowed_roots_env_for_payload(&payload).expect("env"); + let allowed_roots = serde_json::from_str::>( + env.get("MNOTE_AI_ALLOWED_ROOTS_JSON") + .expect("allowed roots"), + ) + .expect("allowed roots json"); + assert_eq!( + allowed_roots, + vec!["/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"] + ); + assert_eq!( + env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str), + Some("/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space") + ); + let scope = serde_json::from_str::( + env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"), + ) + .expect("scope json"); + assert_eq!(scope["permissionLevel"], "shared_read"); + assert_eq!(scope["shareContext"]["shareId"], "share_env_1"); + assert_eq!(scope["allowedResourceIds"][0], "local-md:README.md"); + assert_eq!( + scope["allowedFilePaths"][0], + "/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md" + ); + + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = fs::remove_dir_all(&config_root); + } + #[test] fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() { let state = test_state(); diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index eac4a75e..c1a1b8a3 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -13,6 +13,9 @@ use axum::extract::{Extension, Multipart, Path as AxumPath, Query}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::project_legacy_content_to_block_document; +use core_protocol::{ + KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::cmp::Ordering; @@ -81,6 +84,44 @@ struct LocalAccessGrant { capabilities: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalShareGrantsStore { + #[serde(default)] + grants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalShareGrant { + #[serde(default)] + id: String, + #[serde(default, alias = "share_id")] + share_id: String, + #[serde(default, alias = "owner_user_id")] + owner_user_id: String, + #[serde(default, alias = "target_user_id")] + target_user_id: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default, alias = "root_path")] + root_path: String, + #[serde(default, alias = "documentId")] + document_id: String, + #[serde(default, alias = "allowedResourceIds")] + allowed_resource_ids: Vec, + #[serde(default, alias = "access")] + permission: String, + #[serde(default)] + capabilities: Vec, + #[serde(default, alias = "createdAt")] + created_at: String, + #[serde(default, alias = "revokedAt")] + revoked_at: Option, + #[serde(default = "default_true")] + active: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LocalAccessMode { Read, @@ -131,6 +172,8 @@ struct LocalFolderRow { expanded_by_default: bool, document_id: Option, capabilities: Vec, + workspace_id: String, + root_source_uri: String, } #[derive(Debug, Clone)] @@ -185,10 +228,84 @@ pub struct LocalAccessGrantRequest { pub capabilities: Vec, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalShareGrantRequest { + #[serde(default)] + pub id: String, + #[serde(default, alias = "share_id")] + pub share_id: String, + #[serde(default, alias = "owner_user_id")] + pub owner_user_id: String, + #[serde(default, alias = "target_user_id")] + pub target_user_id: String, + #[serde(default, alias = "root_uri")] + pub root_uri: String, + #[serde(default, alias = "root_path")] + pub root_path: String, + #[serde(default, alias = "documentId")] + pub document_id: String, + #[serde(default, alias = "allowedResourceIds")] + pub allowed_resource_ids: Vec, + #[serde(default, alias = "access")] + pub permission: String, + #[serde(default)] + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedCacheRecordRequest { + pub root_uri: String, + pub share_id: String, + pub permission: String, + #[serde(default)] + pub remote_version: String, + #[serde(default)] + pub base_version: String, + #[serde(default)] + pub source_actor: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncPendingChangeRequest { + pub root_uri: String, + pub share_id: String, + pub resource_id: String, + #[serde(default)] + pub local_version: String, + #[serde(default)] + pub base_version: String, + #[serde(default)] + pub remote_version: String, + #[serde(default)] + pub change_summary: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncConflictReportRequest { + pub root_uri: String, + pub share_id: String, + pub resource_id: String, + #[serde(default)] + pub local_version: String, + #[serde(default)] + pub remote_version: String, + #[serde(default)] + pub base_version: String, + #[serde(default)] + pub actor_id: String, + #[serde(default)] + pub summary: String, +} + const DEFAULT_LOCAL_WORKSPACE_BASE_DIR: &str = "/mnt/Data1T/Mnote_data"; const DEFAULT_LOCAL_WORKSPACE_SLUG: &str = "my-space"; const ENV_LOCAL_ADMIN_USER_IDS: &str = "MNOTE_ADMIN_USER_IDS"; const ENV_LOCAL_ACCESS_POLICY_FILE: &str = "MNOTE_LOCAL_ACCESS_POLICY_FILE"; +const ENV_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE"; fn default_true() -> bool { true @@ -365,6 +482,19 @@ pub(crate) fn local_access_policy_path_display() -> String { local_access_policy_path().display().to_string() } +fn local_share_grants_path() -> PathBuf { + std::env::var(ENV_SHARE_GRANTS_FILE) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + default_local_workspace_base_dir() + .join("control-plane") + .join("share-grants.json") + }) +} + fn load_local_access_policy() -> Result { let path = local_access_policy_path(); if !path.exists() { @@ -384,6 +514,25 @@ fn load_local_access_policy() -> Result { }) } +fn load_local_share_grants_store() -> Result { + let path = local_share_grants_path(); + if !path.exists() { + return Ok(LocalShareGrantsStore::default()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "local_share_grants_read_failed", + format!("无法读取分享授权控制面 {}: {error}", path.display()), + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + WebError::bad_request_code( + "local_share_grants_invalid", + format!("分享授权控制面格式非法: {error}"), + ) + }) +} + fn write_local_access_policy(policy: &LocalAccessPolicy) -> Result<(), WebError> { let path = local_access_policy_path(); if let Some(parent) = path.parent() { @@ -404,6 +553,26 @@ fn write_local_access_policy(policy: &LocalAccessPolicy) -> Result<(), WebError> }) } +fn write_local_share_grants_store(store: &LocalShareGrantsStore) -> Result<(), WebError> { + let path = local_share_grants_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "local_share_grants_dir_create_failed", + format!("无法创建分享授权目录 {}: {error}", parent.display()), + ) + })?; + } + let content = serde_json::to_string_pretty(store) + .map_err(|error| WebError::internal(format!("无法序列化分享授权控制面: {error}")))?; + fs::write(&path, format!("{content}\n")).map_err(|error| { + WebError::bad_request_code( + "local_share_grants_write_failed", + format!("无法写入分享授权控制面 {}: {error}", path.display()), + ) + }) +} + fn require_local_access_policy_admin( context: &RequestContext, ) -> Result { @@ -431,6 +600,31 @@ fn require_local_access_policy_admin( Ok(policy) } +fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError> { + let policy = load_local_access_policy()?; + let actor_id = context.auth.actor_id.trim(); + let actor_type = context.auth.actor_type.trim(); + if actor_id.is_empty() + || actor_id == "anonymous" + || actor_type.is_empty() + || actor_type == "anonymous" + { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_share_grants_auth_required", + "管理分享授权需要先登录", + )); + } + if !is_local_admin(actor_id, actor_type, &policy) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grants_admin_required", + "只有管理员可以管理分享授权", + )); + } + Ok(()) +} + 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(); @@ -532,6 +726,502 @@ fn generate_local_access_grant_id(user_id: &str, canonical_root: &Path) -> Strin format!("grant_{now}_{:x}", hasher.finish()) } +fn normalize_share_grant_permission(permission: &str) -> Result { + let permission = permission.trim(); + match permission { + "read" | "write" => Ok(permission.to_string()), + _ => Err(WebError::bad_request_code( + "local_share_grant_permission_invalid", + "分享授权 permission 只能是 read 或 write", + )), + } +} + +fn normalize_share_grant_capabilities(capabilities: &[String]) -> Result, WebError> { + let mut normalized = Vec::new(); + for capability in capabilities { + let capability = capability.trim(); + if capability.is_empty() { + continue; + } + match capability { + "ai" | "share" => { + if !normalized.iter().any(|item| item == capability) { + normalized.push(capability.to_string()); + } + } + _ => { + return Err(WebError::bad_request_code( + "local_share_grant_capability_invalid", + "分享授权 capability 只能是 ai 或 share", + )); + } + } + } + normalized.sort(); + Ok(normalized) +} + +fn generate_local_share_grant_id(share_id: &str, target_user_id: &str, root: &Path) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let mut hasher = DefaultHasher::new(); + share_id.hash(&mut hasher); + target_user_id.hash(&mut hasher); + root.display().to_string().hash(&mut hasher); + format!("share_grant_{now}_{:x}", hasher.finish()) +} + +fn share_grant_payload(grant: &LocalShareGrant) -> Value { + json!({ + "id": grant.id, + "shareId": grant.share_id, + "ownerUserId": grant.owner_user_id, + "targetUserId": grant.target_user_id, + "rootUri": grant.root_uri, + "rootPath": grant.root_path, + "documentId": grant.document_id, + "allowedResourceIds": grant.allowed_resource_ids, + "permission": grant.permission, + "capabilities": grant.capabilities, + "createdAt": grant.created_at, + "revokedAt": grant.revoked_at, + "active": grant.active, + }) +} + +fn list_local_share_grants_for_context(context: &RequestContext) -> Result { + require_share_grants_admin(context)?; + let store = load_local_share_grants_store()?; + let grants = store + .grants + .iter() + .map(share_grant_payload) + .collect::>(); + Ok(json!({ + "ok": true, + "grantsPath": local_share_grants_path().display().to_string(), + "grants": grants, + "store": { + "grants": grants, + } + })) +} + +fn add_local_share_grant_for_context( + context: &RequestContext, + request: LocalShareGrantRequest, +) -> Result { + require_share_grants_admin(context)?; + let mut store = load_local_share_grants_store()?; + let share_id = request.share_id.trim(); + let owner_user_id = request.owner_user_id.trim(); + let target_user_id = request.target_user_id.trim(); + if share_id.is_empty() { + return Err(WebError::bad_request_code( + "local_share_grant_share_id_required", + "必须提供 shareId", + )); + } + if owner_user_id.is_empty() || owner_user_id == "anonymous" { + return Err(WebError::bad_request_code( + "local_share_grant_owner_required", + "必须提供有效 ownerUserId", + )); + } + if target_user_id.is_empty() || target_user_id == "anonymous" { + return Err(WebError::bad_request_code( + "local_share_grant_target_required", + "必须提供有效 targetUserId", + )); + } + let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; + let permission = normalize_share_grant_permission(&request.permission)?; + let capabilities = normalize_share_grant_capabilities(&request.capabilities)?; + let root_uri = file_uri_for_path(&canonical); + let allowed_resource_ids = request + .allowed_resource_ids + .iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + let grant_id = request.id.trim(); + let grant_id = if grant_id.is_empty() { + generate_local_share_grant_id(share_id, target_user_id, &canonical) + } else { + grant_id.to_string() + }; + if store + .grants + .iter() + .any(|grant| grant.id.trim() == grant_id || grant.share_id.trim() == share_id) + { + return Err(WebError::bad_request_code( + "local_share_grant_exists", + "分享授权已存在", + )); + } + let grant = LocalShareGrant { + id: grant_id, + share_id: share_id.to_string(), + owner_user_id: owner_user_id.to_string(), + target_user_id: target_user_id.to_string(), + root_uri, + root_path: canonical.display().to_string(), + document_id: request.document_id.trim().to_string(), + allowed_resource_ids, + permission, + capabilities, + created_at: now_ms().to_string(), + revoked_at: None, + active: true, + }; + store.grants.push(grant.clone()); + write_local_share_grants_store(&store)?; + Ok(json!({ + "ok": true, + "grantsPath": local_share_grants_path().display().to_string(), + "grant": share_grant_payload(&grant), + "store": store, + })) +} + +fn revoke_local_share_grant_for_context( + context: &RequestContext, + share_id: &str, +) -> Result { + require_share_grants_admin(context)?; + let mut store = load_local_share_grants_store()?; + let share_id = share_id.trim(); + if share_id.is_empty() { + return Err(WebError::bad_request_code( + "local_share_grant_share_id_required", + "必须提供 shareId", + )); + } + let now = now_ms().to_string(); + let mut updated = None; + for grant in &mut store.grants { + if grant.share_id.trim() == share_id || grant.id.trim() == share_id { + grant.active = false; + grant.revoked_at = Some(now.clone()); + updated = Some(grant.clone()); + break; + } + } + let grant = updated.ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "local_share_grant_not_found", + "未找到要撤销的分享授权", + ) + })?; + write_local_share_grants_store(&store)?; + Ok(json!({ + "ok": true, + "grantsPath": local_share_grants_path().display().to_string(), + "revokedShareId": share_id, + "grant": share_grant_payload(&grant), + "store": store, + })) +} + +fn require_active_share_grant_for_actor( + context: &RequestContext, + root_uri: &str, + share_id: &str, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_shared_cache_root_unavailable", + format!("无法访问共享缓存 root: {error}"), + ) + })?; + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_share_grant_auth_required", + "访问共享缓存需要先登录", + )); + } + let store = load_local_share_grants_store()?; + let share_id = share_id.trim(); + let grant = store + .grants + .into_iter() + .find(|grant| grant.share_id.trim() == share_id || grant.id.trim() == share_id) + .ok_or_else(|| { + WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_not_found", + "未找到共享授权", + ) + })?; + if !grant.active || grant.revoked_at.as_deref().unwrap_or("").trim().len() > 0 { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_revoked", + "共享授权已撤销", + )); + } + if grant.target_user_id.trim() != actor_id && grant.owner_user_id.trim() != actor_id { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_actor_mismatch", + "当前用户不在该共享授权范围内", + )); + } + let Some(grant_root) = local_share_grant_root(&grant) else { + return Err(WebError::bad_request_code( + "local_share_grant_root_invalid", + "共享授权 root 无效", + )); + }; + if grant_root != canonical_root { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_root_mismatch", + "共享授权 root 与请求 root 不一致", + )); + } + Ok(grant) +} + +fn local_share_grant_root(grant: &LocalShareGrant) -> Option { + let root = grant.root_uri.trim(); + let path = if !root.is_empty() { + parse_file_root_uri(root).ok()? + } else { + let root_path = grant.root_path.trim(); + if root_path.is_empty() { + return None; + } + PathBuf::from(root_path) + }; + path.canonicalize().ok() +} + +fn shared_cache_json_path(root: &Path) -> PathBuf { + root.join(".mnote").join("share-cache.json") +} + +fn sync_state_json_path(root: &Path) -> PathBuf { + root.join(".mnote").join("sync-state.json") +} + +fn sync_report_dir(root: &Path) -> PathBuf { + root.join(".mnote").join("sync-reports") +} + +fn read_json_or_default(path: &Path, default_value: Value) -> Value { + fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .unwrap_or(default_value) +} + +fn write_json_file(path: &Path, value: &Value) -> Result<(), WebError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "local_sidecar_dir_create_failed", + format!("无法创建本地 sidecar 目录 {}: {error}", parent.display()), + ) + })?; + } + let content = serde_json::to_string_pretty(value) + .map_err(|error| WebError::internal(format!("无法序列化本地 sidecar JSON: {error}")))?; + fs::write(path, format!("{content}\n")).map_err(|error| { + WebError::bad_request_code( + "local_sidecar_write_failed", + format!("无法写入本地 sidecar 文件 {}: {error}", path.display()), + ) + }) +} + +fn record_shared_cache_for_context( + context: &RequestContext, + request: SharedCacheRecordRequest, +) -> Result { + let grant = + require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; + let root = parse_file_root_uri(&request.root_uri)? + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_shared_cache_root_unavailable", + format!("无法访问共享缓存 root: {error}"), + ) + })?; + let permission = normalize_share_grant_permission(&request.permission)?; + if permission != grant.permission.trim() { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_shared_cache_permission_mismatch", + "共享缓存权限必须来自 canonical share grant", + )); + } + let cache_entry = json!({ + "schema": "mnote.share_cache.v1", + "shareId": grant.share_id, + "permission": permission, + "rootUri": grant.root_uri, + "allowedResourceIds": grant.allowed_resource_ids, + "remoteVersion": request.remote_version, + "baseVersion": request.base_version, + "sourceActor": request.source_actor, + "syncedAt": now_ms(), + }); + let path = shared_cache_json_path(&root); + let mut cache = read_json_or_default( + &path, + json!({"schema": "mnote.share_cache.v1", "shares": {}}), + ); + cache["shares"][grant.share_id.clone()] = cache_entry.clone(); + write_json_file(&path, &cache)?; + Ok(json!({ + "ok": true, + "path": path.display().to_string(), + "share": cache_entry, + })) +} + +fn record_sync_pending_change_for_context( + context: &RequestContext, + request: SyncPendingChangeRequest, +) -> Result { + let grant = + require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; + if normalize_share_grant_permission(&grant.permission)? != "write" { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_sync_shared_read_write_forbidden", + "shared_read 不能记录待同步写入", + )); + } + if !grant + .allowed_resource_ids + .iter() + .any(|resource_id| resource_id == &request.resource_id) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_sync_resource_not_shared", + "待同步资源不在 share grant 授权范围内", + )); + } + if !request.remote_version.trim().is_empty() + && !request.base_version.trim().is_empty() + && request.remote_version.trim() != request.base_version.trim() + { + return Err(WebError::new( + StatusCode::CONFLICT, + "local_sync_base_version_conflict", + "同步前远端版本已变化,需要生成冲突报告", + )); + } + let root = parse_file_root_uri(&request.root_uri)? + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_sync_root_unavailable", + format!("无法访问同步 root: {error}"), + ) + })?; + let path = sync_state_json_path(&root); + let mut state = read_json_or_default( + &path, + json!({"schema": "mnote.sync_state.v1", "pendingChanges": []}), + ); + let pending = json!({ + "shareId": grant.share_id, + "resourceId": request.resource_id, + "localVersion": request.local_version, + "baseVersion": request.base_version, + "remoteVersion": request.remote_version, + "actorId": context.auth.actor_id, + "changeSummary": request.change_summary, + "createdAt": now_ms(), + }); + state["pendingChanges"] + .as_array_mut() + .ok_or_else(|| { + WebError::bad_request_code( + "local_sync_state_invalid", + "sync-state pendingChanges 非数组", + ) + })? + .push(pending.clone()); + write_json_file(&path, &state)?; + Ok(json!({ + "ok": true, + "path": path.display().to_string(), + "pendingChange": pending, + })) +} + +fn write_sync_conflict_report_for_context( + context: &RequestContext, + request: SyncConflictReportRequest, +) -> Result { + let grant = + require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; + if !grant + .allowed_resource_ids + .iter() + .any(|resource_id| resource_id == &request.resource_id) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_sync_report_resource_not_shared", + "冲突报告资源不在 share grant 授权范围内", + )); + } + let root = parse_file_root_uri(&request.root_uri)? + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_sync_report_root_unavailable", + format!("无法访问同步报告 root: {error}"), + ) + })?; + let report = json!({ + "schema": "mnote.sync_conflict_report.v1", + "source": "shared_sync", + "shareId": grant.share_id, + "resourceId": request.resource_id, + "localVersion": request.local_version, + "remoteVersion": request.remote_version, + "baseVersion": request.base_version, + "actorId": if request.actor_id.trim().is_empty() { context.auth.actor_id.clone() } else { request.actor_id }, + "summary": request.summary, + "createdAt": now_ms(), + "suggestedActions": ["accept_local", "accept_remote", "open_diff_merge"], + }); + let dir = sync_report_dir(&root); + fs::create_dir_all(&dir).map_err(|error| { + WebError::bad_request_code( + "local_sync_report_dir_create_failed", + format!("无法创建同步报告目录 {}: {error}", dir.display()), + ) + })?; + let path = dir.join(format!( + "sync-conflict-{}-{}.json", + sanitize_file_name(&grant.share_id, "share"), + now_ms() + )); + write_json_file(&path, &report)?; + Ok(json!({ + "ok": true, + "path": path.display().to_string(), + "report": report, + })) +} + fn local_access_policy_payload(policy: &LocalAccessPolicy) -> Value { json!({ "ok": true, @@ -863,6 +1553,59 @@ pub async fn delete_local_access_grant( Ok((StatusCode::OK, Json(payload))) } +pub async fn get_share_grants( + Extension(context): Extension, +) -> Result<(StatusCode, Json), WebError> { + let payload = list_local_share_grants_for_context(&context) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn create_share_grant( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = add_local_share_grant_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn delete_share_grant( + Extension(context): Extension, + AxumPath(share_id): AxumPath, +) -> Result<(StatusCode, Json), WebError> { + let payload = revoke_local_share_grant_for_context(&context, &share_id) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn record_shared_cache( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = record_shared_cache_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn record_sync_pending_change( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = record_sync_pending_change_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn write_sync_conflict_report( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = write_sync_conflict_report_for_context(&context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + #[cfg(test)] pub(crate) fn initialize_local_workspace_for_actor( actor_id: &str, @@ -1092,6 +1835,7 @@ pub fn load_local_folder_file_tree_snapshot( )); } + let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); @@ -1101,6 +1845,7 @@ pub fn load_local_folder_file_tree_snapshot( None, 0, &root_source_uri, + &workspace_id, &metadata, &mut rows, )?; @@ -1112,7 +1857,7 @@ pub fn load_local_folder_file_tree_snapshot( let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; let dataset = json!({ "workspace": { - "id": local_workspace_id(&canonical_root), + "id": &workspace_id, "sourceKind": "local_folder", "rootUri": root_source_uri, }, @@ -1155,6 +1900,7 @@ pub fn load_local_folder_page_tree_snapshot( )); } + let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); @@ -1165,6 +1911,8 @@ pub fn load_local_folder_page_tree_snapshot( 0, &metadata, &mut rows, + &workspace_id, + &root_source_uri, )?; let child_counts = rows .iter() @@ -3121,6 +3869,7 @@ fn scan_directory( parent_node_id: Option, depth: u32, root_source_uri: &str, + workspace_id: &str, metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result<(), WebError> { @@ -3165,6 +3914,8 @@ fn scan_directory( None }, capabilities: local_entry_capabilities(&entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), }); if entry.is_dir && !entry.is_symlink { scan_directory( @@ -3173,6 +3924,7 @@ fn scan_directory( Some(node_id), depth + 1, root_source_uri, + workspace_id, metadata, rows, )?; @@ -3200,6 +3952,8 @@ fn scan_directory( expanded_by_default: false, document_id: None, capabilities: local_path_capabilities(root), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), }); } @@ -3336,6 +4090,8 @@ fn scan_markdown_page_tree( depth: u32, metadata: &LocalFolderMetadata, rows: &mut Vec, + workspace_id: &str, + root_source_uri: &str, ) -> Result { let entries = read_sorted_entries(directory, root)?; let mut directory_rows = Vec::::new(); @@ -3351,6 +4107,8 @@ fn scan_markdown_page_tree( depth + 1, metadata, &mut child_rows, + workspace_id, + root_source_uri, )?; if child_contains_markdown { directory_rows.push(LocalFolderRow { @@ -3369,6 +4127,8 @@ fn scan_markdown_page_tree( expanded_by_default: depth < 2, document_id: None, capabilities: local_entry_capabilities(&entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), }); directory_rows.extend(child_rows); contains_markdown = true; @@ -3396,6 +4156,8 @@ fn scan_markdown_page_tree( expanded_by_default: false, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), }); contains_markdown = true; } @@ -3478,6 +4240,34 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { item["assetId"] = Value::String(asset_id.clone()); item["resourceMeta"]["assetId"] = Value::String(asset_id); } + + // Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath + let object_kind = match row.row_kind.as_str() { + "markdown" | "document" => KernelObjectKind::Page, + "folder" | "index" => KernelObjectKind::Index, + "asset" => match row.icon_hint.as_str() { + "mindmap" => KernelObjectKind::Mindmap, + "onlyoffice" | "office" => KernelObjectKind::OnlyOffice, + _ => KernelObjectKind::Attachment, + }, + _ => KernelObjectKind::Attachment, + }; + let workspace_path = ObjectWorkspacePath { + workspace_id: row.workspace_id.clone(), + source_kind: WorkspaceSourceKind::LocalFolder, + root_uri: row.root_source_uri.clone(), + relative_path: row.relative_path.clone(), + object_identity: KernelObjectIdentity { + object_kind, + document_id: row.document_id.clone(), + block_id: None, + asset_id: None, + }, + resource_kind: Some(row.row_kind.clone()), + }; + if let Ok(path_value) = serde_json::to_value(&workspace_path) { + item["resourceMeta"]["workspacePath"] = path_value; + } item } @@ -4451,14 +5241,17 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val mod tests { use super::{ add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, - create_local_access_grant, ensure_local_path_read_access, + create_local_access_grant, create_share_grant, ensure_local_path_read_access, ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, - execute_local_tree_command, get_local_access_policy, initialize_local_page_id, - initialize_local_workspace_for_actor, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, open_local_file, resolve_local_markdown_page_aggregate, + execute_local_tree_command, get_local_access_policy, get_share_grants, + initialize_local_page_id, initialize_local_workspace_for_actor, + load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file, + record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, save_local_markdown_page, validate_local_access_root, write_local_markdown_asset, - write_local_markdown_page_body, LocalAccessGrantRequest, LocalAccessValidateRootRequest, - LocalFileOpenQuery, LocalUploadFile, + write_local_markdown_page_body, write_sync_conflict_report, LocalAccessGrantRequest, + LocalAccessValidateRootRequest, LocalFileOpenQuery, LocalShareGrantRequest, + LocalUploadFile, SharedCacheRecordRequest, SyncConflictReportRequest, + SyncPendingChangeRequest, }; use crate::context::RequestContext; use axum::extract::{Extension, Path as AxumPath, Query}; @@ -5343,6 +6136,237 @@ fn main() {} let _ = std::fs::remove_dir_all(&policy_root); } + #[tokio::test] + async fn share_grant_admin_api_creates_reads_and_revokes_without_local_access() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-share-grant-workspace-root"); + let config_root = temp_root("mnote-share-grant-config-root"); + let access_policy_file = config_root.join("access-policy.json"); + let share_grants_file = config_root.join("share-grants.json"); + let root_uri = format!("file://{}", root.display()); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + + let non_admin_error = get_share_grants(Extension(request_context("user_1", "user"))) + .await + .expect_err("non-admin cannot read share grants"); + assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN); + + let (_, Json(created)) = create_share_grant( + Extension(request_context("admin_1", "user")), + Json(LocalShareGrantRequest { + id: "share_grant_1".into(), + share_id: "share_1".into(), + owner_user_id: "owner_1".into(), + target_user_id: "target_1".into(), + root_uri: root_uri.clone(), + root_path: String::new(), + document_id: "doc_1".into(), + allowed_resource_ids: vec!["doc_1".into()], + permission: "read".into(), + capabilities: vec!["ai".into(), "share".into()], + }), + ) + .await + .expect("admin can create share grant"); + assert_eq!(created["grant"]["id"], "share_grant_1"); + assert_eq!(created["grant"]["shareId"], "share_1"); + assert_eq!(created["grant"]["active"], true); + + let read_error = + ensure_local_workspace_read_access_for_actor("target_1", "user", &root_uri) + .expect_err("share grant must not grant local filesystem read access"); + assert_eq!(read_error.status(), StatusCode::FORBIDDEN); + let write_error = ensure_local_workspace_access_for_actor("target_1", "user", &root_uri) + .expect_err("share grant must not grant local filesystem write access"); + assert_eq!(write_error.status(), StatusCode::FORBIDDEN); + + let (_, Json(listed)) = get_share_grants(Extension(request_context("admin_1", "user"))) + .await + .expect("admin can list share grants"); + assert_eq!(listed["grants"][0]["id"], "share_grant_1"); + assert_eq!(listed["grants"][0]["active"], true); + + let (_, Json(revoked)) = super::delete_share_grant( + Extension(request_context("admin_1", "user")), + AxumPath("share_1".into()), + ) + .await + .expect("admin can revoke by share id"); + assert_eq!(revoked["revokedShareId"], "share_1"); + assert_eq!(revoked["grant"]["active"], false); + assert!( + revoked["grant"]["revokedAt"] + .as_str() + .unwrap_or_default() + .len() + > 0 + ); + + let stored = std::fs::read_to_string(&share_grants_file).expect("read share grants file"); + let stored_json: Value = serde_json::from_str(&stored).expect("share grants json"); + assert_eq!(stored_json["grants"][0]["active"], false); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&config_root); + } + + #[tokio::test] + async fn shared_cache_and_sync_state_record_permissions_and_conflict_report() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-shared-cache-sync-root"); + let config_root = temp_root("mnote-shared-cache-sync-config"); + let access_policy_file = config_root.join("access-policy.json"); + let share_grants_file = config_root.join("share-grants.json"); + let root_uri = format!("file://{}", root.display()); + std::fs::create_dir_all(root.join(".mnote")).expect("metadata"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + + let (read_status, Json(read_share_payload)) = create_share_grant( + Extension(request_context("admin_1", "user")), + Json(LocalShareGrantRequest { + id: "share_grant_sync_read".into(), + share_id: "share_sync_read".into(), + owner_user_id: "owner_1".into(), + target_user_id: "target_1".into(), + root_uri: root_uri.clone(), + root_path: String::new(), + document_id: "local-md:README.md".into(), + allowed_resource_ids: vec!["local-md:README.md".into()], + permission: "read".into(), + capabilities: vec!["ai".into(), "share".into()], + }), + ) + .await + .expect("create read share"); + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read_share_payload["grant"]["shareId"], "share_sync_read"); + let (write_status, Json(write_share_payload)) = create_share_grant( + Extension(request_context("admin_1", "user")), + Json(LocalShareGrantRequest { + id: "share_grant_sync_write".into(), + share_id: "share_sync_write".into(), + owner_user_id: "owner_1".into(), + target_user_id: "target_1".into(), + root_uri: root_uri.clone(), + root_path: String::new(), + document_id: "local-md:README.md".into(), + allowed_resource_ids: vec!["local-md:README.md".into()], + permission: "write".into(), + capabilities: vec!["ai".into(), "share".into()], + }), + ) + .await + .expect("create write share"); + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write_share_payload["grant"]["shareId"], "share_sync_write"); + + let (_, Json(cache_payload)) = record_shared_cache( + Extension(request_context("target_1", "user")), + Json(SharedCacheRecordRequest { + root_uri: root_uri.clone(), + share_id: "share_sync_read".into(), + permission: "read".into(), + remote_version: "remote-1".into(), + base_version: "base-1".into(), + source_actor: "owner_1".into(), + }), + ) + .await + .expect("record cache"); + assert_eq!(cache_payload["share"]["remoteVersion"], "remote-1"); + let cache_json = + std::fs::read_to_string(root.join(".mnote").join("share-cache.json")).expect("cache"); + assert!(cache_json.contains("share_sync_read")); + assert!(cache_json.contains("remote-1")); + + let read_pending_error = record_sync_pending_change( + Extension(request_context("target_1", "user")), + Json(SyncPendingChangeRequest { + root_uri: root_uri.clone(), + share_id: "share_sync_read".into(), + resource_id: "local-md:README.md".into(), + local_version: "local-1".into(), + base_version: "remote-1".into(), + remote_version: "remote-1".into(), + change_summary: "read share write attempt".into(), + }), + ) + .await + .expect_err("shared_read cannot write pending changes"); + assert_eq!(read_pending_error.status(), StatusCode::FORBIDDEN); + + let (_, Json(pending_payload)) = record_sync_pending_change( + Extension(request_context("target_1", "user")), + Json(SyncPendingChangeRequest { + root_uri: root_uri.clone(), + share_id: "share_sync_write".into(), + resource_id: "local-md:README.md".into(), + local_version: "local-2".into(), + base_version: "remote-1".into(), + remote_version: "remote-1".into(), + change_summary: "写入共享页面".into(), + }), + ) + .await + .expect("shared_write can record pending change"); + assert_eq!( + pending_payload["pendingChange"]["resourceId"], + "local-md:README.md" + ); + let sync_state = + std::fs::read_to_string(root.join(".mnote").join("sync-state.json")).expect("sync"); + assert!(sync_state.contains("share_sync_write")); + assert!(sync_state.contains("写入共享页面")); + + let conflict_error = record_sync_pending_change( + Extension(request_context("target_1", "user")), + Json(SyncPendingChangeRequest { + root_uri: root_uri.clone(), + share_id: "share_sync_write".into(), + resource_id: "local-md:README.md".into(), + local_version: "local-3".into(), + base_version: "remote-1".into(), + remote_version: "remote-2".into(), + change_summary: "冲突写入".into(), + }), + ) + .await + .expect_err("remote changed since base"); + assert_eq!(conflict_error.status(), StatusCode::CONFLICT); + + let (_, Json(report_payload)) = write_sync_conflict_report( + Extension(request_context("target_1", "user")), + Json(SyncConflictReportRequest { + root_uri, + share_id: "share_sync_write".into(), + resource_id: "local-md:README.md".into(), + local_version: "local-3".into(), + remote_version: "remote-2".into(), + base_version: "remote-1".into(), + actor_id: "target_1".into(), + summary: "同步冲突".into(), + }), + ) + .await + .expect("write conflict report"); + assert_eq!(report_payload["report"]["source"], "shared_sync"); + assert_eq!(report_payload["report"]["shareId"], "share_sync_write"); + assert!(root.join(".mnote").join("sync-reports").is_dir()); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&config_root); + } + #[test] fn create_default_local_workspace_uses_managed_data_root() { let base = temp_root("mnote-local-managed-data-root"); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d33b5e49..9c69b992 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -127,6 +127,27 @@ pub fn build_router(state: AppState) -> Router { "/api/admin/access-policy/grants/{grant_id}", delete(local_folder_source::delete_local_access_grant), ) + .route( + "/api/admin/share-grants", + get(local_folder_source::get_share_grants) + .post(local_folder_source::create_share_grant), + ) + .route( + "/api/admin/share-grants/{share_id}", + delete(local_folder_source::delete_share_grant), + ) + .route( + "/api/local-folder/shared-cache/record", + post(local_folder_source::record_shared_cache), + ) + .route( + "/api/local-folder/sync/pending-change", + post(local_folder_source::record_sync_pending_change), + ) + .route( + "/api/local-folder/sync/conflict-report", + post(local_folder_source::write_sync_conflict_report), + ) .route("/api/ai-agent/run", post(compat::next_ai_agent_run)) .route( "/api/page-ai/block-edit-workflow", diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs index 6d5aa730..25dc788f 100644 --- a/rust/crates/mnote-web/src/ssr/pages/admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs @@ -7,6 +7,7 @@ use leptos::prelude::*; pub fn AdminAccessPolicyPage( #[prop(optional)] workspace_name: Option, #[prop(optional)] policy_path: Option, + #[prop(optional)] share_grants_path: Option, ) -> impl IntoView { let workspace_name = workspace_name .unwrap_or_else(|| "开发用户 的空间".to_string()) @@ -14,6 +15,8 @@ pub fn AdminAccessPolicyPage( .to_string(); let policy_path = policy_path .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()); + let share_grants_path = share_grants_path + .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/share-grants.json".to_string()); view! {
@@ -31,6 +34,10 @@ pub fn AdminAccessPolicyPage( "策略文件" {policy_path.clone()} +
+ "分享授权文件" + {share_grants_path.clone()} +
@@ -104,6 +111,76 @@ pub fn AdminAccessPolicyPage(

                     
                 
+ +
+
+

"分享授权"

+ +
+
{""}
+
+
+ +
+
+

"新增分享授权"

+ + + + + + + + + + + +

+                    
+ +
+

"撤销分享授权"

+ + +

+                    
+
@@ -116,10 +193,15 @@ const ADMIN_POLICY_SCRIPT: &str = r#" if (!root) return; var message = root.querySelector('[data-testid="mnote-admin-policy-message"]'); var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]'); + var shareGrantsJson = root.querySelector('[data-testid="mnote-admin-share-grants-json"]'); var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]'); var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]'); var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]'); + var shareGrantsMessage = root.querySelector('[data-testid="mnote-admin-share-grants-message"]'); + var createShareGrantResult = root.querySelector('[data-testid="mnote-admin-create-share-grant-result"]'); + var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]'); var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]'); + var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]'); function setText(node, value) { if (!node) return; @@ -143,6 +225,29 @@ const ADMIN_POLICY_SCRIPT: &str = r#" }; } + function splitList(value) { + return String(value || '') + .split(/[,\s]+/) + .map(function (item) { return item.trim(); }) + .filter(Boolean); + } + + function shareGrantFormValues(form) { + var data = new FormData(form); + return { + id: String(data.get('shareGrantId') || '').trim(), + shareId: String(data.get('shareId') || '').trim(), + ownerUserId: String(data.get('ownerUserId') || '').trim(), + targetUserId: String(data.get('targetUserId') || '').trim(), + rootUri: String(data.get('shareRootUri') || '').trim(), + rootPath: String(data.get('shareRootPath') || '').trim(), + documentId: String(data.get('documentId') || '').trim(), + allowedResourceIds: splitList(data.get('allowedResourceIds')), + permission: String(data.get('sharePermission') || 'read').trim(), + capabilities: splitList(data.get('shareCapabilities')), + }; + } + async function requestJson(url, options) { var response = await fetch(url, { credentials: 'include', @@ -162,11 +267,22 @@ const ADMIN_POLICY_SCRIPT: &str = r#" setText(message, '已刷新策略'); } + async function refreshShareGrants() { + var payload = await requestJson('/api/admin/share-grants', { method: 'GET', headers: {} }); + setText(shareGrantsJson, payload); + setText(shareGrantsMessage, '已刷新分享授权'); + } + refreshButton && refreshButton.addEventListener('click', function () { setText(message, '正在刷新策略...'); refreshPolicy().catch(function (error) { setText(message, error.message || '刷新失败'); }); }); + refreshShareGrantsButton && refreshShareGrantsButton.addEventListener('click', function () { + setText(shareGrantsMessage, '正在刷新分享授权...'); + refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || '刷新失败'); }); + }); + root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) { event.preventDefault(); var values = formValues(event.currentTarget); @@ -218,9 +334,47 @@ const ADMIN_POLICY_SCRIPT: &str = r#" }); }); + root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) { + event.preventDefault(); + var values = shareGrantFormValues(event.currentTarget); + setText(createShareGrantResult, '正在创建...'); + requestJson('/api/admin/share-grants', { + method: 'POST', + body: JSON.stringify(values), + }).then(function (payload) { + setText(createShareGrantResult, payload); + setText(shareGrantsMessage, '分享授权已创建'); + return refreshShareGrants(); + }).catch(function (error) { + setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' }); + setText(shareGrantsMessage, error.message || '创建失败'); + }); + }); + + root.querySelector('[data-admin-form="delete-share-grant"]').addEventListener('submit', function (event) { + event.preventDefault(); + var data = new FormData(event.currentTarget); + var shareId = String(data.get('deleteShareId') || '').trim(); + setText(deleteShareGrantResult, '正在撤销...'); + requestJson('/api/admin/share-grants/' + encodeURIComponent(shareId), { + method: 'DELETE', + headers: {}, + }).then(function (payload) { + setText(deleteShareGrantResult, payload); + setText(shareGrantsMessage, '分享授权已撤销'); + return refreshShareGrants(); + }).catch(function (error) { + setText(deleteShareGrantResult, { ok: false, error: error.message || '撤销失败' }); + setText(shareGrantsMessage, error.message || '撤销失败'); + }); + }); + refreshPolicy().catch(function (error) { setText(message, error.message || '加载策略失败'); }); + refreshShareGrants().catch(function (error) { + setText(shareGrantsMessage, error.message || '加载分享授权失败'); + }); })(); "#; @@ -239,8 +393,12 @@ mod tests { }); assert!(html.contains("mnote-admin-access-policy-page")); assert!(html.contains("mnote-admin-policy-json")); + assert!(html.contains("mnote-admin-share-grants-panel")); + assert!(html.contains("mnote-admin-share-grants-json")); assert!(html.contains("mnote-admin-validate-root-submit")); assert!(html.contains("mnote-admin-create-grant-submit")); assert!(html.contains("mnote-admin-delete-grant-submit")); + assert!(html.contains("mnote-admin-create-share-grant-submit")); + assert!(html.contains("mnote-admin-delete-share-grant-submit")); } }