Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+203 -2
View File
@@ -166,6 +166,46 @@ pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens,
})
}
/// 校验 OnlyOffice Document Server 回调 JWTHS256)。
/// - `secret` 为空:返回 `Ok(None)`(与 `sign_config` 一致,本地未配置 JWT 时放行)。
/// - `secret` 非空:必须提供有效 token,成功时返回解码后的 payload。
pub fn verify_callback_jwt(
token: Option<&str>,
secret: &str,
) -> Result<Option<Value>, String> {
let normalized_secret = normalize_secret(secret);
if normalized_secret.is_empty() {
return Ok(None);
}
let raw = token
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "OnlyOffice 回调缺少 JWT token".to_string())?;
let payload = verify_hs256(raw, &normalized_secret)?;
Ok(Some(payload))
}
/// 从回调 body / Authorization 提取 JWT 字符串。
/// 优先 body.token;其次 Authorization: Bearer <jwt>。
pub fn extract_callback_jwt(body: &Value, authorization: Option<&str>) -> Option<String> {
if let Some(token) = body.get("token").and_then(Value::as_str) {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let auth = authorization?.trim();
let bearer = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))?;
let trimmed = bearer.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult {
let session_id = input
.session_id
@@ -281,7 +321,7 @@ pub fn prepare_proxy_request(
.host_str()
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
if is_private_ipv4(hostname)
if is_private_or_local_ip_literal(hostname)
&& !is_local_hostname(hostname)
&& !allowed_hostnames.contains(hostname)
{
@@ -490,6 +530,53 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
Ok(format!("{signing_input}.{signature_part}"))
}
fn verify_hs256(token: &str, secret: &str) -> Result<Value, String> {
let mut parts = token.split('.');
let header_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let payload_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
let signature_part = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| "JWT 格式无效".to_string())?;
if parts.next().is_some() {
return Err("JWT 格式无效".to_string());
}
let header_bytes = URL_SAFE_NO_PAD
.decode(header_part)
.map_err(|_| "JWT header 无法解码".to_string())?;
let header: Value = serde_json::from_slice(&header_bytes)
.map_err(|_| "JWT header 不是合法 JSON".to_string())?;
let alg = header
.get("alg")
.and_then(Value::as_str)
.unwrap_or_default();
if !alg.eq_ignore_ascii_case("HS256") {
return Err(format!("不支持的 JWT alg: {alg}"));
}
let signature = URL_SAFE_NO_PAD
.decode(signature_part)
.map_err(|_| "JWT signature 无法解码".to_string())?;
let signing_input = format!("{header_part}.{payload_part}");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
mac.update(signing_input.as_bytes());
mac.verify_slice(&signature)
.map_err(|_| "JWT 签名校验失败".to_string())?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_part)
.map_err(|_| "JWT payload 无法解码".to_string())?;
serde_json::from_slice(&payload_bytes).map_err(|_| "JWT payload 不是合法 JSON".to_string())
}
fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
let bytes = URL_SAFE_NO_PAD
.decode(input.trim())
@@ -498,7 +585,11 @@ fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
}
fn is_local_hostname(hostname: &str) -> bool {
matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal")
let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
matches!(
h,
"127.0.0.1" | "localhost" | "host.docker.internal" | "::1" | "0:0:0:0:0:0:0:1"
)
}
fn is_private_ipv4(hostname: &str) -> bool {
@@ -522,6 +613,88 @@ fn is_private_ipv4(hostname: &str) -> bool {
|| a == 0
}
/// Block IPv6 loopback / ULA / link-local / IPv4-mapped / IPv4-compatible private literals.
fn is_private_or_local_ip_literal(hostname: &str) -> bool {
let h = hostname.trim().trim_matches(|c| c == '[' || c == ']');
if is_private_ipv4(h) {
return true;
}
let lower = h.to_ascii_lowercase();
if lower == "::1" || lower == "0:0:0:0:0:0:0:1" {
return true;
}
// link-local fe80::/10 and ULA fc00::/7
if lower.starts_with("fe80:")
|| lower.starts_with("fc")
|| lower.starts_with("fd")
|| lower.starts_with("fe8")
|| lower.starts_with("fe9")
|| lower.starts_with("fea")
|| lower.starts_with("feb")
{
return true;
}
// IPv4-mapped ::ffff:a.b.c.d
if let Some(v4) = lower.strip_prefix("::ffff:") {
if is_private_ipv4(v4) || ipv4_compat_hextets_are_private(v4) {
return true;
}
}
// IPv4-compatible / compressed forms embedding private IPv4:
// ::127.0.0.1, ::7f00:1, 0:0:0:0:0:0:7f00:1, ::ffff:7f00:1
if let Some(embedded) = extract_embedded_ipv4_from_v6_literal(&lower) {
if is_private_ipv4(&embedded) {
return true;
}
}
false
}
fn ipv4_compat_hextets_are_private(tail: &str) -> bool {
// tail may already be dotted quad or two hextets like 7f00:1
if is_private_ipv4(tail) {
return true;
}
extract_ipv4_from_two_hextets(tail)
.map(|v4| is_private_ipv4(&v4))
.unwrap_or(false)
}
fn extract_embedded_ipv4_from_v6_literal(lower: &str) -> Option<String> {
if let Some(v4) = lower.strip_prefix("::") {
if is_private_ipv4(v4) {
return Some(v4.to_string());
}
if let Some(dotted) = extract_ipv4_from_two_hextets(v4) {
return Some(dotted);
}
}
// full form ...:0:0:a.b.c.d or ...:7f00:1
let parts: Vec<&str> = lower.split(':').filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
let last2 = format!("{}:{}", parts[parts.len() - 2], parts[parts.len() - 1]);
if let Some(dotted) = extract_ipv4_from_two_hextets(&last2) {
return Some(dotted);
}
let last = parts[parts.len() - 1];
if is_private_ipv4(last) {
return Some(last.to_string());
}
}
None
}
fn extract_ipv4_from_two_hextets(tail: &str) -> Option<String> {
let (hi, lo) = tail.split_once(':')?;
let hi = u16::from_str_radix(hi, 16).ok()?;
let lo = u16::from_str_radix(lo, 16).ok()?;
let a = (hi >> 8) as u8;
let b = (hi & 0xff) as u8;
let c = (lo >> 8) as u8;
let d = (lo & 0xff) as u8;
Some(format!("{a}.{b}.{c}.{d}"))
}
fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
let value = raw?.trim();
if value.is_empty() {
@@ -579,6 +752,34 @@ mod tests {
assert!(tokens.editor_config_token.is_some());
}
#[test]
fn verify_callback_jwt_roundtrip_and_rejects_tamper() {
let payload = json!({"status": 2, "url": "http://example/file.docx", "key": "k1"});
let token = sign_hs256(&payload, "secret").expect("sign");
let verified = verify_callback_jwt(Some(&token), "secret")
.expect("verify")
.expect("payload present");
assert_eq!(verified.get("status").and_then(Value::as_i64), Some(2));
assert!(verify_callback_jwt(Some(&token), "wrong").is_err());
assert!(verify_callback_jwt(None, "secret").is_err());
assert!(verify_callback_jwt(Some(&token), "").unwrap().is_none());
}
#[test]
fn extract_callback_jwt_prefers_body_then_bearer() {
let body = json!({"token": " body.jwt ", "status": 2});
assert_eq!(
extract_callback_jwt(&body, Some("Bearer header.jwt")),
Some("body.jwt".into())
);
let body_empty = json!({"status": 2});
assert_eq!(
extract_callback_jwt(&body_empty, Some("Bearer header.jwt")),
Some("header.jwt".into())
);
assert_eq!(extract_callback_jwt(&body_empty, None), None);
}
#[test]
fn proxy_request_rewrites_supabase_path_to_internal_origin() {
let encoded_url = URL_SAFE_NO_PAD.encode(
+49 -2
View File
@@ -48,7 +48,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct BridgeContext {
pub deployment_id: Option<String>,
pub project_id: Option<String>,
@@ -67,6 +67,31 @@ pub struct BridgeContext {
pub dry_run: bool,
}
impl std::fmt::Debug for BridgeContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BridgeContext")
.field("deployment_id", &self.deployment_id)
.field("project_id", &self.project_id)
.field("request_id", &self.request_id)
.field("trace_id", &self.trace_id)
.field("actor_type", &self.actor_type)
.field("actor_id", &self.actor_id)
.field("session_id", &self.session_id)
.field("workspace_id", &self.workspace_id)
.field("tenant_id", &self.tenant_id)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "<redacted>"),
)
.field("source_channel", &self.source_channel)
.field("source_client", &self.source_client)
.field("idempotency_key", &self.idempotency_key)
.field("validate_only", &self.validate_only)
.field("dry_run", &self.dry_run)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BridgeErrorKind {
Validation,
@@ -271,7 +296,7 @@ pub enum RuntimeInput {
},
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeBridgeContextWire {
pub deployment_id: Option<String>,
@@ -288,6 +313,28 @@ pub struct RuntimeBridgeContextWire {
pub dry_run: bool,
}
impl std::fmt::Debug for RuntimeBridgeContextWire {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeBridgeContextWire")
.field("deployment_id", &self.deployment_id)
.field("project_id", &self.project_id)
.field("workspace_id", &self.workspace_id)
.field("request_id", &self.request_id)
.field("trace_id", &self.trace_id)
.field("actor", &self.actor)
.field("source", &self.source)
.field("tenant_id", &self.tenant_id)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "<redacted>"),
)
.field("idempotency_key", &self.idempotency_key)
.field("validate_only", &self.validate_only)
.field("dry_run", &self.dry_run)
.finish()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeActorWire {
@@ -46,7 +46,7 @@ enum Backend {
TursoSynced,
}
#[derive(Debug, Clone)]
#[derive(Clone)]
struct BackendConfig {
backend: Backend,
sqlite_path: Option<PathBuf>,
@@ -57,13 +57,60 @@ struct BackendConfig {
turso_token: Option<String>,
}
#[derive(Debug, Clone)]
impl std::fmt::Debug for BackendConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackendConfig")
.field("backend", &self.backend)
.field("sqlite_path", &self.sqlite_path)
.field("libsql_local_path", &self.libsql_local_path)
.field("replica_path", &self.replica_path)
.field("synced_path", &self.synced_path)
.field("turso_url", &self.turso_url)
.field(
"turso_token",
&self.turso_token.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
#[derive(Clone)]
struct Args {
command: String,
flags: BTreeMap<String, String>,
switches: BTreeSet<String>,
}
impl std::fmt::Debug for Args {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// flags 可能含 password / turso-token 等敏感值
const SENSITIVE_FLAGS: &[&str] = &[
"password",
"turso-token",
"token",
"auth-token",
"secret",
"api-key",
];
let redacted_flags: BTreeMap<&String, String> = self
.flags
.iter()
.map(|(k, v)| {
let key_l = k.to_ascii_lowercase();
let hide = SENSITIVE_FLAGS
.iter()
.any(|s| key_l == *s || key_l.contains(s));
(k, if hide { "<redacted>".into() } else { v.clone() })
})
.collect();
f.debug_struct("Args")
.field("command", &self.command)
.field("flags", &redacted_flags)
.field("switches", &self.switches)
.finish()
}
}
enum StoreHandle {
Sqlite(SqliteControlPlaneStore),
Turso(TursoControlPlaneStore),
@@ -887,7 +934,7 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"backend": format!("{:?}", config.backend),
"planned": [
"run schema migrations",
"upsert default e2e/admin user",
"upsert default ops admin user (mnote-admin)",
"create password identity",
"upsert default local workspace",
"grant write access",
@@ -900,20 +947,46 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
}
let store = open_store(&config)?;
// 7-76 方案 A:默认 init 主体是 ops adminmnote-admin),不是 AI 主体 mnote-e2e。
let user_id = flag_or_env(args, "user-id", "MNOTE_CONTROL_PLANE_INIT_USER_ID")
.unwrap_or_else(|| "mnote-e2e".to_string());
.unwrap_or_else(|| "mnote-admin".to_string());
let email = flag_or_env(args, "email", "MNOTE_CONTROL_PLANE_INIT_EMAIL")
.unwrap_or_else(|| "mnote.e2e@example.com".to_string());
.unwrap_or_else(|| "mnote.admin@example.com".to_string());
let username = flag_or_env(args, "username", "MNOTE_CONTROL_PLANE_INIT_USERNAME")
.unwrap_or_else(|| "mnote-e2e".to_string());
.unwrap_or_else(|| "mnote-admin".to_string());
let display_name = flag_or_env(
args,
"display-name",
"MNOTE_CONTROL_PLANE_INIT_DISPLAY_NAME",
)
.unwrap_or_else(|| username.clone());
let password = flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD")
.unwrap_or_else(|| "MnoteE2E123!".to_string());
// 禁止硬编码默认管理员密码。生产/本地 init 必须显式传入:
// --password <secret> 或 MNOTE_CONTROL_PLANE_INIT_PASSWORD
// 仅当显式打开不安全开关时,才允许本地已知默认口令(仅 smoke)。
let password = match flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD") {
Some(value) => value,
None => {
let allow_insecure = std::env::var("MNOTE_CONTROL_PLANE_ALLOW_INSECURE_DEFAULT_PASSWORD")
.ok()
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
})
.unwrap_or(false);
if allow_insecure {
// 与 scripts/TESTING_REFERENCE 本地 admin 口令对齐(仅 insecure 开关)
"MnoteAdmin123!".to_string()
} else {
return Err(
"init 需要管理员密码:请传 --password 或设置 MNOTE_CONTROL_PLANE_INIT_PASSWORD\
本地若必须使用默认口令,需额外设置 MNOTE_CONTROL_PLANE_ALLOW_INSECURE_DEFAULT_PASSWORD=1"
.into(),
);
}
}
};
let workspace_id = flag_or_env(
args,
"workspace-id",
@@ -925,26 +998,37 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"workspace-name",
"MNOTE_CONTROL_PLANE_INIT_WORKSPACE_NAME",
)
.unwrap_or_else(|| "MNote E2E Workspace".to_string());
.unwrap_or_else(|| "MNote Admin Workspace".to_string());
let root_path = flag_or_env(args, "root-path", "MNOTE_CONTROL_PLANE_INIT_ROOT_PATH")
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/workspaces/default".to_string());
.unwrap_or_else(|| {
format!("/mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space")
});
let root_uri = flag_or_env(args, "root-uri", "MNOTE_CONTROL_PLANE_INIT_ROOT_URI")
.unwrap_or_else(|| format!("file://{root_path}"));
// rolemnote-e2e → ai_service;默认 admin 账号 → admin;其它可显式 --role
let role = flag_or_env(args, "role", "MNOTE_CONTROL_PLANE_INIT_ROLE").unwrap_or_else(|| {
if user_id == "mnote-e2e" || username == "mnote-e2e" {
"ai_service".to_string()
} else {
"admin".to_string()
}
});
let user = store.upsert_user(UpsertUserInput {
id: Some(user_id.clone()),
email: Some(email.clone()),
username: username.clone(),
display_name,
role: Some("admin".to_string()),
role: Some(role),
password_hash: None,
})?;
let _ = store.create_password_identity(control_plane::CreatePasswordIdentityInput {
store.create_password_identity(control_plane::CreatePasswordIdentityInput {
user_id: user.id.clone(),
email: Some(email),
username,
password,
});
})?;
let workspace = store.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace_id),
owner_user_id: user.id.clone(),
@@ -997,7 +1081,8 @@ fn cmd_init(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
"ok": true,
"backend": format!("{:?}", config.backend),
"userId": user.id,
"workspaceId": workspace.id
"workspaceId": workspace.id,
"role": user.role,
}))?
);
Ok(())
+90 -4
View File
@@ -19,12 +19,17 @@ pub struct UserRecord {
pub revision: i64,
}
/// 会话令牌哈希(领域前缀,避免与 share/password 等 token 类型混淆)。
/// 注意:改前缀会使既有 session 行失效,需用户重新登录。
pub fn session_token_hash(raw_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"mnote-session-token-v1:");
hasher.update(raw_token.as_bytes());
hex::encode(hasher.finalize())
format!("sha256-v1:{}", hex::encode(hasher.finalize()))
}
/// 密码哈希 v1:SHA-256 + 固定领域前缀(兼容既有 `sha256-v1:` 存档)。
/// 技术债:生产应迁移 Argon2id + 每用户随机盐;在此保持算法兼容以免批量锁死账号。
pub fn password_hash_v1(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"mnote-password-v1:");
@@ -39,27 +44,76 @@ pub fn share_token_hash_v1(token: &str) -> String {
format!("sha256-v1:{}", hex::encode(hasher.finalize()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertUserInput {
pub id: Option<EntityId>,
pub email: Option<String>,
pub username: String,
pub display_name: String,
pub role: Option<String>,
/// 密码哈希;Debug 脱敏,避免日志泄露。
pub password_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
impl std::fmt::Debug for UpsertUserInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpsertUserInput")
.field("id", &self.id)
.field("email", &self.email)
.field("username", &self.username)
.field("display_name", &self.display_name)
.field("role", &self.role)
.field(
"password_hash",
&self
.password_hash
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct CreatePasswordIdentityInput {
pub user_id: EntityId,
pub email: Option<String>,
pub username: String,
/// 明文密码仅用于创建身份;Debug/Serialize 必须脱敏。
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
impl Serialize for CreatePasswordIdentityInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("CreatePasswordIdentityInput", 4)?;
state.serialize_field("user_id", &self.user_id)?;
state.serialize_field("email", &self.email)?;
state.serialize_field("username", &self.username)?;
state.serialize_field("password", "<redacted>")?;
state.end()
}
}
impl std::fmt::Debug for CreatePasswordIdentityInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CreatePasswordIdentityInput")
.field("user_id", &self.user_id)
.field("email", &self.email)
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct AuthenticatePasswordInput {
pub account: String,
/// 明文密码仅用于鉴权;Debug/Serialize 脱敏。
pub password: String,
pub session_id: Option<EntityId>,
pub token_hash: String,
@@ -68,6 +122,38 @@ pub struct AuthenticatePasswordInput {
pub expires_at: Option<Timestamp>,
}
impl Serialize for AuthenticatePasswordInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("AuthenticatePasswordInput", 7)?;
state.serialize_field("account", &self.account)?;
state.serialize_field("password", "<redacted>")?;
state.serialize_field("session_id", &self.session_id)?;
state.serialize_field("token_hash", "<redacted>")?;
state.serialize_field("user_agent", &self.user_agent)?;
state.serialize_field("ip_hash", &self.ip_hash)?;
state.serialize_field("expires_at", &self.expires_at)?;
state.end()
}
}
impl std::fmt::Debug for AuthenticatePasswordInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthenticatePasswordInput")
.field("account", &self.account)
.field("password", &"<redacted>")
.field("session_id", &self.session_id)
.field("token_hash", &"<redacted>")
.field("user_agent", &self.user_agent)
.field("ip_hash", &self.ip_hash)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthSessionRecord {
pub id: EntityId,
+12 -6
View File
@@ -255,7 +255,8 @@ impl CommandContext {
editor_dirty: false,
editor_dirty_state: Some("clean".to_string()),
editor_has_selection: false,
ai_can_write: true,
// 最小权限:默认禁止 AI 写;调用方确认需要时再 with_ai_can_write(true)
ai_can_write: false,
}
}
@@ -351,11 +352,15 @@ impl CommandContext {
self
}
/// 用 builder 模式设置 editor dirty。
/// 用 builder 模式设置 editor dirty;与 `editor_dirty_state` 双向同步
pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
self.editor_dirty = dirty;
if dirty && self.editor_dirty_state.as_deref() == Some("clean") {
if dirty {
// dirty=true 时始终落到 dirty 状态(含从 clean/其它状态同步)
self.editor_dirty_state = Some("dirty".to_string());
} else {
// dirty=false 时重置为 clean,避免两字段语义分裂
self.editor_dirty_state = Some("clean".to_string());
}
self
}
@@ -700,10 +705,11 @@ pub fn check_when(ctx: &CommandContext, when_expr: &str) -> Result<bool, String>
/// 检查一个命令在指定上下文中是否应 enabled。
/// 如果 when 表达式为 None 或空,默认返回 true。
/// when 解析失败时 fail-closed:返回 false(禁止因坏表达式默认放开命令)。
pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool {
match when {
None | Some("") => true,
Some(expr) => check_when(ctx, expr).unwrap_or(true),
Some(expr) => check_when(ctx, expr).unwrap_or(false),
}
}
@@ -905,8 +911,8 @@ mod command_context_tests {
#[test]
fn command_context_parse_error_returns_default() {
let ctx = CommandContext::new();
// 解析错误 → is_command_enabled 返回 true(默认允许
assert!(is_command_enabled(&ctx, Some("invalid syntax &&&")));
// 解析错误 → fail-closedis_command_enabled 返回 false(禁止默认放开
assert!(!is_command_enabled(&ctx, Some("invalid syntax &&&")));
}
#[test]
+23 -1
View File
@@ -60,7 +60,14 @@ impl JobTicket {
}
pub fn decide_access(context: &AccessContext) -> AccessDecision {
if context.tenant_id.is_none() {
// None 与空串/纯空白均视为缺 tenant,避免 `Some("")` 绕过鉴权。
let tenant_missing = context
.tenant_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none();
if tenant_missing {
return AccessDecision::Deny(AccessDenyReason::MissingTenant);
}
if context.workspace_id.trim().is_empty() {
@@ -100,6 +107,21 @@ mod tests {
assert_eq!(decide_access(&context), AccessDecision::Allow);
}
#[test]
fn access_denies_empty_tenant_string() {
let context = AccessContext {
tenant_id: Some(" ".to_string()),
workspace_id: "ws-1".to_string(),
actor_id: "user-1".to_string(),
actor_type: "human".to_string(),
source: "react-next".to_string(),
};
assert_eq!(
decide_access(&context),
AccessDecision::Deny(AccessDenyReason::MissingTenant)
);
}
#[test]
fn job_ticket_keeps_trace_fields() {
let ticket = JobTicket::new(
+24 -1
View File
@@ -246,7 +246,13 @@ impl DocumentBuffer {
pub fn mark_dirty(&mut self, content_hash: String) {
self.current_content_hash = Some(content_hash);
self.dirty_state = DocBufferDirtyState::Dirty;
// Stale / Deleted 不可被普通编辑覆盖:保留外部冲突与删除语义。
match self.dirty_state {
DocBufferDirtyState::Stale | DocBufferDirtyState::Deleted => {}
_ => {
self.dirty_state = DocBufferDirtyState::Dirty;
}
}
}
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
@@ -298,6 +304,14 @@ impl DocumentBuffer {
pub fn mark_deleted(&mut self) {
self.dirty_state = DocBufferDirtyState::Deleted;
}
/// 从回收站 restore 后清除 deleted 标记,使已打开 buffer 可再次编辑。
/// 恢复为 Clean:文件已回到磁盘,当前 buffer 内容仍视为与 base 一致直至下一次外部变更检测。
pub fn clear_deleted(&mut self) {
if self.dirty_state == DocBufferDirtyState::Deleted {
self.dirty_state = DocBufferDirtyState::Clean;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1109,6 +1123,11 @@ mod tests {
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
// Stale 时继续编辑只更新 content_hash,不得降级回 Dirty。
buf.mark_dirty("sha256:stale-edit".into());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
assert_eq!(buf.current_content_hash.as_deref(), Some("sha256:stale-edit"));
buf.mark_saved("v2".into(), "sha256:saved".into());
assert!(!buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
@@ -1119,5 +1138,9 @@ mod tests {
buf.mark_deleted();
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
buf.mark_dirty("sha256:after-delete".into());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
buf.clear_deleted();
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
}
}
+2 -1
View File
@@ -77,7 +77,8 @@ pub struct MindmapKernelProjection {
pub source: MindmapProjectionSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
// style/refs 含 serde_json::Value(可能含 f64),不实现 Eq;仅保留 PartialEq
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapKernelNode {
pub node_id: String,
+68 -22
View File
@@ -765,8 +765,9 @@ fn upsert_match(
}
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
if output.join("\n").len() >= max_chars {
// 维护累计字节长度,避免每次递归 O(n²) join。
fn walk(node: &Value, output: &mut Vec<String>, total_len: &mut usize, max_chars: usize) {
if *total_len >= max_chars {
return;
}
match node {
@@ -775,26 +776,30 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
if let Some(Value::String(text)) = data.get("text") {
let normalized = normalize_text(text);
if !normalized.is_empty() {
if !output.is_empty() {
*total_len = total_len.saturating_add(1); // '\n'
}
*total_len = total_len.saturating_add(normalized.len());
output.push(normalized);
}
}
}
if let Some(Value::Array(children)) = map.get("children") {
for child in children {
walk(child, output, max_chars);
if output.join("\n").len() >= max_chars {
walk(child, output, total_len, max_chars);
if *total_len >= max_chars {
break;
}
}
}
if let Some(root) = map.get("root") {
walk(root, output, max_chars);
walk(root, output, total_len, max_chars);
}
}
Value::Array(items) => {
for item in items {
walk(item, output, max_chars);
if output.join("\n").len() >= max_chars {
walk(item, output, total_len, max_chars);
if *total_len >= max_chars {
break;
}
}
@@ -804,10 +809,18 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
}
let mut output = Vec::new();
walk(value, &mut output, max_chars);
let mut total_len = 0usize;
walk(value, &mut output, &mut total_len, max_chars);
let joined = output.join("\n").trim().to_string();
if joined.len() > max_chars {
format!("{}", &joined[..max_chars])
// 按 UTF-8 字符边界截断,避免 panic
let end = joined
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max_chars)
.last()
.unwrap_or(0);
format!("{}", &joined[..end])
} else {
joined
}
@@ -834,31 +847,45 @@ fn build_snippet(text: &str, keyword: &str) -> String {
if source.is_empty() {
return "暂无正文内容".into();
}
let escaped = escape_html(source);
if keyword.trim().is_empty() {
return truncate_snippet(&escaped);
return truncate_snippet(&escape_html(source));
}
// 先在原文上匹配,再 escape 匹配片段,避免在 `&amp;` 等实体内部插入 <mark>。
let regex = match RegexBuilder::new(&regex::escape(keyword))
.case_insensitive(true)
.build()
{
Ok(regex) => regex,
Err(_) => return truncate_snippet(&escaped),
Err(_) => return truncate_snippet(&escape_html(source)),
};
let Some(found) = regex.find(&escaped) else {
return truncate_snippet(&escaped);
let Some(found) = regex.find(source) else {
return truncate_snippet(&escape_html(source));
};
let start = found.start().saturating_sub(20);
let end = (found.end() + 80).min(escaped.len());
let segment = escaped[start..end].to_string();
regex
.replace_all(&segment, |captures: &regex::Captures<'_>| {
format!("<mark>{}</mark>", &captures[0])
})
.to_string()
// 按 UTF-8 字符边界取上下文,避免多字节字符中间切片 panic。
let match_start = found.start();
let match_end = found.end();
let target_start = match_start.saturating_sub(20);
let start = source
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= target_start)
.last()
.unwrap_or(0);
let target_end = (match_end + 80).min(source.len());
let end = source
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= target_end)
.unwrap_or(source.len());
let end = if end < start { source.len() } else { end };
let prefix = escape_html(&source[start..match_start]);
let matched = escape_html(&source[match_start..match_end]);
let suffix = escape_html(&source[match_end..end]);
format!("{prefix}<mark>{matched}</mark>{suffix}")
}
fn truncate_snippet(value: &str) -> String {
@@ -1120,4 +1147,23 @@ mod tests {
assert_eq!(result.results[0].evidence.len(), 1);
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
}
#[test]
fn build_snippet_does_not_break_html_entities() {
// 关键词 "amp" 若在转义后匹配,会破坏 `&amp;` 实体。
let snippet = build_snippet("x & y amp-word", "amp");
assert!(
!snippet.contains("&<mark>"),
"不得在 HTML 实体内部插入 mark: {snippet}"
);
assert!(
snippet.contains("<mark>") && snippet.contains("</mark>"),
"应高亮匹配词: {snippet}"
);
// 原文 & 应被 escape 为 &amp;,且实体完整
assert!(
snippet.contains("&amp;") || !snippet.contains('&'),
"原文 & 应被正确转义: {snippet}"
);
}
}
+12 -8
View File
@@ -210,22 +210,26 @@ fn move_block(
.iter()
.map(|block| block.id.clone())
.collect();
if let Some(target_id) = after_block_id {
if moved_ids.iter().any(|id| id == target_id) {
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
}
}
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
// 先算好 drain 后的插入点,避免「先 drain 再发现目标不存在」导致块丢失。
let insert_index = match after_block_id {
Some(target_id) => {
if moved_ids.iter().any(|id| id == target_id) {
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
}
let target_range = document
.subtree_range(target_id)
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
*target_range.end() + 1
let target_end = *target_range.end();
// drain 会把 [start..=end] 移除:目标在段后时索引左移 removed 个位置。
if target_end > end {
target_end + 1 - (end - start + 1)
} else {
target_end + 1
}
}
None => 0,
};
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
document
.blocks_mut()
.splice(insert_index..insert_index, moved_blocks);
+14 -1
View File
@@ -23,11 +23,13 @@ pub fn ai_vault_actor_id() -> String {
}
/// Encode actor id for managed path segment (aligned with mnote-web).
///
/// 不保留 `.` / `/` 等路径元字符,避免 actor=`..` 时穿越 `users/` 目录。
pub fn encode_actor_segment(actor_id: &str) -> String {
actor_id
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c.to_string(),
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c.to_string(),
other => format!("~{:02x}", other as u32),
})
.collect()
@@ -786,6 +788,17 @@ mod tests {
static AI_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn encode_actor_segment_rejects_path_traversal_dots() {
// `..` 不得原样保留,否则 join("users", "..") 可穿越目录。
let encoded = encode_actor_segment("..");
assert!(!encoded.contains('.'), "encoded={encoded}");
assert_eq!(encoded, "~2e~2e");
let dotted = encode_actor_segment("a.b");
assert!(!dotted.contains('.'), "encoded={dotted}");
assert_eq!(dotted, "a~2eb");
}
fn with_temp_workspace<F, R>(f: F) -> R
where
F: FnOnce(&Path) -> R,
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -108,10 +108,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, VaultError> {
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
// 生成侧写 32 字节 → 64 hex;读取侧要求一致,拒绝弱 key。
if hex.len() < 64 {
return Err(VaultError::bad_request_code(
"vault_hmac_key_invalid",
"HMAC key 过短",
"HMAC key 过短(需要至少 32 字节 / 64 hex)",
));
}
return hex::decode(hex).map_err(|e| {
+63 -42
View File
@@ -428,6 +428,36 @@ fn sock_reachable(path: &Path) -> bool {
}
}
/// Path-segment safe id for UDS HTTP paths (reject CRLF / query / slash inject).
fn sanitize_item_id_for_path(id: &str) -> Result<String, VaultError> {
let id = id.trim();
if id.is_empty() || id.len() > 128 {
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 无效",
));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 含非法字符",
));
}
if id.contains("..") {
return Err(VaultError::new(
VaultStatus::BadRequest,
"vault_item_id_invalid",
"vault item id 不得含 ..",
));
}
Ok(id.to_string())
}
/// HTTP/1.1 request over UDS; returns status + body.
#[cfg(unix)]
fn uds_http(
@@ -650,7 +680,7 @@ fn cmd_list(args: ListArgs) -> Result<Value, VaultError> {
}
fn cmd_get(args: GetArgs) -> Result<Value, VaultError> {
let id = args.id.clone();
let id = sanitize_item_id_for_path(&args.id)?;
with_transport(
args.local,
args.remote,
@@ -671,7 +701,7 @@ fn cmd_get(args: GetArgs) -> Result<Value, VaultError> {
}
fn cmd_resolve(args: ResolveArgs) -> Result<Value, VaultError> {
let id = args.id.clone();
let id = sanitize_item_id_for_path(&args.id)?;
let field = args.field.clone();
let account_id = args.account_id.clone();
let secret_id = args.secret_id.clone();
@@ -712,7 +742,7 @@ fn cmd_resolve(args: ResolveArgs) -> Result<Value, VaultError> {
}
fn cmd_login(args: LoginArgs) -> Result<Value, VaultError> {
let id = args.id.clone();
let id = sanitize_item_id_for_path(&args.id)?;
let force = args.force_refresh;
with_transport(
args.local,
@@ -740,7 +770,7 @@ fn cmd_login(args: LoginArgs) -> Result<Value, VaultError> {
}
fn cmd_session(args: SessionArgs) -> Result<Value, VaultError> {
let id = args.id.clone();
let id = sanitize_item_id_for_path(&args.id)?;
let cookie = args.cookie_header.clone();
let expires = args.expires_at.clone();
let source = args.source.clone();
@@ -962,14 +992,11 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "get") {
return err_response(e);
}
let id = p.trim_start_matches("/v1/items/");
if id.is_empty() || id.contains('/') {
return err_response(VaultError::bad_request_code(
"vault_item_id_invalid",
"条目 id 无效",
));
}
match get_ai_vault_item(id) {
let id = match sanitize_item_id_for_path(p.trim_start_matches("/v1/items/")) {
Ok(id) => id,
Err(e) => return err_response(e),
};
match get_ai_vault_item(&id) {
Ok(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds"));
@@ -983,15 +1010,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "resolve") {
return err_response(e);
}
let mid = p
.trim_start_matches("/v1/items/")
.trim_end_matches("/resolve");
if mid.is_empty() {
return err_response(VaultError::bad_request_code(
"vault_item_id_invalid",
"条目 id 无效",
));
}
let mid = match sanitize_item_id_for_path(
p.trim_start_matches("/v1/items/")
.trim_end_matches("/resolve"),
) {
Ok(id) => id,
Err(e) => return err_response(e),
};
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let field = parsed
.get("field")
@@ -1006,7 +1031,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.or_else(|| parsed.get("secret_id"))
.and_then(Value::as_str);
let actor = format!("vaultd:{}", ai_vault_actor_id());
match resolve_ai_vault_secret(mid, field, &actor, None, account_id, secret_id) {
match resolve_ai_vault_secret(&mid, field, &actor, None, account_id, secret_id) {
Ok(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds"));
@@ -1020,15 +1045,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "login") {
return err_response(e);
}
let mid = p
.trim_start_matches("/v1/items/")
.trim_end_matches("/login");
if mid.is_empty() {
return err_response(VaultError::bad_request_code(
"vault_item_id_invalid",
"条目 id 无效",
));
}
let mid = match sanitize_item_id_for_path(
p.trim_start_matches("/v1/items/")
.trim_end_matches("/login"),
) {
Ok(id) => id,
Err(e) => return err_response(e),
};
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let force = parsed
.get("forceRefresh")
@@ -1036,7 +1059,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.and_then(Value::as_bool)
.unwrap_or(false);
let actor = format!("vaultd:{}", ai_vault_actor_id());
match login_ai_vault_credential(mid, force, &actor, None) {
match login_ai_vault_credential(&mid, force, &actor, None) {
Ok(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds"));
@@ -1050,15 +1073,13 @@ fn dispatch_request(raw: &str) -> (u16, String) {
if let Err(e) = authorize_bearer(auth.as_deref(), "session") {
return err_response(e);
}
let mid = p
.trim_start_matches("/v1/items/")
.trim_end_matches("/session");
if mid.is_empty() {
return err_response(VaultError::bad_request_code(
"vault_item_id_invalid",
"条目 id 无效",
));
}
let mid = match sanitize_item_id_for_path(
p.trim_start_matches("/v1/items/")
.trim_end_matches("/session"),
) {
Ok(id) => id,
Err(e) => return err_response(e),
};
let parsed: Value = serde_json::from_str(body).unwrap_or(json!({}));
let cookie = parsed
.get("cookieHeader")
@@ -1074,7 +1095,7 @@ fn dispatch_request(raw: &str) -> (u16, String) {
.and_then(Value::as_str)
.unwrap_or("human_bridge");
let actor = format!("vaultd:{}", ai_vault_actor_id());
match put_ai_vault_session(mid, cookie, expires, source, &actor, None) {
match put_ai_vault_session(&mid, cookie, expires, source, &actor, None) {
Ok(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("transport".into(), json!("uds"));
@@ -293,6 +293,8 @@ import {
};
const paneViewRegistry = new Map();
/** 每 pane 导航世代:快速连点时丢弃过期 replacePaneDocument 结果,保证 last-click-wins。 */
const paneReplaceGeneration = new Map();
const currentWebShellWorkspaceId = () => {
try {
return currentUrl().searchParams.get('workspaceId') || '';
@@ -709,6 +711,8 @@ import {
};
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
const generation = (paneReplaceGeneration.get(paneRole) || 0) + 1;
paneReplaceGeneration.set(paneRole, generation);
mindmapHost.unmountMindmapPane(paneRole);
const root = paneRole === 'primary'
? ensureLazyPrimaryPaneRoot()
@@ -719,8 +723,12 @@ import {
const aggregatePromise = options.aggregate
? Promise.resolve(options.aggregate)
: fetchPageAggregateForPane(descriptor);
const previousView = paneViewRegistry.get(paneRole);
const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]);
// 过期导航:丢弃结果,避免覆盖更新的 last-click View 并泄漏中间实例。
if (paneReplaceGeneration.get(paneRole) !== generation) {
return null;
}
const previousView = paneViewRegistry.get(paneRole);
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete(paneRole);
@@ -754,6 +762,13 @@ import {
clearEmbeddedLocalDraft(runtimeDescriptor);
try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
if (paneReplaceGeneration.get(paneRole) !== generation) {
try {
if (typeof runtime.unmount === 'function') runtime.unmount(mountId);
} catch (_) { /* ignore stale unmount */ }
unmountEditorViewBinding(view);
return null;
}
view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
@@ -1181,7 +1196,9 @@ import {
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
descriptor.pageBlockNavigation = pageBlockNavigation === true;
await replacePaneDocument('primary', descriptor);
const view = await replacePaneDocument('primary', descriptor);
// 过期导航(快速连点被 generation 丢弃)不得改 URL / 激活 tab。
if (!view) return false;
activateMainEditorTab('', 'primary');
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
return true;
@@ -1202,7 +1219,8 @@ import {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('secondary', descriptor);
const view = await replacePaneDocument('secondary', descriptor);
if (!view) return false;
activateMainEditorTab('', 'secondary');
if (url instanceof URL) replaceUrlState(url);
return true;
@@ -1473,7 +1473,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
};
const knowledgeRagProvider = () => {
return 'weknora';
return 'lightrag_legacy';
};
const setKnowledgeRagStatus = (entry, status, message, job) => {
@@ -11,20 +11,65 @@ function uploadedAssetTitle(asset) {
return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件';
}
/** Reject path escape segments and null bytes after light decode. */
function hasPathEscape(value) {
var s = String(value || '').replace(/\\/g, '/');
try {
s = decodeURIComponent(s);
} catch (_) {
// keep raw
}
s = s.replace(/\\/g, '/');
if (s.indexOf('\0') >= 0) return true;
var parts = s.split('/');
for (var i = 0; i < parts.length; i += 1) {
if (parts[i] === '..') return true;
}
return false;
}
/** Allow only safe URL schemes for editor img/src or link href. */
function isSafeAssetUrl(url) {
var value = String(url || '').trim();
if (!value) return false;
if (value.charAt(0) === '#' || value.charAt(0) === '/' || value.indexOf('./') === 0) {
return !hasPathEscape(value);
}
// scheme-relative
if (value.indexOf('//') === 0) return false;
var colon = value.indexOf(':');
if (colon < 0) {
// relative path without scheme
return !hasPathEscape(value);
}
var scheme = value.slice(0, colon).toLowerCase();
if (scheme === 'http' || scheme === 'https' || scheme === 'blob' || scheme === 'data') {
// data: raster images only. Reject svg+xml (can embed script even when base64 hides "script").
if (scheme === 'data') {
if (!/^data:image\//i.test(value)) return false;
if (/^data:image\/svg\+xml/i.test(value)) return false;
// deny explicit script markers in non-svg image payloads
if (value.toLowerCase().indexOf('script') >= 0) return false;
return true;
}
return true;
}
return false;
}
function uploadedAssetUrl(asset) {
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
var raw = String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
return isSafeAssetUrl(raw) ? raw : '';
}
function uploadedAssetMarkdownHref(asset) {
var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim();
if (href) {
var normalizedHref = href.replace(/\\/g, '/');
if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return '';
if (hasPathEscape(href) || !isSafeAssetUrl(href)) return '';
return href;
}
var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/');
if (!relativePath) return '';
if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return '';
if (!relativePath || hasPathEscape(relativePath)) return '';
if (relativePath.indexOf('./') === 0) return relativePath;
return './' + relativePath;
}
@@ -621,9 +666,9 @@ async function uploadFilesWithResolvedTarget(files, detail, options, deps) {
if (errors.length) {
var message = '部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : '');
if (typeof deps.alert === 'function') {
deps.alert(message);
await deps.alert(message);
} else {
window.alert(message);
await window.mnote.alert(message);
}
}
return uploaded;
@@ -633,7 +678,7 @@ function localAssetOpenUrl(asset, download, context) {
if (!isLocalUploadedAsset(asset)) return '';
var rootUri = String(context && context.rootUri || asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri();
var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim();
if (!rootUri || !rootRelativePath) return '';
if (!rootUri || !rootRelativePath || hasPathEscape(rootRelativePath)) return '';
var url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('path', rootRelativePath);
@@ -1,8 +1,12 @@
// MNote UI 基础运行时:全局 portaltoast API
// MNote UI 基础运行时:全局 portaltoast、居中 confirm/alert
(function initMnoteUiRuntime() {
if (window.__mnoteUiRuntimeStarted) return;
window.__mnoteUiRuntimeStarted = true;
var nativeConfirm = typeof window.confirm === 'function' ? window.confirm.bind(window) : null;
var nativeAlert = typeof window.alert === 'function' ? window.alert.bind(window) : null;
var activeDialog = null;
function ensurePortalRoot() {
var root = document.getElementById('mnote-portal-root');
if (root instanceof HTMLElement) return root;
@@ -52,15 +56,192 @@
return item;
}
function closeActiveDialog(result) {
if (!activeDialog) return;
var state = activeDialog;
activeDialog = null;
document.removeEventListener('keydown', state.onKeyDown, true);
if (state.backdrop && state.backdrop.parentNode) {
state.backdrop.parentNode.removeChild(state.backdrop);
}
if (typeof state.resolve === 'function') state.resolve(result);
}
/**
* 居中对话框
* @param {object} options
* @param {string} [options.title]
* @param {string} options.message
* @param {'confirm'|'alert'} [options.mode]
* @param {string} [options.confirmLabel]
* @param {string} [options.cancelLabel]
* @param {'primary'|'danger'} [options.tone]
* @returns {Promise<boolean>} confirm 模式确定=truealert 模式始终 true
*/
function showDialog(options) {
var config = options || {};
var message = String(config.message || '').trim();
if (!message) return Promise.resolve(config.mode === 'alert');
return new Promise(function (resolve) {
if (activeDialog) closeActiveDialog(false);
var mode = config.mode === 'alert' ? 'alert' : 'confirm';
var tone = String(config.tone || 'primary');
var title =
String(config.title || (mode === 'alert' ? '提示' : '确认')).trim() ||
(mode === 'alert' ? '提示' : '确认');
var confirmLabel = String(config.confirmLabel || (mode === 'alert' ? '知道了' : '确定')).trim();
var cancelLabel = String(config.cancelLabel || '取消').trim();
var backdrop = document.createElement('div');
backdrop.className = 'mnote-dialog-backdrop';
backdrop.setAttribute('data-mnote-dialog-backdrop', 'true');
backdrop.setAttribute('data-mode', mode);
var dialog = document.createElement('div');
dialog.className =
'mnote-dialog' + (tone === 'danger' ? ' mnote-dialog--danger' : '');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('data-mnote-dialog', 'true');
var body = document.createElement('div');
body.className = 'mnote-dialog__body';
var titleEl = document.createElement('h2');
titleEl.className = 'mnote-dialog__title';
titleEl.textContent = title;
body.appendChild(titleEl);
var messageEl = document.createElement('p');
messageEl.className = 'mnote-dialog__message';
messageEl.textContent = message;
body.appendChild(messageEl);
dialog.appendChild(body);
var actions = document.createElement('div');
actions.className = 'mnote-dialog__actions';
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className =
'mnote-dialog__btn ' +
(tone === 'danger' ? 'mnote-dialog__btn--danger' : 'mnote-dialog__btn--primary');
confirmBtn.setAttribute('data-role', 'confirm');
confirmBtn.textContent = confirmLabel || '确定';
if (mode === 'confirm') {
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'mnote-dialog__btn';
cancelBtn.setAttribute('data-role', 'cancel');
cancelBtn.textContent = cancelLabel || '取消';
cancelBtn.addEventListener('click', function () {
closeActiveDialog(false);
});
actions.appendChild(cancelBtn);
}
confirmBtn.addEventListener('click', function () {
closeActiveDialog(true);
});
actions.appendChild(confirmBtn);
dialog.appendChild(actions);
backdrop.appendChild(dialog);
backdrop.addEventListener('click', function (event) {
if (event.target === backdrop) {
closeActiveDialog(mode === 'alert' ? true : false);
}
});
var onKeyDown = function (event) {
if (event.key === 'Escape') {
event.preventDefault();
closeActiveDialog(mode === 'alert' ? true : false);
} else if (event.key === 'Enter') {
var tag = (event.target && event.target.tagName) || '';
if (tag === 'TEXTAREA' || tag === 'BUTTON') return;
event.preventDefault();
closeActiveDialog(true);
}
};
activeDialog = {
backdrop: backdrop,
resolve: resolve,
onKeyDown: onKeyDown,
};
document.addEventListener('keydown', onKeyDown, true);
ensurePortalRoot().appendChild(backdrop);
(mode === 'confirm' ? actions.querySelector('[data-role="cancel"]') || confirmBtn : confirmBtn).focus({
preventScroll: true,
});
});
}
function confirmDialog(message, options) {
var config = options && typeof options === 'object' ? options : {};
return showDialog({
mode: 'confirm',
message: message,
title: config.title,
confirmLabel: config.confirmLabel || config.okText,
cancelLabel: config.cancelLabel || config.cancelText,
tone: config.tone || (config.danger ? 'danger' : 'primary'),
});
}
function alertDialog(message, options) {
var config = options && typeof options === 'object' ? options : {};
return showDialog({
mode: 'alert',
message: message,
title: config.title || '提示',
confirmLabel: config.confirmLabel || config.okText || '知道了',
tone: config.tone || (config.kind === 'error' || config.danger ? 'danger' : 'primary'),
}).then(function () {
return undefined;
});
}
window.mnote = window.mnote || {};
window.mnote.ensurePortalRoot = ensurePortalRoot;
window.mnote.toast = toast;
window.mnote.confirm = confirmDialog;
window.mnote.alert = alertDialog;
window.mnote.dialog = showDialog;
// 全局替换:统一返回 Promise。调用点需 await(已在各 runtime 迁移)。
window.alert = function mnoteWindowAlert(message) {
if (!document.body) {
if (nativeAlert) nativeAlert(message);
return Promise.resolve();
}
return alertDialog(message);
};
window.confirm = function mnoteWindowConfirm(message) {
if (!document.body) {
return Promise.resolve(nativeConfirm ? !!nativeConfirm(message) : true);
}
return confirmDialog(message);
};
window.addEventListener('mnote:toast', function onMnoteToast(event) {
var detail = event && event.detail ? event.detail : {};
toast(detail.message, detail);
});
window.addEventListener('mnote:confirm', function onMnoteConfirm(event) {
var detail = event && event.detail ? event.detail : {};
var message = detail.message || '';
confirmDialog(message, detail).then(function (ok) {
if (typeof detail.onResult === 'function') detail.onResult(ok);
});
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensurePortalRoot, { once: true });
} else {
@@ -783,7 +783,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
var resolved = await resolveEditorAttachmentUrl(detail);
var title = String(detail.fileName || resolved.asset.file_name || 'PDF').trim() || 'PDF';
var previewUrl = buildPdfPreviewOpenUrl(resolved.url, title);
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'async function') {
var didOpenPdfTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:pdf:' + (detail.documentId || '') + ':' + (detail.assetId || previewUrl || resolved.url),
assetId: detail.assetId || '',
@@ -799,7 +799,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
}
window.open(previewUrl || resolved.url, '_blank', 'noopener,noreferrer');
} catch (error) {
window.alert(error && error.message ? error.message : '打开 PDF 失败');
await window.mnote.alert(error && error.message ? error.message : '打开 PDF 失败');
}
}
@@ -848,7 +848,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
window.open(resolved.url, '_blank', 'noopener,noreferrer');
return;
}
window.alert(error && error.message ? error.message : '打开代码附件失败');
await window.mnote.alert(error && error.message ? error.message : '打开代码附件失败');
}
}
@@ -148,10 +148,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}).then(function(){
if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle);
});
void work.then(close).catch(function(error) {
void work.then(close).catch(async function(error) {
committing = false;
input.disabled = false;
window.alert(error && error.message ? error.message : '重命名失败');
await window.mnote.alert(error && error.message ? error.message : '重命名失败');
});
};
input.addEventListener('click', function(event) { event.stopPropagation(); });
@@ -298,7 +298,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return url.toString();
}
function convertToPreviousSiblingChild(trigger, detail) {
async function convertToPreviousSiblingChild(trigger, detail) {
var documentId = detail.documentId || '';
var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(documentId) + '"]');
if (!(row instanceof HTMLElement)) return;
@@ -308,7 +308,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
});
var index = siblings.indexOf(row);
if (index <= 0) {
window.alert('当前页面前面没有同级页面。');
await window.mnote.alert('当前页面前面没有同级页面。');
return;
}
var previous = siblings[index - 1];
@@ -369,9 +369,21 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
function joinLocalAbsolutePath(rootUri, relativePath) {
var rootPath = localRootPathFromRootUri(rootUri);
var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '');
var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '').replace(/\\/g, '/');
if (!rootPath || !normalized) return rootPath || normalized;
return rootPath.replace(/\/+$/g, '') + '/' + normalized;
// Collapse . / empty and resolve .. without climbing above workspace root.
var parts = [];
normalized.split('/').forEach(function (seg) {
if (!seg || seg === '.') return;
if (seg === '..') {
if (parts.length) parts.pop();
return;
}
parts.push(seg);
});
var safeRel = parts.join('/');
if (!safeRel) return rootPath.replace(/\/+$/g, '');
return rootPath.replace(/\/+$/g, '') + '/' + safeRel;
}
function fileTreeCopyLocalAbsolutePath(detail, trigger) {
@@ -488,7 +500,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return payload;
}
function handleTreeContextMenuAction(action, detail, trigger) {
async function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
if (action === 'knowledge-rag-index') {
@@ -499,13 +511,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (window.mnote && window.mnote.toast) {
window.mnote.toast('已加入知识库', { kind: 'success' });
}
}).catch(function(error) {
}).catch(async function(error) {
var msg = error && error.message ? error.message : '知识库索引失败';
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
if (window.mnote && window.mnote.toast) {
window.mnote.toast(msg, { kind: 'error' });
} else {
window.alert(msg);
await window.mnote.alert(msg);
}
});
return;
@@ -584,7 +596,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return;
}
if (isAsset && action === 'open-edit-mode') {
withOfficeEditModeGuard(function() {
withOfficeEditModeGuard(async function() {
recordFileTreeAction('open-edit-mode', detail);
void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
});
@@ -635,15 +647,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
}
if (action === 'delete-trash' && isAsset) {
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return;
recordFileTreeAction('delete-trash', detail);
recordFileTreeActionStatus('pending', detail);
void deleteSingleFileTreeAsset(detail, trigger).then(function() {
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
}).catch(function(error) {
}).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资源删除失败');
await window.mnote.alert(error && error.message ? error.message : '资源删除失败');
});
return;
}
@@ -655,9 +667,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
recordFileTreeAction('new-folder', detail);
void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) {
recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail);
}).catch(function(error) {
}).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '新建文件夹失败');
await window.mnote.alert(error && error.message ? error.message : '新建文件夹失败');
});
return;
}
@@ -690,9 +702,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
recordFileTreeAction('paste-into', detail);
void pasteSidebarFileTreeClipboard(pasteRow).then(function(ok) {
recordFileTreeActionStatus(ok ? 'applied' : 'skipped', detail);
}).catch(function(error) {
}).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '粘贴失败');
await window.mnote.alert(error && error.message ? error.message : '粘贴失败');
});
return;
}
@@ -738,7 +750,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
workspaceId: workspaceId,
documentId: documentId,
title: nextTitle.trim()
}).then(function(){ updateTitleEverywhere(documentId, nextTitle.trim()); });
}).then(async function(){ updateTitleEverywhere(documentId, nextTitle.trim()); });
}
return;
}
@@ -747,12 +759,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return;
}
if (action === 'convert-child') {
convertToPreviousSiblingChild(trigger, detail);
void convertToPreviousSiblingChild(trigger, detail);
return;
}
var deleteTargetId = documentId || String(detail.rowId || '').trim();
if (action === 'delete-trash' && deleteTargetId) {
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
if (!(await window.mnote.confirm('确定要将“' + title + '”删除到垃圾桶吗?'))) return;
recordFileTreeAction('delete-trash', detail);
recordFileTreeActionStatus('pending', detail);
void dispatchTreeCommand(trigger || document.body, {
@@ -762,9 +774,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}).then(function() {
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
}).catch(function(error) {
}).catch(async function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '删除失败');
await window.mnote.alert(error && error.message ? error.message : '删除失败');
});
}
}
@@ -967,7 +979,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
document.head.appendChild(style);
}
// ── 知识库列表获取:只读 MNote 后端 registry/status避免前端直连 WeKnora service key。 ──
// ── 知识库列表获取:只读 MNote 后端 registry/status不直连外部知识库 service key。 ──
function fetchKnowledgeBaseList(detail) {
var rootUri = knowledgeRagRootUri(detail, null);
if (!rootUri) return Promise.resolve([]);
@@ -2125,7 +2137,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } }));
if (failures.length > 0) {
recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId });
window.alert('部分对象移动失败:' + failures.join(''));
await window.mnote.alert('部分对象移动失败:' + failures.join(''));
return false;
}
recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId });
@@ -2159,7 +2171,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var plan = buildSidebarFileTreeDeletePlan(rows);
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
if (total === 0) return false;
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false;
if (!(await window.mnote.confirm(sidebarFileTreeDeleteConfirmText(plan)))) return false;
var batchId = nextFileTreeOperationBatchId('bulk-delete');
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId);
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId });
@@ -2264,7 +2276,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (failures.length > 0) {
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } }));
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId });
window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
await window.mnote.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
return false;
}
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
@@ -209,13 +209,39 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|| (/^思维导图/i.test(fileName) && /\.json$/i.test(fileName));
}
function sanitizeWorkspaceRelativePath(rel) {
var value = String(rel || '').trim().replace(/\\/g, '/');
try {
value = decodeURIComponent(value);
} catch (_) {
// keep raw
}
value = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!value || value.indexOf('\0') >= 0) return '';
var parts = [];
value.split('/').forEach(function (seg) {
if (!seg || seg === '.') return;
if (seg === '..') {
if (parts.length) parts.pop();
return;
}
parts.push(seg);
});
return parts.join('/');
}
function localFilePathFromAssetId(assetId) {
var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.localFilePathFromAssetId === 'function') {
return _rto_.localFilePathFromAssetId(assetId);
return sanitizeWorkspaceRelativePath(_rto_.localFilePathFromAssetId(assetId));
}
var value = String(assetId || '').trim();
return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : value.indexOf('local:asset:') === 0 ? value.slice('local:asset:'.length) : '';
var raw = value.indexOf('local-file:') === 0
? value.slice('local-file:'.length)
: value.indexOf('local:asset:') === 0
? value.slice('local:asset:'.length)
: '';
return sanitizeWorkspaceRelativePath(raw);
}
function workspacePathFromDetail(detail) {
@@ -249,15 +275,16 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
}
function buildLocalFileOpenUrl(relativePath, download) {
var safePath = sanitizeWorkspaceRelativePath(relativePath);
var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') {
return _rto_.buildLocalFileOpenUrl(relativePath, download);
return safePath ? _rto_.buildLocalFileOpenUrl(safePath, download) : '';
}
var rootUri = currentRootUri();
if (!rootUri || !relativePath) return '';
if (!rootUri || !safePath) return '';
var url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('path', relativePath);
url.searchParams.set('path', safePath);
if (download) url.searchParams.set('download', 'true');
return url.toString();
}
@@ -466,7 +493,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
}
var documentId = String(detail && detail.documentId || '').trim();
if (isMindmapAssetDetail(detail) && documentId) {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'async function') {
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab');
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
@@ -493,7 +520,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
return;
}
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
window.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
await window.mnote.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
}
window.addEventListener('tree.asset.open', function(event) {
@@ -46,7 +46,7 @@ export const createSidebarFileTreeUploadRuntime = (dependencies = {}) => {
});
}
function blockReadonlyFileTreeAction(action, detail, message) {
async function blockReadonlyFileTreeAction(action, detail, message) {
var normalizedAction = String(action || 'drop').trim() || 'drop';
var text = String(message || '目标位置是只读,不能拖放到这里').trim();
var targetRowId = String(detail && (detail.targetRowId || detail.rowId) || '').trim();
@@ -75,7 +75,7 @@ export const createSidebarFileTreeUploadRuntime = (dependencies = {}) => {
row.setAttribute('data-readonly-message', text);
}
}
window.alert(text);
await window.mnote.alert(text);
return false;
}
@@ -1186,15 +1186,15 @@
var documentId = String(urlDocumentId || activeWorkspacePath.documentId || active.documentId || '').trim();
var fileTreeWorkspacePath = activeFileTreeWorkspacePath(documentId) || {};
var workspacePath = Object.assign({}, activeWorkspacePath, fileTreeWorkspacePath);
var session = piLabState.session || {};
var pagePath = normalizeSlashes(urlPagePath || workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || session.pagePath || '');
// Do not fall back to piLabState.session.* — that re-amplifies stale context after navigation.
var pagePath = normalizeSlashes(urlPagePath || workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || '');
var resourceKind = String(workspacePath.resourceKind || workspacePath.kind || active.resourceKind || active.kind || '').trim().toLowerCase();
var isDirectory = resourceKind === 'directory' || resourceKind === 'folder' || resourceKind === 'workspace';
var isStandalonePiPage = currentUrl().pathname === '/page-ai/pi';
var isLocalMarkdownPage = documentId.indexOf('local-md:') === 0 || /\.md$/i.test(pagePath);
if (isDirectory || (isStandalonePiPage && pagePath && !isLocalMarkdownPage)) pagePath = '';
var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || session.rootUri || '').trim();
var workspaceId = String(currentWorkspaceId() || workspacePath.workspaceId || active.workspaceId || session.workspaceId || '').trim();
var rootUri = String(currentRootUri() || workspacePath.rootUri || active.rootUri || '').trim();
var workspaceId = String(currentWorkspaceId() || workspacePath.workspaceId || active.workspaceId || '').trim();
var titleNode = document.querySelector('[data-page-title-current="true"]') || document.querySelector('.wolai-breadcrumb-current');
var title = String(active.title || workspacePath.title || (titleNode && titleNode.textContent) || document.title || '').trim();
var selection = currentSelectionText();
@@ -1278,12 +1278,13 @@
var context = currentPageContext();
refreshSelectionSummary();
if (!piLabState.session) piLabState.session = {};
if (context.pagePath) piLabState.session.pagePath = context.pagePath;
if (context.pageTitle) piLabState.session.pageTitle = context.pageTitle;
if (context.rootUri) piLabState.session.rootUri = context.rootUri;
if (context.workspaceId) piLabState.session.workspaceId = context.workspaceId;
// Always sync (including empty) so navigation away from a page clears stale context.
piLabState.session.pagePath = context.pagePath || '';
piLabState.session.pageTitle = context.pageTitle || '';
piLabState.session.rootUri = context.rootUri || '';
piLabState.session.workspaceId = context.workspaceId || '';
var folderPath = currentFolderPathFromPagePath(context.pagePath);
if (folderPath !== null) piLabState.session.folderPath = folderPath;
piLabState.session.folderPath = folderPath === null ? '' : folderPath;
updateContextStrip();
return context;
}
@@ -2294,11 +2295,11 @@
updateConfig();
}
function deleteHistorySession(sessionId) {
async function deleteHistorySession(sessionId) {
if (!sessionId) return Promise.resolve();
var existing = currentHistorySession(sessionId);
var title = existing ? historyTitle(existing) : sessionId;
if (!window.confirm('删除历史对话“' + title + '”?')) return Promise.resolve();
if (!(await window.mnote.confirm('删除历史对话“' + title + '”?'))) return Promise.resolve();
return fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), {
method: 'DELETE',
credentials: 'same-origin',
@@ -2313,10 +2314,10 @@
});
}
function clearHistorySessions() {
async function clearHistorySessions() {
var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0;
if (!count) return Promise.resolve();
if (!window.confirm('清空全部 ' + count + ' 条 Pi 历史对话?')) return Promise.resolve();
if (!(await window.mnote.confirm('清空全部 ' + count + ' 条 Pi 历史对话?'))) return Promise.resolve();
return fetch(API.SESSIONS + '?limit=1000', {
method: 'DELETE',
credentials: 'same-origin',
@@ -2618,8 +2619,10 @@
if (lightrag) lightrag.textContent = '唯一默认';
if (changedFiles) {
var files = {};
piLabState.changedFiles.forEach(function (file) { if (file) files[file] = true; });
piLabState.messages.forEach(function (msg) {
(Array.isArray(piLabState.changedFiles) ? piLabState.changedFiles : []).forEach(function (file) {
if (file) files[file] = true;
});
(Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) {
if (msg && msg.diffSummary && Array.isArray(msg.diffSummary.files)) {
msg.diffSummary.files.forEach(function (file) { if (file) files[file] = true; });
}
@@ -3264,7 +3267,7 @@
function collectPiArtifacts() {
var artifacts = [];
var seen = {};
piLabState.messages.forEach(function (msg) {
(Array.isArray(piLabState.messages) ? piLabState.messages : []).forEach(function (msg) {
if (!msg) return;
(Array.isArray(msg.citations) ? msg.citations : []).forEach(function (cit, index) {
var key = 'citation:' + (cit.url || cit.source || cit.title || index);
@@ -3307,7 +3310,7 @@
});
});
});
piLabState.changedFiles.forEach(function (file) {
(Array.isArray(piLabState.changedFiles) ? piLabState.changedFiles : []).forEach(function (file) {
if (!file || seen['diff:' + file]) return;
seen['diff:' + file] = true;
artifacts.push({ kind: 'diff', title: file, summary: '文件变更' });
@@ -3722,8 +3725,10 @@
function handleToolCallEventPayload(payload) {
payload = payload || {};
// Fail-closed: only explicit allowed===true triggers side effects / changedFiles.
var allowed = payload.allowed === true;
applyToolCallSideEffects({
ok: payload.allowed !== false,
ok: allowed,
toolName: payload.toolName,
result: {
rootUri: payload.rootUri,
@@ -3733,7 +3738,7 @@
});
piLabState.receipts.push({
toolName: payload.toolName,
allowed: payload.allowed !== false,
allowed: allowed,
denyReason: payload.denyReason || null,
diffSummary: payload.diffSummary || null,
citationCount: payload.citationCount || 0,
@@ -3746,9 +3751,9 @@
toolCallId: payload.toolCallId || payload.id || ('receipt:' + (payload.toolName || 'tool') + ':' + piLabState.receipts.length),
toolName: payload.toolName || 'tool',
args: payload.params || payload.args || {},
}, payload.allowed === false ? 'denied' : 'done', {
}, allowed ? 'done' : 'denied', {
result: {
allowed: payload.allowed !== false,
allowed: allowed,
denyReason: payload.denyReason || null,
diffSummary: payload.diffSummary || null,
citationCount: payload.citationCount || 0,
@@ -3757,7 +3762,9 @@
approvalConfirmed: payload.approvalConfirmed === true,
toolPolicy: payload.toolPolicy || '',
});
if (payload.normalizedFilePath && payload.diffSummary) piLabState.changedFiles.push(payload.normalizedFilePath);
if (allowed && payload.normalizedFilePath && payload.diffSummary) {
piLabState.changedFiles.push(payload.normalizedFilePath);
}
updateReceiptDisplay();
updateContextStrip();
}
@@ -4591,7 +4598,8 @@
function parseUnifiedPatch(patchText) {
if (!patchText) return [];
var files = [];
var fileLines = patchText.split('\\n');
// Real newlines (JSON-parsed patch text), not the two-char sequence \ + n.
var fileLines = String(patchText).replace(/\r\n/g, '\n').split('\n');
var currentFile = null;
var currentHunk = null;
@@ -4982,8 +4990,8 @@
}
var copyMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-copy-markdown]') : null;
if (copyMarkdown) {
copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function () {
showPiToast('Markdown copied', 'success');
copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function (ok) {
showPiToast(ok ? 'Markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
});
return;
}
@@ -4999,7 +5007,11 @@
}
var openArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-open]') : null;
if (openArtifact) {
var artUrl = openArtifact.getAttribute('data-page-ai-pi-lab-artifact-open') || openArtifact.getAttribute('href') || '';
var artUrl = String(openArtifact.getAttribute('data-page-ai-pi-lab-artifact-open') || openArtifact.getAttribute('href') || '').trim();
if (!artUrl || artUrl === '#') {
showPiToast('引用无效或无法访问', 'warning');
return;
}
window.dispatchEvent(new CustomEvent('mnote:open-reference', {
detail: {
source: 'page_ai_pi_lab_artifact',
@@ -5007,9 +5019,7 @@
url: artUrl,
},
}));
if (!artUrl || artUrl === '#') {
showPiToast('引用无效或无法访问', 'warning');
}
return;
}
var viewDiff = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-view-diff]') : null;
if (viewDiff) {
@@ -5020,15 +5030,17 @@
}
var copyArtifactMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy-markdown]') : null;
if (copyArtifactMarkdown) {
copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function () {
showPiToast('Artifact markdown copied', 'success');
copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function (ok) {
showPiToast(ok ? 'Artifact markdown copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
});
return;
}
var copyArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy]') : null;
if (copyArtifact) {
var raw = copyArtifact.getAttribute('data-page-ai-pi-lab-artifact-copy') || '';
copyTextToClipboard(raw).then(function () { showPiToast('Artifact raw copied', 'success'); });
copyTextToClipboard(raw).then(function (ok) {
showPiToast(ok ? 'Artifact raw copied' : '复制失败(剪贴板不可用)', ok ? 'success' : 'warning');
});
return;
}
var deleteTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-delete]') : null;
@@ -14,8 +14,21 @@ export function createSidebarPageAiRuntime(context) {
function openPageAiDrawer() {
// 产品唯一入口:Pi Lab。旧 Hermes/OpenCode/Board drawer 不再打开。
if (window.createSidebarPageAiPiLabRuntime) {
if (typeof window.createSidebarPageAiPiLabRuntime !== 'function') {
console.warn('[page-ai] Pi Lab runtime not loaded');
if (window.mnote && typeof window.mnote.toast === 'function') {
window.mnote.toast('Page AI 运行时未加载', { kind: 'warning' });
}
return;
}
try {
window.createSidebarPageAiPiLabRuntime({});
} catch (err) {
console.error('[page-ai] Pi Lab runtime init failed', err);
if (window.mnote && typeof window.mnote.toast === 'function') {
window.mnote.toast('Page AI 启动失败', { kind: 'error' });
}
return;
}
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin);
if (pageUiState) pageUiState.pageAiOpen = true;
@@ -691,49 +691,49 @@ export function createSidebarPageSettingsRuntime(context) {
function createKnowledgeRagSettingsPanelHtmlV2() {
return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-weknora-page-replacement="false">' +
'<div class="mnote-weknora-kb-page-header">' +
'<div class="mnote-weknora-kb-page-title">' +
'<span class="mnote-weknora-kb-page-eyebrow">LightRAG</span>' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-krag-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-krag-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-krag-page-replacement="false">' +
'<div class="mnote-krag-kb-page-header">' +
'<div class="mnote-krag-kb-page-title">' +
'<span class="mnote-krag-kb-page-eyebrow">LightRAG</span>' +
'<strong>知识库</strong>' +
'<span>资料、索引状态和检索</span>' +
'</div>' +
'<div class="mnote-weknora-kb-page-actions">' +
'<div class="mnote-krag-kb-page-actions">' +
'<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-action="open-dashboard" title="打开知识库" aria-label="打开知识库"><span class="material-symbols-outlined" data-icon="open_in_new" aria-hidden="true"></span></button>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'</div>' +
'</div>' +
'<div class="mnote-weknora-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-weknora-kb-page-body" data-kb-list-hidden="true">' +
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane" hidden>' +
'<div class="mnote-weknora-kb-list-head">' +
'<div><strong>资料库</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
'<div class="mnote-krag-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-krag-kb-page-body" data-kb-list-hidden="true">' +
'<aside class="mnote-krag-kb-list-pane" data-testid="mnote-krag-kb-list-pane" hidden>' +
'<div class="mnote-krag-kb-list-head">' +
'<div><strong>资料库</strong><span data-testid="mnote-krag-kb-list-count">0</span></div>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb" title="新建资料库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'</div>' +
'<div class="mnote-weknora-kb-card-wrap" data-testid="mnote-weknora-kb-card-list"></div>' +
'<div class="mnote-krag-kb-card-wrap" data-testid="mnote-krag-kb-card-list"></div>' +
'</aside>' +
'<section class="mnote-weknora-kb-detail-pane" data-testid="mnote-weknora-kb-detail-pane">' +
'<div class="mnote-weknora-kb-detail-hero" data-testid="mnote-weknora-kb-detail-hero">' +
'<section class="mnote-krag-kb-detail-pane" data-testid="mnote-krag-kb-detail-pane">' +
'<div class="mnote-krag-kb-detail-hero" data-testid="mnote-krag-kb-detail-hero">' +
'<div>' +
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>资料来源</span>' +
'<h2 data-testid="mnote-weknora-kb-detail-title">LightRAG 资料库</h2>' +
'<p data-testid="mnote-weknora-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
'<nav class="mnote-krag-kb-breadcrumb" data-testid="mnote-krag-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-krag-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
'<span class="mnote-krag-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>资料来源</span>' +
'<h2 data-testid="mnote-krag-kb-detail-title">LightRAG 资料库</h2>' +
'<p data-testid="mnote-krag-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
'</div>' +
'</div>' +
'<section class="mnote-knowledge-rag-kb-manager" data-testid="mnote-knowledge-rag-kb-manager">' +
'<div class="mnote-knowledge-rag-kb-manager-row">' +
'<label class="mnote-knowledge-rag-kb-select-field"><span>当前资料库</span><select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select></label>' +
'<label class="mnote-knowledge-rag-kb-select-field"><span>当前资料库</span><select class="mnote-kb-params-select" data-testid="mnote-krag-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select></label>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span>新建</button>' +
'<button type="button" data-knowledge-rag-action="delete-kb" data-knowledge-rag-delete-selected-kb="true"><span class="material-symbols-outlined" data-icon="delete" aria-hidden="true"></span>删除</button>' +
'</div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<section class="mnote-krag-kb-create-card" data-testid="mnote-krag-kb-controls" hidden>' +
'<div class="mnote-krag-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-krag-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-krag-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
'<span class="mnote-knowledge-rag-input-status" data-testid="mnote-weknora-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' +
'<span class="mnote-knowledge-rag-input-status" data-testid="mnote-krag-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' +
'</section>' +
'<div class="mnote-knowledge-rag-kb-manager-note" data-testid="mnote-knowledge-rag-kb-manager-note"></div>' +
'</section>' +
@@ -742,27 +742,27 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'</details>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="当前资料库内容">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">资料</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki" hidden>Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph" hidden>Graph</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-krag-tab-documents">资料</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-krag-tab-wiki" hidden>Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-krag-tab-graph" hidden>Graph</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' +
'<div class="mnote-weknora-doc-layout" data-testid="mnote-weknora-document-management">' +
'<aside class="mnote-weknora-doc-sidebar" data-testid="mnote-weknora-doc-source-sidebar" hidden>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>来源</strong><span>MNote registry</span></div>' +
'<div class="mnote-weknora-doc-source-tags" data-testid="mnote-weknora-doc-source-tags">' +
'<div class="mnote-krag-doc-layout" data-testid="mnote-krag-document-management">' +
'<aside class="mnote-krag-doc-sidebar" data-testid="mnote-krag-doc-source-sidebar" hidden>' +
'<div class="mnote-krag-doc-sidebar-head"><strong>来源</strong><span>MNote registry</span></div>' +
'<div class="mnote-krag-doc-source-tags" data-testid="mnote-krag-doc-source-tags">' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="all" data-active="true"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>全部资料</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' +
'</div>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' +
'<div class="mnote-weknora-doc-tags" data-testid="mnote-weknora-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' +
'<div class="mnote-krag-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' +
'<div class="mnote-krag-doc-tags" data-testid="mnote-krag-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' +
'</aside>' +
'<section class="mnote-weknora-doc-main">' +
'<div class="mnote-weknora-doc-toolbar">' +
'<div class="mnote-weknora-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-weknora-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前资料库内容" spellcheck="false" /></div>' +
'<div class="mnote-weknora-view-switch" role="group" aria-label="文档视图切换" data-testid="mnote-weknora-view-switch">' +
'<section class="mnote-krag-doc-main">' +
'<div class="mnote-krag-doc-toolbar">' +
'<div class="mnote-krag-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-krag-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前资料库内容" spellcheck="false" /></div>' +
'<div class="mnote-krag-view-switch" role="group" aria-label="文档视图切换" data-testid="mnote-krag-view-switch">' +
'<button type="button" class="is-active" data-knowledge-rag-view="list" aria-pressed="true" title="列表视图"><span class="material-symbols-outlined" data-icon="view_list" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-view="grid" aria-pressed="false" title="网格视图"><span class="material-symbols-outlined" data-icon="grid_view" aria-hidden="true"></span></button>' +
'</div>' +
@@ -774,24 +774,24 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-weknora-doc-actions">' +
'<div class="mnote-krag-doc-actions">' +
'<button type="button" class="mnote-knowledge-rag-primary-action" data-knowledge-rag-action="ingest">开始索引</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
'<div class="mnote-weknora-doc-table" data-testid="mnote-knowledge-rag-sources" data-weknora-document-list-view="true"></div>' +
'<div class="mnote-krag-doc-table" data-testid="mnote-knowledge-rag-sources" data-krag-document-list-view="true"></div>' +
'</section>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="wiki" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-wiki-panel">' +
'<div class="mnote-krag-placeholder-panel" data-testid="mnote-krag-wiki-panel">' +
'<strong>Wiki</strong>' +
'<span>后续可基于 LightRAG 召回结果生成知识页;当前以 MNote open-reference 边界为准。</span>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="graph" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-graph-panel">' +
'<div class="mnote-krag-placeholder-panel" data-testid="mnote-krag-graph-panel">' +
'<strong>Graph</strong>' +
'<span>展示 LightRAG 图谱和文档关系入口,本地文件真相仍由 MNote source registry 管理。</span>' +
'</div>' +
@@ -1255,9 +1255,7 @@ export function createSidebarPageSettingsRuntime(context) {
function knowledgeRagProviderLabel(status) {
var providerName = knowledgeRagProviderName(status);
if (providerName === 'weknora') return 'WeKnora';
if (providerName === 'lightrag') return 'LightRAG';
if (providerName === 'lightrag_legacy') return 'LightRAG';
if (providerName === 'lightrag' || providerName === 'lightrag_legacy') return 'LightRAG';
return providerName;
}
@@ -1371,10 +1369,8 @@ export function createSidebarPageSettingsRuntime(context) {
}
function knowledgeRagProviderKnowledgeBaseIds(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
var ids = [];
var raw = weknora.knowledgeBaseIds || weknora.knowledge_base_ids || status && status.knowledgeBaseIds || [];
var raw = status && status.knowledgeBaseIds || [];
if (!Array.isArray(raw) && raw) raw = [raw];
(Array.isArray(raw) ? raw : []).forEach(function(id) {
var text = String(id || '').trim();
@@ -1542,10 +1538,10 @@ export function createSidebarPageSettingsRuntime(context) {
}
function renderKnowledgeRagKnowledgeBaseCards(popover, status, entries, providerDocs) {
var listNode = popover.querySelector('[data-testid="mnote-weknora-kb-card-list"]');
var countNode = popover.querySelector('[data-testid="mnote-weknora-kb-list-count"]');
var listPane = popover.querySelector('[data-testid="mnote-weknora-kb-list-pane"]');
var pageBody = popover.querySelector('.mnote-weknora-kb-page-body');
var listNode = popover.querySelector('[data-testid="mnote-krag-kb-card-list"]');
var countNode = popover.querySelector('[data-testid="mnote-krag-kb-list-count"]');
var listPane = popover.querySelector('[data-testid="mnote-krag-kb-list-pane"]');
var pageBody = popover.querySelector('.mnote-krag-kb-page-body');
if (!(listNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
@@ -1563,9 +1559,9 @@ export function createSidebarPageSettingsRuntime(context) {
var baseStatus = String(base.status || 'active');
var baseStatusLabel = knowledgeRagBaseStatusLabel(baseStatus);
return '' +
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<span class="mnote-weknora-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
'<span class="mnote-weknora-kb-card-main">' +
'<button type="button" class="mnote-krag-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<span class="mnote-krag-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
'<span class="mnote-krag-kb-card-main">' +
'<strong title="' + escapeHtml(base.name) + '">' + escapeHtml(base.name) + '</strong>' +
'<em title="' + escapeHtml(base.description || '暂无描述') + '">' + escapeHtml(base.description || '暂无描述') + '</em>' +
'<span>' +
@@ -1574,16 +1570,16 @@ export function createSidebarPageSettingsRuntime(context) {
(stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') +
'</span>' +
'</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatusLabel) + '</span>' +
'<span class="mnote-krag-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatusLabel) + '</span>' +
'</button>';
}).join('');
}
function renderKnowledgeRagKnowledgeBaseHero(popover, status, entries, providerDocs) {
var heroNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-hero"]');
var titleNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-title"]');
var descNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-description"]');
var breadcrumbNode = popover.querySelector('[data-testid="mnote-weknora-kb-breadcrumb-current"]');
var heroNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-hero"]');
var titleNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-title"]');
var descNode = popover.querySelector('[data-testid="mnote-krag-kb-detail-description"]');
var breadcrumbNode = popover.querySelector('[data-testid="mnote-krag-kb-breadcrumb-current"]');
if (!(titleNode instanceof HTMLElement) || !(descNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
@@ -1646,14 +1642,11 @@ export function createSidebarPageSettingsRuntime(context) {
deleteButton.title = canDelete ? '删除当前空资料库' : (selectedValue === 'lightrag-default' ? '默认资料库不能删除' : '请先移除资料后再删除资料库');
}
if (noteNode instanceof HTMLElement) {
var providerName = knowledgeRagProviderName(status || {});
var activeName = activeBase.name || '当前资料库';
if (selectedValue === 'lightrag-default') {
noteNode.textContent = '默认资料库显示当前工作区全部已索引资料;新建资料库可管理独立资料范围。';
} else if (activeStats.sourceCount === 0 && activeStats.processingCount === 0) {
noteNode.textContent = activeName + ':空资料库,可添加资料或直接删除。';
} else if (providerName === 'weknora') {
noteNode.textContent = bases.length > 1 ? '已连接多个资料库,可用下拉框切换当前范围。' : '当前只有一个资料库,可新建后切换。';
} else {
noteNode.textContent = bases.length > 1 ? '当前可切换资料库;删除前需要先移除其中资料。' : '当前使用默认资料库,也可以新建一个资料库管理独立范围。';
}
@@ -1674,7 +1667,7 @@ export function createSidebarPageSettingsRuntime(context) {
var activeCount = Object.keys(activeKbIds).length || kbIds.length;
var processingCount = knowledgeRagInFlightCount(entries, providerDocs);
return '' +
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
'<div class="mnote-krag-kb-summary-card" data-testid="mnote-krag-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
'<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' +
'<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' +
'<div><span>索引文档</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
@@ -1928,7 +1921,7 @@ export function createSidebarPageSettingsRuntime(context) {
function renderKnowledgeRagSourceFilters(popover, rows) {
var filtersNode = popover.querySelector('[data-testid="mnote-knowledge-rag-source-filters"]');
var sourceTagsNode = popover.querySelector('[data-testid="mnote-weknora-doc-source-tags"]');
var sourceTagsNode = popover.querySelector('[data-testid="mnote-krag-doc-source-tags"]');
if (!(filtersNode instanceof HTMLElement)) return;
var counts = rows.reduce(function(acc, row) {
acc.all += 1;
@@ -1997,7 +1990,7 @@ export function createSidebarPageSettingsRuntime(context) {
if (filePath) providerDocsByPath.set(filePath, doc);
});
var listView = String(pageUiState.knowledgeRagDocumentView || 'list') === 'grid' ? 'grid' : 'list';
sourcesNode.setAttribute('data-weknora-document-view', listView);
sourcesNode.setAttribute('data-krag-document-view', listView);
sourcesNode.innerHTML = items.map(function(row) {
if (row.kind === 'provider') {
var doc = row.doc || {};
@@ -2022,7 +2015,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' +
'<span>' + escapeHtml(providerMeta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(knowledgeId || '索引记录') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '无法直接定位') + '</i></span>' +
'<span class="mnote-krag-source-aux"><i>' + escapeHtml(knowledgeId || '索引记录') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '无法直接定位') + '</i></span>' +
knowledgeRagSourceProgressHtml(providerProgress) +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions"></div>' +
@@ -2075,7 +2068,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名资料') + '</strong>' +
'<span>' + escapeHtml(meta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(kbId || '未绑定 KB') + '</i><i>' + escapeHtml(knowledgeId || '待生成 citation') + '</i></span>' +
'<span class="mnote-krag-source-aux"><i>' + escapeHtml(kbId || '未绑定 KB') + '</i><i>' + escapeHtml(knowledgeId || '待生成 citation') + '</i></span>' +
knowledgeRagSourceProgressHtml(entryProgress) +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions">' +
@@ -2191,12 +2184,8 @@ export function createSidebarPageSettingsRuntime(context) {
function publicKnowledgeRagDashboardUrl(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var active = knowledgeRagProviderName(status || {});
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
var lightrag = config.lightrag && typeof config.lightrag === 'object' ? config.lightrag : {};
var url = active === 'weknora'
? String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim()
: String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
var url = String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
if (!url) return '';
try {
var parsed = new URL(url, window.location.href);
@@ -2360,7 +2349,7 @@ export function createSidebarPageSettingsRuntime(context) {
function setKnowledgeRagCreateCardVisible(visible) {
var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]');
var card = popover.querySelector('[data-testid="mnote-krag-kb-controls"]');
var statusNode = popover.querySelector('[data-knowledge-rag-create-kb-status]');
if (card instanceof HTMLElement) card.hidden = !visible;
if (statusNode instanceof HTMLElement && visible) statusNode.hidden = true;
@@ -2372,7 +2361,7 @@ export function createSidebarPageSettingsRuntime(context) {
function toggleKnowledgeRagCreateCard() {
var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]');
var card = popover.querySelector('[data-testid="mnote-krag-kb-controls"]');
setKnowledgeRagCreateCardVisible(!(card instanceof HTMLElement) || card.hidden);
}
@@ -2724,7 +2713,10 @@ export function createSidebarPageSettingsRuntime(context) {
}
// --- 检索测试 ---
function knowledgeRagSearchResultSourcePath(result) {
// depth 限制防止恶意/循环 source 对象导致栈溢出
function knowledgeRagSearchResultSourcePath(result, depth) {
if (depth == null) depth = 0;
if (depth > 4) return '';
var candidates = [
result && result.sourceRootRelativePath,
result && result.source_root_relative_path,
@@ -2740,7 +2732,7 @@ export function createSidebarPageSettingsRuntime(context) {
}
var source = result && result.source;
if (source && typeof source === 'object') {
return knowledgeRagSearchResultSourcePath(source);
return knowledgeRagSearchResultSourcePath(source, depth + 1);
}
if (typeof source === 'string' && source.trim()) return source.trim();
return '';
@@ -2100,7 +2100,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return await runtimeFn(files, detail || {}, options || {}, {
resolveFileTreeUploadTarget: resolveFileTreeUploadTarget,
uploadFileToMediaAsset: uploadFileToMediaAsset,
alert: function(message) { window.alert(message); }
alert: async function(message) { await window.mnote.alert(message); }
});
}
var list = Array.from(files || []).filter(Boolean);
@@ -2116,7 +2116,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
}
if (errors.length) {
window.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''));
await window.mnote.alert('部分文件上传失败:\n' + errors.slice(0, 6).join('\n') + (errors.length > 6 ? '\n...' : ''));
}
return uploaded;
}
@@ -1966,7 +1966,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "创建页面失败";
setStatus(message, "error");
setLastAction("创建页面失败", "error");
window.alert(message);
await window.mnote.alert(message);
}
};
@@ -2040,7 +2040,7 @@ function startTreeShellRuntime() {
setLastAction("重命名失败", "error");
inlineRenameState = { mode: null, id: null, committing: false };
renderTree();
window.alert(message);
await window.mnote.alert(message);
}
};
@@ -2071,7 +2071,7 @@ function startTreeShellRuntime() {
const item = itemById.get(nodeId);
if (!item) return;
const siblings = getSiblings(item.parentNodeId);
const currentIndex = siblings.findIndex((entry) => entry.nodeId === nodeId);
const currentIndex = siblings.findIndex(entry) => entry.nodeId === nodeId);
if (currentIndex === -1) return;
const nextIndex = currentIndex + delta;
if (nextIndex < 0 || nextIndex >= siblings.length) return;
@@ -2099,7 +2099,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "移动页面失败";
setStatus(message, "error");
setLastAction("移动页面失败", "error");
window.alert(message);
await window.mnote.alert(message);
}
};
@@ -2108,7 +2108,7 @@ function startTreeShellRuntime() {
const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) return;
const siblings = getSiblings(targetItem.parentNodeId);
const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId);
const targetIndex = siblings.findIndex(entry) => entry.nodeId === targetNodeId);
if (targetIndex < 0) return;
try {
const result = await sendCommand({
@@ -2134,7 +2134,7 @@ function startTreeShellRuntime() {
const message = error instanceof Error ? error.message : "拖拽移动失败";
setStatus(message, "error");
setLastAction("拖拽移动失败", "error");
window.alert(message);
await window.mnote.alert(message);
}
};
@@ -79,49 +79,77 @@
return data && data.result != null ? data.result : data;
}
function maskLabel(state) {
if (!state || state.state === 'absent') return '(无)';
if (state.state === 'masked') return '••••••••';
if (state.state === 'revealed') return state.value || '';
return '••••••••';
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Reusable helper for rendering a single secret row (password/apikey/token).
* Used heavily by account secrets, nested secrets, and the second-secret bug fix.
* Reduces duplication in render blocks.
* Display label for a secret field state. Always HTML-escaped so callers can
* safely inject into innerHTML (revealed values may contain user-controlled text).
*/
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) {
var key = opts.secretId || '';
function maskLabel(secretState) {
if (!secretState || secretState.state === 'absent') return '(无)';
if (secretState.state === 'masked') return '••••••••';
if (secretState.state === 'revealed') return escapeHtml(secretState.value || '');
return '••••••••';
}
/**
* Optional reusable secret-row helper. Prefer bindWorkbench's secretRow for
* production paths; this stays fail-safe if called with uiState in opts.
* @param {object} [opts.uiState] workbench state with revealed / _revealedValues
*/
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts) {
opts = opts || {};
var uiState = opts.uiState || { revealed: {}, _revealedValues: {} };
var revealed = uiState.revealed || {};
var revealedValues = uiState._revealedValues || {};
var key = opts.secretId || secretId || field || '';
var has = secretState && secretState.state === 'masked';
var canReveal = has || (secretState && secretState.state === 'revealed');
if (!canReveal && !showEmpty) return '';
var shown = state.revealed[key]
? state._revealedValues && state._revealedValues[key]
? escapeHtml(state._revealedValues[key])
: maskLabel(secretState)
var isOpen = !!revealed[key];
var shown = isOpen
? (revealedValues[key] != null
? escapeHtml(revealedValues[key])
: maskLabel(secretState))
: maskLabel(secretState);
var revealBtn = state.revealed[key] ? '隐藏' : '显示';
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>';
var revealBtn = isOpen ? '隐藏' : '显示';
var copyBtn =
'<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' +
escapeHtml(key) +
'">复制</button>';
var actions =
'<button type="button" data-vault-reveal="" data-vault-secret-id="' +
escapeHtml(secretId || '') +
'" data-testid="vault-reveal-' +
escapeHtml(key) +
'">' +
revealBtn +
'</button>' +
copyBtn;
var actions = state.revealed[key]
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn;
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">'
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>'
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>'
+ '</div>';
return (
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
escapeHtml(key) +
'">' +
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
escapeHtml(key) +
'">' +
shown +
'</div>' +
'<div class="mnote-vault-secret-actions">' +
actions +
'</div>' +
'</div>'
);
}
function bindWorkbench(root) {
@@ -531,6 +559,10 @@
badge.classList.remove('is-ai');
}
}
var repairBtn = qs('[data-vault-repair-ai-folders]', root);
if (repairBtn) {
repairBtn.hidden = !state.isAiVault;
}
root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user');
}
@@ -540,9 +572,9 @@
statusEl.setAttribute('data-type', type || 'info');
}
function confirmDiscard() {
async function confirmDiscard() {
if (!state.dirty) return true;
return window.confirm('有未保存的修改,确定放弃?');
return await window.mnote.confirm('有未保存的修改,确定放弃?');
}
function filteredItems() {
@@ -840,6 +872,53 @@
return out;
}
/**
* URLs for the edit form: keep empty draft rows (so "+ 网址" can add a blank
* line). normalizeUrlsFromItem strips empties and would make add-url look dead.
*/
function urlsForFormEditor(item) {
if (item && Array.isArray(item.urls) && item.urls.length) {
return item.urls.map(function (u) {
return u == null ? '' : String(u);
});
}
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
return urls;
}
/** Make a safe absolute href for external open (http/https only). */
function externalUrlHref(raw) {
var t = String(raw || '').trim();
if (!t) return '';
if (/^https?:\/\//i.test(t)) return t;
if (/^\/\//.test(t)) return 'https:' + t;
// Bare host/path: assume https (common for vault entries).
if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}([/:].*)?$/i.test(t)) {
return 'https://' + t;
}
return '';
}
/** View-mode URL as hyperlink look; open only via Ctrl/Cmd+click (see click handler). */
function urlLinkHtml(raw) {
var text = String(raw || '').trim();
if (!text) return '<span class="mnote-vault-muted">—</span>';
var href = externalUrlHref(text);
if (!href) {
return '<span class="mnote-vault-field-value">' + escapeHtml(text) + '</span>';
}
return (
'<a class="mnote-vault-url-link" href="' +
escapeHtml(href) +
'" data-vault-external-url="' +
escapeHtml(href) +
'" title="Ctrl/Cmd + 点击打开">' +
escapeHtml(text) +
'</a>'
);
}
function emptyForm(defaults) {
defaults = defaults || {};
var urls = normalizeUrlsFromItem(defaults);
@@ -1381,20 +1460,14 @@
if (urls.length) {
urlsHtml = urls
.map(function (u, idx) {
var label = idx === 0 ? '主网址' : '备用 ' + idx;
var link =
'<a href="' +
escapeHtml(u) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(u) +
'</a>';
var label = idx === 0 ? (urls.length > 1 ? '主网址' : '网址') : '备用 ' + idx;
return (
'<div class="mnote-vault-field-row">' +
'<label>' +
escapeHtml(label) +
'</label>' +
'<div class="mnote-vault-field-value">' +
link +
urlLinkHtml(u) +
'</div></div>'
);
})
@@ -1412,9 +1485,7 @@
' 个</span></div>' +
urlsHtml +
'</div>'
: urls.length === 1
? fieldRow('网址', urls[0], showEmpty)
: urlsHtml) +
: urlsHtml) +
'<div class="mnote-vault-section">' +
'<div class="mnote-vault-section-head"><span>登录账号</span>' +
(accounts.length > 1
@@ -1722,8 +1793,8 @@
var tagsStr = (item.tags || []).join(', ');
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
var accounts = normalizeAccountsFromItem(item, true);
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
// Prefer draft rows (may include empty slots after "+ 网址"); do not strip blanks.
var urls = urlsForFormEditor(item);
state._formUrls = urls.slice();
/**
* Rebuild draft slots from item projection.
@@ -2325,10 +2396,43 @@
} else {
renderDetail();
}
setStatus(
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条',
'success'
var repair = result && result.folderRepair;
var statusMsg =
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条';
if (repair && Number(repair.updated) > 0) {
statusMsg += ' · 已修复分组 ' + repair.updated + ' 条';
}
setStatus(statusMsg, 'success');
} catch (err) {
setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error');
}
}
async function repairAiFolders() {
if (!state.isAiVault) return;
// 你已清掉其它人的分享时,可指定默认分享者;留空则自动从 from-* / sharedFrom 推断
var hint = window.prompt(
'修复 AI 分组为「用户名/原分组」。\n' +
'若库内只剩一人分享,可留空自动识别;否则填写分享者用户名(如 liaibo):',
'liaibo'
);
if (hint === null) return;
setStatus('正在修复 AI 分组…', 'info');
try {
var body = {};
var actor = String(hint || '').trim();
if (actor) body.defaultSourceActor = actor;
var payload = await apiJson('POST', '/api/vault/ai/repair-folders', state.rootUri, body);
var result = (payload && payload.result) || payload || {};
var n = Number(result.updated || 0);
toast(
n > 0
? '已修复 ' + n + ' 条 → 用户名/原分组(如 liaibo/系统)'
: '无需修复(分组已是 用户名/原分组)',
n > 0 ? 'success' : 'info'
);
await loadList();
} catch (err) {
setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error');
@@ -2893,10 +2997,32 @@
}
}
root.addEventListener('click', function (ev) {
root.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof Element)) return;
// View-mode URL: look like a link, open only with Ctrl/Cmd+click.
var urlLink = t.closest('a.mnote-vault-url-link, a[data-vault-external-url]');
if (urlLink && root.contains(urlLink)) {
var href =
urlLink.getAttribute('data-vault-external-url') ||
urlLink.getAttribute('href') ||
'';
if (!(ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
return;
}
ev.preventDefault();
if (href) {
try {
window.open(href, '_blank', 'noopener,noreferrer');
} catch (_e) {
/* ignore popup block */
}
}
return;
}
var toggle = t.closest('[data-vault-toggle-folder]');
if (toggle && root.contains(toggle)) {
var fpath = toggle.getAttribute('data-vault-toggle-folder') || '';
@@ -3298,8 +3424,7 @@
var shareTitle =
(state.item && state.item.title) || state.selectedId;
var alreadyShared = itemIsSharedToAi(state.item);
if (
!window.confirm(
if (!(await window.mnote.confirm(
alreadyShared
? '将「' +
shareTitle +
@@ -3310,7 +3435,7 @@
'」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' +
'会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' +
'密文 [Key] 模板原样复制,并自动同步缺失密文 key。'
)
))
) {
return;
}
@@ -3343,7 +3468,7 @@
return loadList();
});
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
setStatus(err.message || String(err), 'error');
});
@@ -3367,8 +3492,7 @@
state.item.sharedToAi &&
state.item.sharedToAi.targetActorId) ||
'mnote-e2e';
if (
!window.confirm(
if (!(await window.mnote.confirm(
'撤销「' +
unshareTitle +
'」对 AI 密码本(' +
@@ -3376,7 +3500,7 @@
')的共享?\n' +
'将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' +
'原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。'
)
))
) {
return;
}
@@ -3414,6 +3538,10 @@
return;
}
if (t.closest('[data-vault-repair-ai-folders]')) {
repairAiFolders();
return;
}
if (t.closest('[data-vault-cipher-book]')) {
if (!confirmDiscard()) return;
state.mode = 'cipher';
@@ -3472,7 +3600,7 @@
delete state.cipherRevealed[ek];
return loadCipherBook();
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
});
return;
@@ -3480,7 +3608,7 @@
var cipherDel = t.closest('[data-vault-cipher-delete]');
if (cipherDel) {
var dk = cipherDel.getAttribute('data-vault-cipher-delete');
if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return;
if (!(await window.mnote.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。'))) return;
apiJson(
'DELETE',
'/api/vault/cipher-book/' + encodeURIComponent(dk),
@@ -3570,27 +3698,6 @@
return;
}
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
if (clearNestedSec) {
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
var cAcc = clearNestedSec.getAttribute('data-acc-id');
var formClearSec = qs('[data-vault-form]', detailEl);
if (formClearSec) snapshotFormSlots(formClearSec);
(state._formAccounts || []).forEach(function (acc) {
if (cAcc && acc.id !== cAcc) return;
(acc.secrets || []).forEach(function (sec) {
if (sec.id !== csid) return;
sec.valueClear = true;
sec.valueText = '';
sec.valueState = { state: 'absent' };
});
});
state.dirty = true;
reRenderFormKeepingSlots(state.mode === 'create');
toast('密钥将在保存时清空', 'info');
return;
}
var clearBtn = t.closest('[data-vault-clear-secret]');
if (clearBtn) {
var cf = clearBtn.getAttribute('data-vault-clear-secret');
@@ -3606,7 +3713,7 @@
}
if (t.closest('[data-vault-delete]')) {
if (!window.confirm('确定将条目移入密码箱回收站?')) return;
if (!(await window.mnote.confirm('确定将条目移入密码箱回收站?'))) return;
apiJson(
'DELETE',
'/api/vault/items/' + encodeURIComponent(state.selectedId),
@@ -3637,13 +3744,13 @@
if (tabDeleted) tabDeleted.classList.remove('is-active');
return loadList();
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
});
return;
}
if (t.closest('[data-vault-purge]')) {
if (!window.confirm('永久删除后无法恢复,确定?')) return;
if (!(await window.mnote.confirm('永久删除后无法恢复,确定?'))) return;
apiJson(
'POST',
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge',
+22
View File
@@ -41,6 +41,8 @@ pub struct AppConfig {
pub dev_user_id: String,
pub dev_user_name: String,
pub dev_user_email: String,
/// "dev" | "prod" — 影响前端显示与 env 片段中的 base URL。
pub environment: String,
}
impl AppConfig {
@@ -93,10 +95,29 @@ impl AppConfig {
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()),
environment: detect_environment(),
}
}
}
/// 推断运行环境:显式 MNOTE_WEB_ENV > 端口匹配(3000=dev, 3003=prod> 默认 "dev"。
fn detect_environment() -> String {
if let Ok(v) = env::var("MNOTE_WEB_ENV") {
let t = v.trim().to_lowercase();
if !t.is_empty() {
return t;
}
}
let port = env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.ok()
.and_then(|addr| addr.rsplit(':').next()?.parse::<u16>().ok());
match port {
Some(3003) => "prod".into(),
_ => "dev".into(),
}
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
@@ -392,6 +413,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}
}
+70 -2
View File
@@ -29,14 +29,79 @@ pub struct TraceContext {
pub path: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, PartialEq, Eq)]
pub struct AuthContext {
/// 原始 Authorization;禁止序列化/Debug 明文以免 token 进日志或错误响应。
pub authorization: Option<String>,
pub cookie_header: Option<String>,
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
/// `anonymous` | `session` | `pat` | `extension`7-76
pub auth_method: String,
/// PAT scopessession 为空表示不受 PAT 白名单限制。
pub scopes: Vec<String>,
/// PAT jti(若适用)
pub pat_jti: Option<String>,
}
impl std::fmt::Debug for AuthContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthContext")
.field(
"authorization",
&self
.authorization
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field(
"cookie_header",
&self
.cookie_header
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("actor_id", &self.actor_id)
.field("actor_type", &self.actor_type)
.field(
"session_id",
&self
.session_id
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("auth_method", &self.auth_method)
.field("scopes", &self.scopes)
.field(
"pat_jti",
&self
.pat_jti
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.finish()
}
}
impl Serialize for AuthContext {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// 不序列化 sessionId / token:避免进入错误响应 / 日志 / 诊断载荷。
let mut state = serializer.serialize_struct("AuthContext", 4)?;
state.serialize_field("actorId", &self.actor_id)?;
state.serialize_field("actorType", &self.actor_type)?;
state.serialize_field("authMethod", &self.auth_method)?;
state.serialize_field("scopes", &self.scopes)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@@ -88,6 +153,9 @@ impl RequestContext {
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
.unwrap_or_else(|| "anonymous".into()),
session_id: header_value(headers, HEADER_SESSION_ID),
auth_method: "anonymous".into(),
scopes: Vec::new(),
pat_jti: None,
},
workspace: WorkspaceContext {
workspace_id: header_value(headers, HEADER_WORKSPACE_ID),
@@ -297,6 +297,26 @@ impl BufferStore {
}
}
/// 本地文件 restore 后清除已打开 Markdown buffer 的 deleted 标记。
pub fn clear_local_folder_markdown_deleted(
&self,
workspace_id: &str,
root_uri: &str,
relative_path: &str,
document_id: &str,
) -> Option<DocumentBuffer> {
let path =
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
let key = BufferKey::from_workspace_path(&path);
let mut inner = self.inner.write().expect("BufferStore lock");
if let Some(buf) = inner.buffers.get_mut(&key) {
buf.clear_deleted();
Some(buf.clone())
} else {
None
}
}
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
pub fn init_buffer(
&self,
@@ -738,5 +758,15 @@ mod tests {
.expect("buffer should be marked deleted");
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
let restored = store
.clear_local_folder_markdown_deleted(
"local:test",
root_uri,
"docs/Delete.md",
"local-md:docs~2FDelete.md",
)
.expect("buffer should clear deleted after restore");
assert_eq!(restored.dirty_state, DocBufferDirtyState::Clean);
}
}
+145 -72
View File
@@ -137,54 +137,7 @@ impl EditorRuntimeActor {
format!("文档 {document_id} 尚未加载"),
)
})?;
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
build_block_delta_from_state(state, document_id, command)
}
pub fn new() -> Self {
Self {
@@ -257,33 +210,64 @@ impl EditorRuntimeActor {
)
})?;
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_command_locked(state, document_id, command, command_name)
}
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(
|error| WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")),
)?;
/// 单次写锁内完成 load_or_init + apply + legacy export + BlockDelta。
/// 避免 is_loaded / apply / legacy_content_for_save 分锁导致的并发交错。
pub fn apply_command_and_export(
&self,
document_id: &str,
workspace_id: Option<&str>,
page_aggregate: &Value,
command: EditorCommand,
command_name: &str,
) -> Result<(Value, Option<Value>), WebError> {
let mut documents = self
.documents
.write()
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
state.revision += 1;
state.conflict_detection_key = format!(
"{}:{}:{}",
document_id,
state.revision,
state.last_applied_at.elapsed().as_micros()
);
state.last_applied_at = Instant::now();
if !documents.contains_key(document_id) {
let content = page_aggregate
.pointer("/body/content")
.cloned()
.unwrap_or_else(|| json!([]));
let revision = page_aggregate
.pointer("/body/revision")
.and_then(|value| value.as_u64())
.unwrap_or(1);
let conflict_detection_key = page_aggregate
.pointer("/body/conflictDetectionKey")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let document = editor_document_from_legacy_content(document_id, &content);
documents.insert(
document_id.to_string(),
EditorDocumentState {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(ToString::to_string),
document,
revision,
conflict_detection_key,
last_applied_at: Instant::now(),
},
);
}
Ok(ApplyResult {
ok: true,
command: command_name.to_string(),
document_id: Some(document_id.to_string()),
workspace_id: state.workspace_id.clone(),
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
changed_blocks,
warnings: vec![],
blocked: false,
risk: "low".to_string(),
})
let state = documents.get_mut(document_id).ok_or_else(|| {
WebError::internal(format!(
"EditorRuntimeActor 文档 {document_id} 初始化后仍不可用"
))
})?;
let _apply = apply_command_locked(state, document_id, command.clone(), command_name)?;
let content = legacy_content_from_editor_document(&state.document);
let delta = build_block_delta_from_state(state, document_id, &command)
.ok()
.and_then(|bd| serde_json::to_value(bd).ok());
Ok((content, delta))
}
/// 从内存态生成 legacy content(用于构建 Convex save payload)。
@@ -325,6 +309,95 @@ impl EditorRuntimeActor {
}
}
fn apply_command_locked(
state: &mut EditorDocumentState,
document_id: &str,
command: EditorCommand,
command_name: &str,
) -> Result<ApplyResult, WebError> {
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
})?;
state.revision += 1;
state.conflict_detection_key = format!(
"{}:{}:{}",
document_id,
state.revision,
state.last_applied_at.elapsed().as_micros()
);
state.last_applied_at = Instant::now();
Ok(ApplyResult {
ok: true,
command: command_name.to_string(),
document_id: Some(document_id.to_string()),
workspace_id: state.workspace_id.clone(),
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
changed_blocks,
warnings: vec![],
blocked: false,
risk: "low".to_string(),
})
}
fn build_block_delta_from_state(
state: &EditorDocumentState,
document_id: &str,
command: &EditorCommand,
) -> Result<BlockDelta, WebError> {
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
}
fn extract_changed_blocks(
document: &EditorBlockDocument,
command: &EditorCommand,
@@ -1,4 +1,5 @@
use crate::context::RequestContext;
use crate::routes::api_access_token::{bearer_mnpat1, verify_pat_token};
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
use axum::extract::Request;
use axum::middleware::Next;
@@ -8,23 +9,56 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
let mut context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous)
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() {
// 7-76Bearer mnpat1.* 优先于 cookiePAT 请求忽略 cookie 并权)。
if let Some(token) = bearer_mnpat1(context.auth.authorization.as_deref()) {
if let Ok(verified) = verify_pat_token(token) {
if let Some(actor) = crate::context::stable_actor_id(&verified.subject_user_id) {
context.auth.actor_id = actor;
context.auth.actor_type = if verified.claims.principal_kind == "ai_service" {
"ai_service".into()
} else {
"user".into()
};
context.auth.auth_method = "pat".into();
context.auth.scopes = verified.claims.scope.clone();
context.auth.pat_jti = Some(verified.claims.jti.clone());
context.auth.session_id = Some(format!("pat:{}", verified.claims.jti));
// 清除 cookie 头语义:后续 current_actor_id 见 auth_method=pat 短路
context.auth.cookie_header = None;
}
}
}
// 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / 仍 anonymous
if context.auth.auth_method != "pat"
&& (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty())
{
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
if let Ok(claims) = verify_extension_token(token) {
context.auth.actor_id = claims.actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
if let Some(actor) = crate::context::stable_actor_id(&claims.actor) {
context.auth.actor_id = actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
context.auth.auth_method = "extension".into();
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
}
}
}
}
}
// cookie actor 已存在时标记 session 方法(真正 session 校验仍在 current_actor_id
if context.auth.auth_method == "anonymous"
&& context.auth.actor_id.trim() != "anonymous"
&& !context.auth.actor_id.trim().is_empty()
{
context.auth.auth_method = "session".into();
}
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
@@ -9,7 +9,10 @@ use bridge_runtime::{
};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::path::Path;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub async fn create_summary(
state: &AppState,
@@ -79,7 +82,8 @@ async fn create_artifact_node(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(context)
})?;
ensure_local_workspace_access(context, &root_uri)
// 使用 ensure 返回的规范化路径,避免权限检查与 I/O 路径不一致。
let root_path = ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
if input.dry_run.unwrap_or(false) {
return Ok(json!({
@@ -93,7 +97,6 @@ async fn create_artifact_node(
"diff": [{"op": "create_artifact", "artifactType": node_type}]
}));
}
let root_path = parse_local_root_path(&root_uri)?;
let artifact_dir = root_path.join(".mnote").join("artifacts");
fs::create_dir_all(&artifact_dir).map_err(|error| {
WebError::bad_request_code(
@@ -105,10 +108,12 @@ async fn create_artifact_node(
)
.with_context(context)
})?;
let artifact_path = artifact_dir.join(format!(
"{}.json",
sanitize_local_artifact_file_name(&artifact_document_id)
));
let artifact_file_name =
format!("{}.json", sanitize_local_artifact_file_name(&artifact_document_id));
let artifact_path = artifact_dir.join(&artifact_file_name);
let created_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
let artifact_value = json!({
"schema": "mnote.local_artifact.v1",
"artifactType": node_type,
@@ -116,16 +121,14 @@ async fn create_artifact_node(
"documentId": document_id,
"workspaceId": workspace_id,
"content": content,
"createdAt": context.trace.trace_id,
"createdAt": created_at,
"traceId": context.trace.trace_id,
});
fs::write(
&artifact_path,
serde_json::to_string_pretty(&artifact_value).map_err(|error| {
WebError::internal(format!("本地 artifact 序列化失败: {error}"))
.with_context(context)
})?,
)
.map_err(|error| {
let artifact_body = serde_json::to_string_pretty(&artifact_value).map_err(|error| {
WebError::internal(format!("本地 artifact 序列化失败: {error}")).with_context(context)
})?;
// tmp + fsync + rename,降低并发写半截文件 / 丢失更新风险(对齐 vault atomic_write)。
atomic_write_string(&artifact_path, &artifact_body).map_err(|error| {
WebError::bad_request_code(
"local_artifact_write_failed",
format!(
@@ -135,6 +138,8 @@ async fn create_artifact_node(
)
.with_context(context)
})?;
// 响应只返回相对路径,避免泄露服务器绝对路径。
let relative_artifact_path = format!(".mnote/artifacts/{artifact_file_name}");
return Ok(json!({
"dryRun": false,
"commandName": "tree.node.create",
@@ -147,7 +152,7 @@ async fn create_artifact_node(
"result": {
"ok": true,
"source": "local_folder",
"artifactPath": artifact_path,
"artifactPath": relative_artifact_path,
"artifactDocumentId": artifact_document_id,
}
}));
@@ -248,21 +253,6 @@ async fn create_artifact_node(
}))
}
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
stripped.trim()
} else {
root_uri.trim()
};
if root_path.is_empty() {
return Err(WebError::bad_request_code(
"local_folder_root_required",
"缺少本地文件夹 rootUri",
));
}
Ok(PathBuf::from(root_path))
}
fn sanitize_local_artifact_file_name(value: &str) -> String {
value
.chars()
@@ -275,6 +265,21 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
.to_string()
}
/// 本地 artifact 原子写:先写同目录临时文件,sync 后 rename 覆盖目标。
fn atomic_write_string(path: &Path, content: &str) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
{
let mut file = fs::File::create(&tmp)?;
file.write_all(content.as_bytes())?;
let _ = file.sync_all();
}
fs::rename(&tmp, path)?;
Ok(())
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
@@ -323,6 +323,8 @@ pub async fn block_move_after(
&anchor_block_id,
"move_after_anchor",
)?;
// move_after 对复杂/不可编辑块走 soft-block200 + blocked),便于 agent 规划;
// 与 replace/delete 的硬失败不同,避免把「暂不支持」误报成工具调用错误。
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
let leaf = block
.get("children")
@@ -612,12 +614,21 @@ pub async fn doc_apply_block_ops(
&anchor_block_id,
"move_after_anchor",
)?;
if block.get("parentBlockId") != anchor.get("parentBlockId")
// 与单个 block_move_after 一致:仅 paragraph/heading/todo/task 可移动。
let movable_type = block
.get("type")
.and_then(Value::as_str)
.map(|block_type| {
matches!(block_type, "paragraph" | "heading" | "todo" | "task")
})
.unwrap_or(false);
if !movable_type
|| block.get("parentBlockId") != anchor.get("parentBlockId")
|| block_id == anchor_block_id
{
return Err(WebError::bad_request_code(
"mnote_block_unsupported",
"move_after 批量快路径第一阶段仅支持同父级普通叶子块",
"move_after 批量快路径第一阶段仅支持同父级普通叶子块paragraph/heading/todo/task",
)
.with_context(context));
}
@@ -988,15 +999,27 @@ fn value_label(value: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}
/// 将 revision 规范化为 u64 JSON;无法解析时保留原值,避免静默变成 null 绕过冲突检测。
fn revision_number_value(value: Value) -> Option<Value> {
if value.is_null() {
return None;
}
if let Some(number) = value.as_u64() {
return Some(json!(number));
}
value
.as_str()
.map(str::trim)
.and_then(|value| value.parse::<u64>().ok())
.map(|number| json!(number))
if let Some(number) = value.as_i64() {
if number >= 0 {
return Some(json!(number as u64));
}
}
if let Some(raw) = value.as_str().map(str::trim).filter(|v| !v.is_empty()) {
if let Ok(number) = raw.parse::<u64>() {
return Some(json!(number));
}
// 非数值字符串:原样保留,由下游冲突检测处理,不丢 revision。
return Some(json!(raw));
}
Some(value)
}
fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
@@ -1063,30 +1086,15 @@ fn compute_next_content_via_actor(
})?;
let workspace_id = input.effective_workspace_id();
// 确保 actor 已加载此文档
if !state.editor_actor.is_loaded(&document_id) {
state
.editor_actor
.load_or_init(&document_id, workspace_id.as_deref(), aggregate)?;
}
// 单次写锁内完成 load + apply + export,避免并发交错损坏内存态。
let (content, delta) = state.editor_actor.apply_command_and_export(
&document_id,
workspace_id.as_deref(),
aggregate,
command.clone(),
command_name,
)?;
// 在内存中 apply
let _apply_result =
state
.editor_actor
.apply_command(&document_id, command.clone(), command_name)?;
// 从 actor 获取 legacy content(用于 Convex save 的 payload
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
// 构建 BlockDeltaPhase B)并序列化为 JSON
let delta = state
.editor_actor
.build_block_delta(&document_id, command)
.ok()
.and_then(|bd| serde_json::to_value(bd).ok());
// Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream
if let Some(ref delta_json) = delta {
state.editor_actor.try_push_block_delta(delta_json);
}
@@ -1238,7 +1246,17 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
}))
}
/// 递归抽取 content 文本的最大深度,防止恶意/异常嵌套导致栈溢出。
const CONTENT_TO_TEXT_MAX_DEPTH: usize = 32;
fn content_to_text(value: &Value) -> String {
content_to_text_limited(value, CONTENT_TO_TEXT_MAX_DEPTH)
}
fn content_to_text_limited(value: &Value, depth: usize) -> String {
if depth == 0 {
return String::new();
}
if let Some(text) = value.as_str() {
return text.to_string();
}
@@ -1246,18 +1264,18 @@ fn content_to_text(value: &Value) -> String {
return text.to_string();
}
if let Some(payload) = value.get("payload") {
return content_to_text(payload);
return content_to_text_limited(payload, depth - 1);
}
if let Some(content_nodes) = value.get("contentNodes") {
return content_to_text(content_nodes);
return content_to_text_limited(content_nodes, depth - 1);
}
if let Some(content) = value.get("content") {
return content_to_text(content);
return content_to_text_limited(content, depth - 1);
}
if let Some(items) = value.as_array() {
return items
.iter()
.map(content_to_text)
.map(|item| content_to_text_limited(item, depth - 1))
.collect::<Vec<_>>()
.join("");
}
@@ -1271,8 +1289,11 @@ fn ensure_allowed_target(
block_id: &str,
op: &str,
) -> Result<(), WebError> {
let allowed = allowed_target_block_ids(input, operation);
if allowed.is_empty() || allowed.contains(block_id) {
// None = 未声明 selection 限制(整页写权限路径);Some = 显式限制(含空数组 fail-closed)。
let Some(allowed) = allowed_target_block_scope(input, operation) else {
return Ok(());
};
if allowed.contains(block_id) {
return Ok(());
}
Err(WebError::bad_request_code(
@@ -1282,9 +1303,17 @@ fn ensure_allowed_target(
.with_context(context))
}
fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> {
/// 解析 `allowedTargetBlockIds` 作用域。
/// - `None`input/operation 均未提供该字段 → 不施加 selection 限制
/// - `Some(set)`:至少一处显式提供(含空数组)→ fail-closed,仅集合内 block 可写
fn allowed_target_block_scope(
input: &ToolCallInput,
operation: &Value,
) -> Option<HashSet<String>> {
let mut scope_declared = false;
let mut allowed = HashSet::new();
if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
scope_declared = true;
for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string());
@@ -1295,13 +1324,18 @@ fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
{
scope_declared = true;
for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string());
}
}
}
allowed
if scope_declared {
Some(allowed)
} else {
None
}
}
fn block_to_ai_content(
@@ -1550,4 +1584,21 @@ mod tests {
assert_eq!(content_to_text(&value), "agent 插件插入段");
}
#[test]
fn content_to_text_stops_at_max_depth() {
// 构造远超 CONTENT_TO_TEXT_MAX_DEPTH 的 payload 链,验证不 panic 且深度耗尽后返回空。
let mut nested = json!({"text": "deep-leaf"});
for _ in 0..(CONTENT_TO_TEXT_MAX_DEPTH + 8) {
nested = json!({ "payload": nested });
}
assert_eq!(content_to_text(&nested), "");
// 深度内仍可读到文本。
let mut shallow = json!({"text": "ok"});
for _ in 0..3 {
shallow = json!({ "payload": shallow });
}
assert_eq!(content_to_text(&shallow), "ok");
}
}
@@ -524,20 +524,39 @@ pub(crate) fn ensure_ai_scope_resource_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(());
};
let allowed = scope
// 与 resource ensure_resource_scope_allowed 对齐:
// - 未声明 allowedResourceIds → 不按资源 id 收紧
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty() || allowed.contains(document_id) {
.or_else(|| scope.get("allowed_resource_ids"));
let Some(raw) = raw else {
return Ok(());
};
let Some(values) = raw.as_array() else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_invalid",
"aiAccessScope.allowedResourceIds 必须是字符串数组",
)
.with_context(context));
};
let allowed = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝读取任何资源",
)
.with_context(context));
}
if allowed.contains(document_id) {
return Ok(());
}
Err(WebError::new(
@@ -575,6 +594,19 @@ fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
})
}
/// full_content 创建场景:仅文档缺失可当空文档;鉴权/越权/网络等错误必须传播。
fn is_document_missing_for_full_content(error: &WebError) -> bool {
matches!(
error.code(),
"local_markdown_not_found"
| "page_not_found"
| "document_not_found"
| "mnote_resource_page_not_found"
| "local_file_unavailable"
| "local_file_not_file"
) || error.status() == StatusCode::NOT_FOUND
}
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
aggregate
.pointer("/body/blockDocument/blocks")
@@ -804,15 +836,18 @@ fn extract_block_comment(line: &str) -> (Option<String>, Option<(String, String,
(None, None)
}
/// 提取 `[mnote-raw-block:ID]` 标记
/// 提取 `[mnote-raw-block:ID]` 标记
/// 前缀长度为 17`[mnote-raw-block:`);`]` 必须在 marker 之后,避免 panic / 截断首字符。
fn extract_raw_block_marker(line: &str) -> Option<String> {
let start = line.find("[mnote-raw-block:");
let end = line.find(']');
if let (Some(start), Some(end)) = (start, end) {
let id = &line[start + 18..end];
return Some(id.to_string());
const MARKER: &str = "[mnote-raw-block:";
let start = line.find(MARKER)?;
let id_start = start + MARKER.len();
let end = line[id_start..].find(']')? + id_start;
let id = line[id_start..end].trim();
if id.is_empty() {
return None;
}
None
Some(id.to_string())
}
/// 从行前缀推断块类型
@@ -1140,6 +1175,9 @@ fn blocks_to_content(
"page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
"text" | "plain" => blocks_to_text(blocks, include_ids),
"markdown" | "md" => blocks_to_markdown(blocks, include_ids),
// 默认 format=jsoncontent 返回 blocks 的 JSON 文本,与 format 字段一致;
// 结构化数组仍在响应的 blocks 字段中。
"json" => serde_json::to_string(blocks).unwrap_or_else(|_| "[]".into()),
_ => blocks_to_markdown(blocks, include_ids),
}
}
@@ -1291,6 +1329,45 @@ mod tests {
assert!(result.unwrap_err().contains("无法匹配"));
}
#[test]
fn test_search_replace_not_found_utf8_long_needle_no_panic() {
// 多字节中文 needle 超过 60 字符时,错误消息截断不得 panic。
// 使用与原文差异极大的 needle,避免 Level3 fuzzy30% 容限)误匹配。
let needle: String = format!("UNIQUE_NEEDLE_{}", "".repeat(80));
let result = search_replace("完全不同的短文内容ABC", &needle, "x");
let err = result.expect_err("应无法匹配");
assert!(err.contains("无法匹配"), "err={err}");
assert!(err.contains("..."), "err={err}");
// 截断后总长合理(前缀 60 字 + "..." + 引号包装)
assert!(err.chars().count() < 120, "err len={}", err.chars().count());
}
#[test]
fn test_truncate_for_error_char_safe() {
assert_eq!(truncate_for_error("abc", 10), "abc");
assert_eq!(truncate_for_error("abcdefghij", 5), "abcde...");
let s: String = "".repeat(70);
let t = truncate_for_error(&s, 60);
assert!(t.ends_with("..."));
assert_eq!(t.chars().count(), 63); // 60 + "..."
}
#[test]
fn test_fuzzy_match_rejects_pattern_longer_than_text() {
let long: String = "".repeat(80);
assert!(!fuzzy_match("短文", &long, 0.3));
assert!(fuzzy_match("第一段内容差不多", "第一段内容差不", 0.3));
}
#[test]
fn test_search_replace_fuzzy_paragraph_no_unwrap_panic() {
// Level3:段落 fuzzy 命中后必须安全替换,不依赖 find().unwrap()。
let text = "引言。\n\n第一段内容差不多。\n\n结尾。";
let result = search_replace(text, "第一段内容差不", "替换段").expect("fuzzy 应命中");
assert!(result.contains("替换段"), "result={result}");
assert!(!result.contains("第一段内容差不多"), "result={result}");
}
#[test]
fn test_search_replace_full_content() {
// 全文替换:search 等于全文
@@ -1335,6 +1412,30 @@ mod tests {
assert!(md.contains("<!-- block:rsc_1:resource -->"));
}
#[test]
fn test_extract_raw_block_marker_keeps_full_id() {
assert_eq!(
extract_raw_block_marker("[mnote-raw-block:rsc_1] <!-- block:rsc_1:resource -->")
.as_deref(),
Some("rsc_1")
);
// 前缀 `]` 不得影响 marker 后的 id 截取
assert_eq!(
extract_raw_block_marker("prefix] [mnote-raw-block:abc_9] tail").as_deref(),
Some("abc_9")
);
assert_eq!(extract_raw_block_marker("no marker here"), None);
}
#[test]
fn test_blocks_to_content_json_format() {
let blocks = json!([{"blockId": "b1", "text": "x", "type": "paragraph"}]);
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
let content = blocks_to_content("json", &blocks, true, "doc_1", &json!({}));
let parsed: Value = serde_json::from_str(&content).expect("json content");
assert_eq!(parsed[0]["blockId"], "b1");
}
#[test]
fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() {
let original_content = json!([
@@ -1489,23 +1590,43 @@ pub async fn doc_markdown_edit(
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
let is_local_file = document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.starts_with("file://");
let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
// 本地绝对/相对路径写:强制 rootUri + 路径守卫(防穿越)
let local_file_path = if is_local_file {
let tool_root = local_root_uri_for_tool(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"ai_scope_root_uri_required",
"本地文件写入需要授权 rootUri",
)
.with_context(context)
})?;
Some(
crate::routes::ensure_local_path_write_access(context, &tool_root, &document_id)
.map_err(|error| error.with_context(context))?,
)
} else {
None
};
// 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file {
let (current_md, source) = if let Some(ref path) = local_file_path {
use std::fs;
// full_content 模式时允许文件不存在(创建新文件)
let has_full = input.arg_value("full_content").is_some();
let content = match fs::read_to_string(&document_id) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) if has_full => String::new(), // 创建模式:空内容
Err(error) => {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法读取本地文件 {document_id}: {error}"),
format!("无法读取本地文件 {}: {error}", path.display()),
)
.with_context(context));
}
@@ -1629,23 +1750,24 @@ pub async fn doc_markdown_edit(
};
// 5. 写回(本地文件直接 fs::writeConvex 文档通过 block ops apply
let apply_result = if is_local_file {
let apply_result = if let Some(ref path) = local_file_path {
if input.dry_run.unwrap_or(false) {
json!({"written": false, "dryRun": true, "path": document_id.clone()})
json!({"written": false, "dryRun": true, "path": path.display().to_string()})
} else {
use std::fs;
fs::write(&document_id, &md).map_err(|error| {
fs::write(path, &md).map_err(|error| {
WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法写入本地文件 {document_id}: {error}"),
format!("无法写入本地文件 {}: {error}", path.display()),
)
.with_context(context)
})?;
json!({"written": true, "path": document_id.clone()})
json!({"written": true, "path": path.display().to_string()})
}
} else {
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
// full_content 仅对「文档不存在」类错误放行为空文档;鉴权/网络等错误 fail-closed
let (aggregate, blocks, original_content) =
match aggregate_value(state, context, input).await {
Ok(agg) => {
@@ -1653,8 +1775,10 @@ pub async fn doc_markdown_edit(
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
(agg, blocks, original_content)
}
Err(_) if use_full_content.is_some() => {
// 空文档 + full_content:跳过读取
Err(error)
if use_full_content.is_some()
&& is_document_missing_for_full_content(&error) =>
{
(Value::Null, vec![], json!([]))
}
Err(e) => return Err(e),
@@ -1817,20 +1941,27 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
// Level 3: 按段落 fuzzy30% 字符差异容限)
for para in text.split("\n\n") {
if fuzzy_match(para, search, 0.3) {
let idx = text.find(para).unwrap();
// para 来自 split,正常必是 text 子串;不用 unwrap,避免极端 Unicode 规范化下 panic。
let Some(idx) = text.find(para) else {
continue;
};
let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]);
return Ok(replaced);
}
}
// Level 4: 失败
Err(format!(
"无法匹配 \"{}\"",
if search.len() > 60 {
format!("{}...", &search[..60])
} else {
search.to_string()
}
))
// Level 4: 失败(按字符截断,避免 UTF-8 多字节边界 panic
Err(format!("无法匹配 \"{}\"", truncate_for_error(search, 60)))
}
/// 按 Unicode 标量截断用于错误消息;不在 UTF-8 字节中间切开。
fn truncate_for_error(s: &str, max_chars: usize) -> String {
let mut it = s.chars();
let head: String = it.by_ref().take(max_chars).collect();
if it.next().is_some() {
format!("{head}...")
} else {
head
}
}
fn search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> {
@@ -1896,9 +2027,17 @@ fn normalize_search_char(ch: char) -> Option<char> {
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
let text_chars: Vec<char> = text.chars().collect();
let pat_chars: Vec<char> = pattern.chars().collect();
if pat_chars.is_empty() {
return false;
}
// pattern 比 text 长时无法在 text 上形成等长窗口;旧逻辑用 min 缩短窗口,
// 会把 max_dist 相对长 needle 放大到「几乎任意短段落都命中」。
if pat_chars.len() > text_chars.len() {
return false;
}
let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize;
// 简单的滑动窗口匹配
for window in text_chars.windows(pat_chars.len().min(text_chars.len())) {
// 等长滑动窗口:只比较完整 pattern 窗口
for window in text_chars.windows(pat_chars.len()) {
let dist = window
.iter()
.zip(pat_chars.iter())
@@ -62,6 +62,8 @@ pub async fn index_refresh(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
// 索引刷新会写本地索引文件,只读 scope / ai_can_write=false 必须拒绝。
ensure_write_authorized(context, input)?;
let workspace_id = effective_workspace_id(context, input);
let root_uri = required_root_uri(
context,
@@ -70,7 +72,7 @@ pub async fn index_refresh(
"本地索引刷新缺少 rootUri",
)?;
let root_path =
routes::ensure_local_workspace_read_access_with_state(state, context, &root_uri)
routes::ensure_local_workspace_write_access_with_state(state, context, &root_uri)
.map_err(|error| error.with_context(context))?;
let effective_settings = routes::effective_local_index_settings_for_root(
state.control_plane(),
@@ -14,18 +14,29 @@ pub async fn status(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
// status 与 inject_identity 一致:context / 顶层绑定优先,不采用 args 自报身份。
let body = KnowledgeRagStatusQuery {
workspace_id: input.effective_workspace_id().or_else(|| {
args.get("workspaceId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
root_uri: input.effective_root_uri().or_else(|| {
args.get("rootUri")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
workspace_id: context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
input
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}),
root_uri: input
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
};
let Json(payload) = crate::routes::knowledge_rag::status(
State(state.clone()),
@@ -42,12 +53,12 @@ pub async fn search(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_weknora_search_payload_invalid",
format!("WeKnora 检索参数无效: {error}"),
"mnote_knowledge_search_payload_invalid",
format!("知识库检索参数无效: {error}"),
)
.with_context(context)
})?;
@@ -66,7 +77,7 @@ pub async fn query(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input);
inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
@@ -89,8 +100,8 @@ pub async fn open_reference(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input, context);
let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -114,7 +125,7 @@ pub async fn section_context(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input);
inject_identity_args(&mut args, input, context);
let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -138,16 +149,16 @@ pub async fn list_sources(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
ensure_knowledge_tool_scope(&args, input, context)?;
let payload = status(state, context, input).await?;
Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.weknora.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"schema": "mnote.knowledge.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
"registry": payload.get("registry").cloned().unwrap_or(Value::Null),
"documents": payload.get("documents").cloned().unwrap_or(Value::Null),
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.",
"locatorPolicy": "Provider ids are returned separately; filenames and chunk ids are not local paths.",
}))
}
@@ -157,7 +168,7 @@ pub async fn get_source_status(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
ensure_knowledge_tool_scope(&args, input, context)?;
let payload = list_sources(state, context, input).await?;
let requested_source = args
.get("sourcePath")
@@ -186,28 +197,50 @@ pub async fn get_source_status(
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"schema": "mnote.weknora.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"schema": "mnote.knowledge.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
"sources": matched,
"requestedSource": requested_source,
}))
}
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) {
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
/// 身份字段 fail-closed 注入:
/// 1) `RequestContext` 头/会话 workspace
/// 2) `ToolCallInput` **顶层** workspaceId/rootUri(宿主绑定)
/// 绝不采用 `args` 里的用户自报身份(防跨 workspace 劫持)。
fn inject_identity_args(args: &mut Value, input: &ToolCallInput, context: &RequestContext) {
let workspace_id = context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
input
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
if let Some(workspace_id) = workspace_id {
args["workspaceId"] = json!(workspace_id);
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
// rootUri 不在 RequestContext;仅信任 tool 顶层绑定,不读 args。
if let Some(root_uri) = input
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args["rootUri"] = json!(root_uri);
}
}
fn ensure_weknora_scope(
fn ensure_knowledge_tool_scope(
args: &Value,
input: &ToolCallInput,
context: &RequestContext,
@@ -229,8 +262,8 @@ fn ensure_weknora_scope(
return Ok(());
}
Err(WebError::bad_request_code(
"mnote_weknora_scope_required",
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
"mnote_knowledge_scope_required",
"knowledge tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
)
.with_context(context))
}
@@ -297,7 +330,7 @@ pub(crate) fn compact_query_result_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"referenceCount": payload_references.len(),
@@ -643,7 +676,7 @@ pub(crate) fn compact_section_context_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
@@ -669,7 +702,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.unwrap_or_else(|| quote_diagnostics(&quote));
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
@@ -693,7 +726,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
.unwrap_or_default();
json!({
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
@@ -1010,4 +1043,79 @@ mod tests {
< 60_000
);
}
#[test]
fn inject_identity_args_prefers_request_context_over_user_args() {
use crate::context::{
AuthContext, RequestContext, SourceContext, TraceContext, WorkspaceContext,
};
use crate::mnote_agent_tools::ToolCallInput;
let context = RequestContext {
trace: TraceContext {
request_id: "req".into(),
trace_id: "tr".into(),
method: "POST".into(),
path: "/api".into(),
},
auth: AuthContext {
authorization: None,
cookie_header: None,
actor_id: "user_1".into(),
actor_type: "user".into(),
session_id: None,
auth_method: "session".into(),
scopes: Vec::new(),
pat_jti: None,
},
workspace: WorkspaceContext {
workspace_id: Some("ws-from-header".into()),
tenant_id: None,
deployment_id: None,
project_id: None,
},
source: SourceContext {
channel: "http".into(),
client: "test".into(),
idempotency_key: None,
},
};
let input = ToolCallInput {
tool_name: "mnote.knowledge_rag.query".into(),
workspace_id: Some("ws-from-top".into()),
document_id: None,
source_kind: None,
root_uri: Some("file:///bound-root".into()),
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(json!({
"workspaceId": "ws-attacker",
"rootUri": "file:///etc",
"query": "x",
})),
};
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args, &input, &context);
assert_eq!(args["workspaceId"].as_str(), Some("ws-from-header"));
assert_eq!(args["rootUri"].as_str(), Some("file:///bound-root"));
// 无 header 时用顶层,仍忽略 args
let context_no_ws = RequestContext {
workspace: WorkspaceContext {
workspace_id: None,
..context.workspace.clone()
},
..context.clone()
};
let mut args2 = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args2, &input, &context_no_ws);
assert_eq!(args2["workspaceId"].as_str(), Some("ws-from-top"));
assert_eq!(args2["rootUri"].as_str(), Some("file:///bound-root"));
}
}
@@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGWeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGWeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -2096,13 +2096,16 @@ fn onlyoffice_tool(
{ "required": ["bridgeSessionId"] }
]);
}
// 清空幻灯片 / 删除页等会不可逆丢内容,必须标 destructive,避免 AI 当普通写操作。
let destructive =
!readonly && (name.contains("delete") || name.contains("clear_slide"));
json!({
"name": name,
"description": description,
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": capability_scope.into_iter().collect::<Vec<_>>(),
"status": "available",
"annotations": tool_annotations(readonly, false, readonly, false),
"annotations": tool_annotations(readonly, destructive, readonly, destructive),
"inputSchema": input_schema
})
}
@@ -141,15 +141,17 @@ impl ToolCallInput {
}
pub fn ai_access_scope_is_read_only(&self) -> bool {
match self.ai_access_permission_level() {
Some(level) => is_read_only_permission_level(&level),
// 缺 scope / permissionLevel 时不再默认“可写”;由 ensure_write_authorized 统一 fail-closed。
None => false,
}
}
/// 是否显式声明了可写 permissionLevel(缺省不算可写)。
pub fn ai_access_scope_explicitly_allows_write(&self) -> bool {
self.ai_access_permission_level()
.map(|level| {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
})
.map(|level| !is_read_only_permission_level(&level))
.unwrap_or(false)
}
@@ -164,12 +166,13 @@ impl ToolCallInput {
.or_else(|| ctx.get("workspace_readonly"))
.and_then(Value::as_bool)
.unwrap_or(false);
// 与 core-protocol CommandContext 一致:未显式声明时默认不可写(fail-closed
let ai_can_write = ctx
.get("ai.canWrite")
.or_else(|| ctx.get("aiCanWrite"))
.or_else(|| ctx.get("ai_can_write"))
.and_then(Value::as_bool)
.unwrap_or(true);
.unwrap_or(false);
Some(CommandContextBridge {
workspace_readonly,
ai_can_write,
@@ -191,13 +194,23 @@ pub struct CommandContextBridge {
pub ai_can_write: bool,
}
fn is_read_only_permission_level(level: &str) -> bool {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
}
/// 统一的 agent tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput
/// - 如果提供了 `bridge` `ai_can_write == false`拒绝
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
/// - 若声明了只读 `aiAccessScope.permissionLevel`,拒绝
/// - 提供了 `commandContext``workspace.readonly` / `ai.canWrite` 任一拒绝则拒绝
/// - **fail-closed**:必须至少有一方显式授予写权限
/// `aiAccessScope` 可写 permissionLevel,或 `commandContext.ai.canWrite=true`
///
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
pub fn ensure_write_authorized(
@@ -218,7 +231,12 @@ pub fn ensure_write_authorized(
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
// 显式只读 scope 优先拒绝(含 shared_read
if input
.ai_access_permission_level()
.as_deref()
.is_some_and(is_read_only_permission_level)
{
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
@@ -226,7 +244,9 @@ pub fn ensure_write_authorized(
)
.with_context(context));
}
if let Some(bridge) = input.command_context_bridge() {
let bridge = input.command_context_bridge();
if let Some(bridge) = bridge {
if bridge.workspace_readonly {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
@@ -244,6 +264,20 @@ pub fn ensure_write_authorized(
.with_context(context));
}
}
let scope_allows_write = input.ai_access_scope_explicitly_allows_write();
let bridge_allows_write = bridge
.map(|b| b.ai_can_write && !b.workspace_readonly)
.unwrap_or(false);
// 缺 aiAccessScope 且缺 commandContext(或两者都未授予写)→ fail-closed
if !scope_allows_write && !bridge_allows_write {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_write_authorization_required",
"写入型 mnote agent tool 必须携带可写 aiAccessScope.permissionLevel 或 commandContext.ai.canWrite=true",
)
.with_context(context));
}
Ok(())
}
@@ -298,6 +332,23 @@ mod tests {
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test]
fn ensure_write_authorized_rejects_missing_ai_can_write_default_false() {
// commandContext 存在但未声明 ai.canWrite → fail-closed 拒绝写
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"workspace.readonly": false
}
})),
)
.expect_err("missing ai.canWrite should default false and reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test]
fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
let error = ensure_write_authorized(
@@ -314,4 +365,39 @@ mod tests {
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
}
#[test]
fn ensure_write_authorized_rejects_missing_command_context_and_scope() {
// 无 aiAccessScope、无 commandContext → fail-closed,禁止写
let error = ensure_write_authorized(&context(), &write_input(json!({})))
.expect_err("missing write authorization should reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_write_authorization_required");
}
#[test]
fn ensure_write_authorized_accepts_explicit_write_scope_without_command_context() {
ensure_write_authorized(
&context(),
&write_input(json!({
"aiAccessScope": { "permissionLevel": "read_write" }
})),
)
.expect("explicit write scope should authorize without commandContext");
}
#[test]
fn ensure_write_authorized_accepts_command_context_write_without_scope() {
ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
})),
)
.expect("commandContext ai.canWrite=true should authorize without aiAccessScope");
}
}
@@ -311,6 +311,9 @@ pub async fn document_insert_html(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let html = required_string(context, input, "html")?;
// AI / bridge 传入的 HTML 可能含 script / on* / javascript: 等载荷;
// OnlyOffice 虽不完全执行脚本,仍会落盘或外链,必须先净化。
let html = sanitize_insert_html(&html);
run_write_action(
context,
input,
@@ -320,6 +323,234 @@ pub async fn document_insert_html(
.await
}
/// 轻量 HTML 净化:剥危险标签与事件处理器,保留基础排版标签。
/// 不引入 ammonia / regex 依赖;面向 OnlyOffice insert_html 的最小防御。
fn sanitize_insert_html(html: &str) -> String {
let without_blocks = strip_dangerous_html_blocks(html);
strip_dangerous_html_attrs(&without_blocks)
}
const DANGEROUS_BLOCK_TAGS: &[&str] = &[
"script", "style", "iframe", "object", "embed", "link", "meta", "base", "form",
];
fn strip_dangerous_html_blocks(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let bytes = html.as_bytes();
let lower_bytes = lower.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
// 拷贝到下一个 '<' 或末尾
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 解析标签名
let after_lt = i + 1;
let is_close = after_lt < bytes.len() && bytes[after_lt] == b'/';
let name_start = if is_close { after_lt + 1 } else { after_lt };
// 跳过空白
let mut name_pos = name_start;
while name_pos < lower_bytes.len() && lower_bytes[name_pos].is_ascii_whitespace() {
name_pos += 1;
}
let mut name_end = name_pos;
while name_end < lower_bytes.len()
&& (lower_bytes[name_end].is_ascii_alphanumeric() || lower_bytes[name_end] == b'-' || lower_bytes[name_end] == b':')
{
name_end += 1;
}
let tag = std::str::from_utf8(&lower_bytes[name_pos..name_end]).unwrap_or("");
let is_dangerous = DANGEROUS_BLOCK_TAGS.iter().any(|t| *t == tag);
// 找到本标签结束 '>'(粗处理引号内 > 较少见,足够防御)
let mut tag_end = name_end;
while tag_end < bytes.len() && bytes[tag_end] != b'>' {
tag_end += 1;
}
if tag_end >= bytes.len() {
// 残缺标签:丢弃剩余
break;
}
let self_close = tag_end > i && bytes[tag_end - 1] == b'/';
if !is_dangerous {
out.push_str(&html[i..=tag_end]);
i = tag_end + 1;
continue;
}
// 危险开标签:跳过到匹配闭合(或自闭合)
i = tag_end + 1;
if is_close || self_close {
continue;
}
// 找 </tag ...>
let close_pat = format!("</{tag}");
let close_bytes = close_pat.as_bytes();
while i < lower_bytes.len() {
if lower_bytes[i..].starts_with(close_bytes) {
// 前进到 '>'
let mut k = i + close_bytes.len();
while k < bytes.len() && bytes[k] != b'>' {
k += 1;
}
i = if k < bytes.len() { k + 1 } else { bytes.len() };
break;
}
i += 1;
}
}
out
}
fn strip_dangerous_html_attrs(html: &str) -> String {
let bytes = html.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 标签起点
out.push('<');
i += 1;
// 保留 / 与 tag name + 空白分隔
while i < bytes.len() && bytes[i] != b'>' {
// 尝试读一个属性
// 跳过空白
if bytes[i].is_ascii_whitespace() {
// 窥探下一个属性名
let mut k = i;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k >= bytes.len() || bytes[k] == b'>' || bytes[k] == b'/' {
// 空白直接写入到 > 或 /
out.push(bytes[i] as char);
i += 1;
continue;
}
// 读属性名
let name_start = k;
let mut name_end = k;
while name_end < bytes.len()
&& (bytes[name_end].is_ascii_alphanumeric()
|| bytes[name_end] == b'-'
|| bytes[name_end] == b':'
|| bytes[name_end] == b'_')
{
name_end += 1;
}
if name_end == name_start {
out.push(bytes[i] as char);
i += 1;
continue;
}
let attr_name = html[name_start..name_end].to_ascii_lowercase();
// 找 = 与值
let mut p = name_end;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
let mut value = String::new();
let value_end;
if p < bytes.len() && bytes[p] == b'=' {
p += 1;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
if p < bytes.len() && (bytes[p] == b'"' || bytes[p] == b'\'') {
let quote = bytes[p];
p += 1;
let vstart = p;
while p < bytes.len() && bytes[p] != quote {
p += 1;
}
value = html[vstart..p].to_string();
if p < bytes.len() {
p += 1; // closing quote
}
value_end = p;
} else {
let vstart = p;
while p < bytes.len()
&& !bytes[p].is_ascii_whitespace()
&& bytes[p] != b'>'
&& bytes[p] != b'/'
{
p += 1;
}
value = html[vstart..p].to_string();
value_end = p;
}
} else {
value_end = name_end;
}
let drop_attr = should_drop_html_attr(&attr_name, &value);
if drop_attr {
i = value_end;
continue;
}
// 保留:从原空白起点到 value_end
out.push_str(&html[i..value_end]);
i = value_end;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
if i < bytes.len() && bytes[i] == b'>' {
out.push('>');
i += 1;
}
}
out
}
fn should_drop_html_attr(name: &str, value: &str) -> bool {
if name.starts_with("on") && name.len() > 2 {
return true;
}
let url_attrs = [
"href",
"src",
"xlink:href",
"action",
"formaction",
"poster",
"data",
"background",
"dynsrc",
"lowsrc",
];
if !url_attrs.iter().any(|a| *a == name) {
return false;
}
// 折叠空白后判断危险 scheme,防 `java script:` 类绕过的弱变体
let compact: String = value
.chars()
.filter(|c| !c.is_whitespace() && *c != '\0')
.collect::<String>()
.to_ascii_lowercase();
compact.starts_with("javascript:")
|| compact.starts_with("vbscript:")
|| compact.starts_with("data:text/html")
}
pub async fn sheet_set_value(
context: &RequestContext,
input: &ToolCallInput,
@@ -1019,7 +1250,16 @@ fn arg_f64(input: &ToolCallInput, key: &'static str) -> Option<f64> {
_ => None,
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= 0.0)
.filter(|value| {
if !value.is_finite() {
return false;
}
// 位置/偏移可负(向左/上);尺寸/字号/线宽必须非负。
match key {
"xOffsetMm" | "yOffsetMm" | "xMm" | "yMm" => true,
_ => *value >= 0.0,
}
})
}
fn arg_bool(input: &ToolCallInput, key: &'static str) -> Option<bool> {
@@ -1128,3 +1368,81 @@ fn bridge_run_error(context: &RequestContext, error: BridgeRunError) -> WebError
}
.with_context(context)
}
#[cfg(test)]
mod tests {
use super::{arg_f64, sanitize_insert_html, should_drop_html_attr};
use crate::mnote_agent_tools::ToolCallInput;
use serde_json::json;
fn input_with_args(args: serde_json::Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.onlyoffice.test".into(),
workspace_id: None,
document_id: None,
source_kind: None,
root_uri: None,
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(args),
}
}
#[test]
fn arg_f64_allows_negative_offsets_but_rejects_negative_sizes() {
let offsets = input_with_args(json!({
"xOffsetMm": -12.5,
"yMm": -3.0,
"widthMm": -1.0,
"fontSize": -8.0,
"strokeWidthMm": 1.5
}));
assert_eq!(arg_f64(&offsets, "xOffsetMm"), Some(-12.5));
assert_eq!(arg_f64(&offsets, "yMm"), Some(-3.0));
assert_eq!(arg_f64(&offsets, "widthMm"), None);
assert_eq!(arg_f64(&offsets, "fontSize"), None);
assert_eq!(arg_f64(&offsets, "strokeWidthMm"), Some(1.5));
}
#[test]
fn sanitize_strips_script_and_event_handlers() {
let dirty = r#"<p onclick="alert(1)">hi</p><script>evil()</script><b>ok</b>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<script"));
assert!(!clean.to_ascii_lowercase().contains("onclick"));
assert!(clean.contains("<b>ok</b>") || clean.contains("ok"));
assert!(clean.contains("hi"));
}
#[test]
fn sanitize_strips_javascript_href() {
let dirty = r#"<a href="javascript:alert(1)">x</a><a href="https://ok.example">y</a>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("javascript:"));
assert!(clean.contains("https://ok.example"));
}
#[test]
fn sanitize_strips_iframe_and_data_html() {
let dirty = r#"<iframe src="https://evil"></iframe><img src="data:text/html;base64,xx">"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<iframe"));
assert!(!clean.to_ascii_lowercase().contains("data:text/html"));
}
#[test]
fn should_drop_on_and_js_schemes() {
assert!(should_drop_html_attr("onclick", "x"));
assert!(should_drop_html_attr("href", "javascript:alert(1)"));
assert!(should_drop_html_attr("href", " java\tscript:alert(1)"));
assert!(!should_drop_html_attr("href", "https://example.com"));
assert!(!should_drop_html_attr("class", "foo"));
}
}
@@ -360,13 +360,17 @@ async fn resolve_page_save_content(
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
// 与 page_get 一致:local_folder 等非默认源必须带上 source_kind/root_uri
// 否则 append/prepend 会读到错误 workspace 的当前正文。
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
source_kind.as_deref(),
root_uri.as_deref(),
)
.await?;
let aggregate_value = serde_json::to_value(&aggregate)
@@ -394,6 +394,15 @@ impl ResourceToolTarget {
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
// 与 resolve_write_path 一致:canonicalize 根,并逐段解析已存在路径上的符号链接,
// 防止 `subdir -> /etc` 后再 create `subdir/x` 逃出授权目录。
let root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法访问授权根目录: {error}"),
)
.with_context(context)
})?;
let relative = self.relative_path();
let relative_path = Path::new(&relative);
if relative_path.is_absolute()
@@ -407,15 +416,41 @@ impl ResourceToolTarget {
)
.with_context(context));
}
let path = root.join(relative_path);
if !path.starts_with(&root) {
let mut cursor = root.clone();
let components: Vec<_> = relative_path.components().collect();
for (index, component) in components.iter().enumerate() {
cursor = cursor.join(component);
if !cursor.exists() {
// 从本段起路径尚未落盘:直接拼完剩余段(无 symlink 可解析),仍须 starts_with(root)。
for rest in components.iter().skip(index + 1) {
cursor = cursor.join(rest);
}
break;
}
let canonical = cursor.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法解析资源路径: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
cursor = canonical;
}
if !cursor.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(path)
Ok(cursor)
}
fn relative_path(&self) -> String {
@@ -436,24 +471,40 @@ fn ensure_resource_scope_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(());
};
let allowed = scope
// 与 block allowedTargetBlockIds 一致:
// - 未声明 allowedResourceIds → 不按资源 id 收紧(仍受 rootUri / 写授权约束)
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty()
|| allowed.contains(&target.resource_id)
|| allowed.contains(&target.object_identity)
{
.or_else(|| scope.get("allowed_resource_ids"));
let Some(raw) = raw else {
return Ok(());
};
let Some(values) = raw.as_array() else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_invalid",
"aiAccessScope.allowedResourceIds 必须是字符串数组",
)
.with_context(context));
};
let allowed = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝访问任何资源",
)
.with_context(context));
}
if allowed.contains(&target.resource_id) || allowed.contains(&target.object_identity) {
return Ok(());
}
Err(WebError::new(
@@ -979,14 +1030,22 @@ fn ensure_mindmap_revision_precondition(
return Ok(());
};
let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() {
return Err(WebError::bad_request_code(
let expected_label = revision_label(&expected);
let current_label = revision_label(&current);
// 双方均无法解析时不得 None==None 静默放行(fail-closed)。
match (expected_label.as_deref(), current_label.as_deref()) {
(Some(exp), Some(cur)) if exp == cur => Ok(()),
(Some(_), Some(_)) => Err(WebError::bad_request_code(
"mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入",
)
.with_context(context));
.with_context(context)),
_ => Err(WebError::bad_request_code(
"mnote_resource_revision_unparseable",
"mindmap resource revision 无法解析,拒绝写入",
)
.with_context(context)),
}
Ok(())
}
fn revision_label(value: &Value) -> Option<String> {
@@ -1063,8 +1122,33 @@ fn office_mime_type(path: &Path) -> &'static str {
}
fn office_text_preview(path: &Path) -> Value {
match fs::read_to_string(path) {
Ok(text) => json!(text.chars().take(4000).collect::<String>()),
Err(_) => Value::Null,
// Office Open XML 是 ZIP 二进制,read_to_string 必失败;不要整文件读入内存。
// 文本摘要请走 onlyoffice document.export;此处仅对可能的纯文本做有限预览。
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
ext.as_str(),
"docx" | "xlsx" | "pptx" | "doc" | "xls" | "ppt" | "odt" | "ods" | "odp"
) {
return Value::Null;
}
const MAX_PREVIEW_BYTES: usize = 16 * 1024;
let Ok(mut file) = fs::File::open(path) else {
return Value::Null;
};
use std::io::Read;
let mut buf = vec![0_u8; MAX_PREVIEW_BYTES];
let Ok(n) = file.read(&mut buf) else {
return Value::Null;
};
buf.truncate(n);
// 含明显 NUL 则当二进制,避免把 ZIP/乱码当摘要
if buf.iter().any(|b| *b == 0) {
return Value::Null;
}
let text = String::from_utf8_lossy(&buf);
json!(text.chars().take(4000).collect::<String>())
}
@@ -1,12 +1,8 @@
use crate::error::WebError;
use control_plane::UserRecord;
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::{json, Value};
use std::env;
use std::time::Duration;
//! Provider identity sync (legacy).
//!
//! 外部知识库身份同步已移除。本模块保留 API 兼容外壳,始终返回 skipped。
const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1";
const PROVIDER_IDENTITY_SYNC_TIMEOUT_MS: u64 = 1_500;
use control_plane::UserRecord;
#[derive(Debug, Clone)]
pub struct ProviderIdentitySyncResult {
@@ -16,143 +12,16 @@ pub struct ProviderIdentitySyncResult {
pub provider_user_id: Option<String>,
}
fn env_flag(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn clean_url(value: String) -> Option<String> {
let trimmed = value.trim().trim_end_matches('/').to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
fn weknora_endpoint() -> String {
env::var("MNOTE_WEKNORA_ENDPOINT")
.or_else(|_| env::var("WEKNORA_ENDPOINT"))
.ok()
.and_then(clean_url)
.unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string())
}
fn join_url(base_url: &str, path: &str) -> String {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
fn internal_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(secret) = env::var("MNOTE_PROVIDER_IDENTITY_SYNC_SECRET")
.or_else(|_| env::var("MNOTE_INTERNAL_API_SECRET"))
.or_else(|_| env::var("INTERNAL_API_SECRET"))
{
if let Ok(value) = HeaderValue::from_str(secret.trim()) {
headers.insert("x-internal-token", value);
}
}
headers
}
fn fallback_email(user: &UserRecord) -> String {
user.email
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{}@mnote.local", user.username))
}
fn sync_disabled() -> bool {
!env_flag("MNOTE_PROVIDER_IDENTITY_SYNC", true)
}
/// 历史入口:注册/改密时同步外部知识库身份。已退役,固定 no-op。
pub async fn sync_provider_identities(
user: &UserRecord,
password: &str,
_password: &str,
) -> Vec<ProviderIdentitySyncResult> {
if sync_disabled() {
return vec![ProviderIdentitySyncResult {
provider: "all",
ok: true,
message: "provider identity sync disabled".to_string(),
provider_user_id: None,
}];
}
let sync_weknora = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_WEKNORA", true);
if sync_weknora {
vec![sync_weknora_identity(user, password).await]
} else {
vec![ProviderIdentitySyncResult {
provider: "all",
ok: true,
message: "provider identity sync disabled".to_string(),
provider_user_id: None,
}]
}
}
async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS))
.build()
.map_err(|error| WebError::internal(format!("provider identity sync client: {error}")))?;
let response = client
.post(url.clone())
.headers(internal_headers())
.json(&body)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"provider_identity_sync_unreachable",
format!("{url}: {error}"),
)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"provider_identity_sync_failed",
format!("{url} returned {status}: {payload}"),
));
}
Ok(payload)
}
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
let payload = json!({
"mnote_user_id": user.id,
"username": user.username,
"email": fallback_email(user),
"password": password,
"role": "contributor",
"is_active": user.status == "active",
});
match post_json(
join_url(&weknora_endpoint(), "/internal/mnote/users/provision"),
payload,
)
.await
{
Ok(payload) => ProviderIdentitySyncResult {
provider: "weknora",
ok: true,
message: "synced".to_string(),
provider_user_id: payload
.pointer("/user/id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
Err(error) => ProviderIdentitySyncResult {
provider: "weknora",
ok: false,
message: error.message().to_string(),
provider_user_id: None,
},
}
let _ = user;
vec![ProviderIdentitySyncResult {
provider: "none",
ok: true,
message: "External knowledge provider identity sync removed; no sync configured".into(),
provider_user_id: None,
}]
}
+181 -30
View File
@@ -593,8 +593,14 @@ pub async fn create_directory_access_request(
Json(body): Json<DirectoryAccessRequestBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let root_path = body.root_path.trim();
let root_uri = body.root_uri.trim();
let root_path = sanitize_directory_access_root(body.root_path.trim()).map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_path", message)
.with_context(&context)
})?;
let root_uri = sanitize_directory_access_root(body.root_uri.trim()).map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
.with_context(&context)
})?;
if root_path.is_empty() && root_uri.is_empty() {
return Err(WebError::bad_request_code(
"directory_access_request_root_required",
@@ -659,23 +665,34 @@ pub async fn approve_directory_access_request(
.unwrap_or_default()
.trim()
.to_string();
let root_path = request
.get("rootPath")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_uri = request
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_path = sanitize_directory_access_root(
request
.get("rootPath")
.and_then(Value::as_str)
.unwrap_or_default(),
)
.map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_path", message)
})?;
let root_uri = sanitize_directory_access_root(
request
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default(),
)
.map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
})?;
let permission = request
.get("permission")
.and_then(Value::as_str)
.map(normalize_directory_request_permission)
.unwrap_or_else(|| "read".into());
// 默认递归;若申请明确写了 recursive=false 则尊重(兼容旧审计无该字段)
let recursive = request
.get("recursive")
.and_then(Value::as_bool)
.unwrap_or(true);
let (_, Json(created)) = local_folder_source::create_local_access_grant(
State(state.clone()),
Extension(context.clone()),
@@ -685,7 +702,7 @@ pub async fn approve_directory_access_request(
root_uri,
root_path,
permission,
recursive: true,
recursive,
capabilities: Vec::new(),
}),
)
@@ -786,18 +803,53 @@ fn normalize_directory_request_permission(value: &str) -> String {
}
}
/// 拒绝路径穿越与空段;允许绝对/相对路径与 file:// URI(仅做字面安全检查)。
fn sanitize_directory_access_root(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
if trimmed.contains('\0') {
return Err("目录路径包含非法字符".into());
}
// file:// 或普通路径统一按 path component 检查 `..`
let path_part = trimmed
.strip_prefix("file://")
.or_else(|| trimmed.strip_prefix("file:"))
.unwrap_or(trimmed);
let path = std::path::Path::new(path_part);
for component in path.components() {
match component {
std::path::Component::ParentDir => {
return Err("目录路径不能包含 ..".into());
}
std::path::Component::CurDir => continue,
_ => {}
}
}
// 拒绝 Windows 风格盘符以外的可疑 `..` 编码残留
if path_part.split(['/', '\\']).any(|seg| seg == "..") {
return Err("目录路径不能包含 ..".into());
}
Ok(trimmed.to_string())
}
fn list_directory_access_requests(
state: &AppState,
user_filter: Option<&str>,
) -> Result<Vec<Value>, WebError> {
// list_audit_log 按 created_at DESC;用两遍处理保证与返回顺序无关:
// 先收 requested,再叠 approved/rejected,避免决策事件找不到条目而永久 pending。
const AUDIT_LOG_FETCH_LIMIT: usize = 5_000;
let rows = state
.control_plane()
.list_audit_log(1000)
.list_audit_log(AUDIT_LOG_FETCH_LIMIT)
.map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?;
let mut requests: HashMap<String, Value> = HashMap::new();
for row in rows.iter().rev() {
let mut apply_row = |row: &control_plane::AuditLogRecord, phase: u8| {
if row.target_kind.as_str() != "directory_access_request" {
continue;
return;
}
let Some(request_id) = row
.target_id
@@ -805,19 +857,35 @@ fn list_directory_access_requests(
.map(str::trim)
.filter(|v| !v.is_empty())
else {
continue;
return;
};
let metadata =
serde_json::from_str::<Value>(&row.metadata_json).unwrap_or_else(|_| json!({}));
match row.action.as_str() {
"directory_access.requested" => {
match (phase, row.action.as_str()) {
(0, "directory_access.requested") => {
let mut value = metadata;
value["requestId"] = json!(request_id);
value["status"] = json!("pending");
value["createdAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
// 同 id 多次申请:保留最早 createdAt(若已有条目则不覆盖 createdAt)
requests
.entry(request_id.to_string())
.and_modify(|existing| {
if existing.get("createdAt").and_then(Value::as_str).is_none() {
existing["createdAt"] = json!(row.created_at.clone());
}
// 若尚无决策,刷新申请元数据
if existing.get("status").and_then(Value::as_str) == Some("pending") {
for key in ["rootPath", "rootUri", "permission", "note", "userId"] {
if let Some(v) = value.get(key) {
existing[key] = v.clone();
}
}
}
})
.or_insert(value);
}
"directory_access.approved" | "directory_access.rejected" => {
(1, "directory_access.approved") | (1, "directory_access.rejected") => {
if let Some(value) = requests.get_mut(request_id) {
let status = metadata.get("status").and_then(Value::as_str).unwrap_or(
if row.action.ends_with("approved") {
@@ -837,11 +905,32 @@ fn list_directory_access_requests(
if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) {
value["grantId"] = json!(grant_id);
}
} else {
// 决策日志早于申请窗口之外时,仍以决策元数据建档,避免幽灵 pending
let mut value = metadata;
value["requestId"] = json!(request_id);
if value.get("status").and_then(Value::as_str).is_none() {
value["status"] = json!(if row.action.ends_with("approved") {
"approved"
} else {
"rejected"
});
}
value["decidedAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
}
}
_ => {}
}
};
for row in &rows {
apply_row(row, 0);
}
for row in &rows {
apply_row(row, 1);
}
let mut values = requests
.into_values()
.filter(|request| {
@@ -1050,12 +1139,29 @@ pub async fn admin_list_users(
let users = users
.into_iter()
.map(|user| {
// 7-76mnote-e2e / role=ai_service 视为 AI 主体;普通用户不可冒充。
let principal_kind = if user.role == "ai_service"
|| user.id == "mnote-e2e"
|| user.username == "mnote-e2e"
{
"ai_service"
} else {
"human"
};
let display_role = if principal_kind == "ai_service" {
"ai_service"
} else if is_admin_user_for_display(&user.id, &user.role) {
"admin"
} else {
user.role.as_str()
};
json!({
"id": user.id,
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
"role": display_role,
"principalKind": principal_kind,
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
@@ -2294,9 +2400,11 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
"用户 ID 不能为空",
));
}
// 控制面尚无 get_user;提高 limit 降低误报,仍是 O(n) 列表扫描。
const USER_LOOKUP_LIMIT: usize = 5_000;
let exists = state
.control_plane()
.list_users(500)
.list_users(USER_LOOKUP_LIMIT)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?
.into_iter()
.any(|user| user.id == user_id);
@@ -2311,26 +2419,39 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
}
fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> {
let global_models = global_policy
// 与 runtime 一致:两边都 canonical,避免 freefirst-fast ↔ freefirst 误拒。
let global_models: std::collections::HashSet<String> = global_policy
.get("allowedModels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>();
.filter_map(|value| canonical_ai_model_ref(None, value))
.collect();
if let Some(models) = &body.allowed_models {
for model in models {
if !global_models.contains(model.trim()) {
let Some(canonical) = canonical_ai_model_ref(None, model.trim()) else {
return Err(format!("模型 {model} 非法"));
};
if !global_models.contains(&canonical) {
return Err(format!("模型 {model} 不在管理员允许范围内"));
}
}
}
if let Some(default_model) = &body.default_model {
let Some(default_canonical) = canonical_ai_model_ref(None, default_model.trim()) else {
return Err("默认模型非法".into());
};
let allowed = body
.allowed_models
.as_ref()
.map(|models| models.iter().any(|model| model == default_model))
.unwrap_or_else(|| global_models.contains(default_model.as_str()));
.map(|models| {
models.iter().any(|model| {
canonical_ai_model_ref(None, model.trim()).as_deref()
== Some(default_canonical.as_str())
})
})
.unwrap_or_else(|| global_models.contains(&default_canonical));
if !allowed {
return Err("默认模型必须属于该用户允许的模型".into());
}
@@ -3609,6 +3730,36 @@ mod tests {
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn user_policy_accepts_canonical_model_aliases() {
// 全局存 freefirst-fast,用户侧用 canonical freefirst 应通过
let global = json!({
"allowedModels": ["omniroute/freefirst-fast"],
"tools": {}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into()]),
tools: None,
skills: None,
mcp_servers: None,
pi_extensions: None,
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn sanitize_directory_access_root_rejects_traversal() {
assert!(sanitize_directory_access_root("../../etc/passwd").is_err());
assert!(sanitize_directory_access_root("/tmp/../etc").is_err());
assert!(sanitize_directory_access_root("file:///tmp/../secret").is_err());
assert_eq!(
sanitize_directory_access_root("/home/user/notes").unwrap(),
"/home/user/notes"
);
assert_eq!(sanitize_directory_access_root("").unwrap(), "");
}
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::agent_env_lock()
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -53,6 +53,16 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
)
}
/// Cap bridge overview page size to avoid unbounded fan-out / memory pressure.
const BRIDGE_WORKSPACE_LIMIT_MAX: u32 = 200;
const BRIDGE_WORKSPACE_LIMIT_DEFAULT: u32 = 50;
fn clamped_bridge_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(BRIDGE_WORKSPACE_LIMIT_DEFAULT)
.clamp(1, BRIDGE_WORKSPACE_LIMIT_MAX)
}
fn workspace_query_payload(
workspace_id: &str,
query: &BridgeWorkspaceQuery,
@@ -61,7 +71,7 @@ fn workspace_query_payload(
name: "bridge.workspace.overview".into(),
payload: json!({
"workspaceId": workspace_id,
"limit": query.limit.unwrap_or(50),
"limit": clamped_bridge_limit(query.limit),
"cursor": query.cursor,
"commandStatus": query.command_status,
"eventStatus": query.event_status,
@@ -82,14 +92,27 @@ async fn execute_bridge_query(
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
}
fn require_resolved_workspace_id(
context: &RequestContext,
query_workspace_id: Option<&str>,
) -> Result<String, WebError> {
resolve_effective_workspace_id(context, query_workspace_id, true)?.ok_or_else(|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
.with_header("x-error-phase", "workspace_resolve")
})
}
pub async fn workspace(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<BridgeWorkspaceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -106,8 +129,7 @@ pub async fn request(
Query(query): Query<BridgeRequestQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -131,8 +153,7 @@ pub async fn trace(
Query(query): Query<BridgeTraceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -177,6 +198,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -83,6 +83,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -122,6 +123,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -178,6 +180,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -246,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -532,6 +532,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -605,6 +605,10 @@ pub async fn meta(
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_meta_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -614,6 +618,10 @@ pub async fn content(
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_content_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -691,6 +699,10 @@ pub async fn page_body_write(
Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -724,6 +736,10 @@ pub async fn save(
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -977,6 +993,10 @@ pub async fn title(
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentTitleRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -1280,6 +1300,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -1913,6 +1913,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -398,6 +398,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
}
+95 -22
View File
@@ -128,7 +128,8 @@ pub async fn auth_api(
if action != "auth:signIn" && action != "auth:signOut" {
return Err(WebError::bad_request_code(
"auth_action_unsupported",
"Rust gateway 当前仅支持账号登录、注册与登出动作。",
// 注册走 auth:signIn 的 create-if-missing 路径;独立 auth:signUp 未开放。
"Rust gateway 当前仅支持账号登录(auth:signIn)与登出(auth:signOut)。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
@@ -306,6 +307,14 @@ fn ai_management_response(
} else {
"user-ai-admin"
};
let ui_runtime_src = {
let base = "/api/mnote-browser-runtime/mnote-ui-runtime.js";
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
format!("{base}?devHot={cache_buster}")
} else {
base.to_string()
}
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -314,6 +323,7 @@ fn ai_management_response(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>{}</style>
<script src="{}"></script>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{}
@@ -321,6 +331,7 @@ fn ai_management_response(
</html>"#,
title,
crate::ssr::MNOTE_CSS,
ui_runtime_src,
shell,
escape_html(context.auth.actor_id.as_str()),
content
@@ -1105,14 +1116,32 @@ fn parent_scope_from_relative_path(relative_path: &str) -> &str {
.unwrap_or("")
}
/// 导航 scope 路径:词法拒 `..` / 绝对路径,避免把穿越串写进 query(下游仍会再校验)。
fn sanitize_navigation_relative_path(relative_path: &str) -> &str {
let trimmed = relative_path.trim();
if trimmed.is_empty() || trimmed == "." {
return "";
}
let path = std::path::Path::new(trimmed);
if path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return "";
}
trimmed
}
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
let mut href = format!(
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(root_uri)
);
if !relative_path.trim().is_empty() {
let scope = sanitize_navigation_relative_path(relative_path);
if !scope.is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(relative_path));
href.push_str(&query_escape(scope));
}
href
}
@@ -1123,9 +1152,10 @@ fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id:
query_escape(document_id),
query_escape(root_uri)
);
if !file_tree_scope.trim().is_empty() {
let scope = sanitize_navigation_relative_path(file_tree_scope);
if !scope.is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(file_tree_scope));
href.push_str(&query_escape(scope));
}
href
}
@@ -1246,8 +1276,7 @@ pub async fn vault_entry(
let root_uri = root_uri.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
// 单次调用同时完成权限校验与路径解析,避免重复 FS 操作。
let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
@@ -1419,6 +1448,7 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create"></button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">簿</button>
<button type="button" data-vault-repair-ai-folders data-testid="vault-repair-ai-folders" hidden title="将分享副本分组重写为 用户名/原分组"> AI </button>
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
<span class="mnote-vault-sr-only"></span>
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
@@ -1750,13 +1780,13 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
}}
return Promise.reject(new Error('resource_kind_unsupported'));
}}
root.addEventListener('click', function(event) {{
root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
var documentId = button.getAttribute('data-document-id') || '';
if (action === 'empty-documents') {{
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
fetch('/api/documents/empty-trash', {{
method: 'POST',
@@ -1770,14 +1800,14 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
if (count) count.textContent = '0';
setStatus('', false);
}});
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (action === 'empty-resources') {{
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
Promise.all([
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
@@ -1788,7 +1818,7 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var count = root.querySelector('[data-trash-resource-count]');
if (count) count.textContent = '0';
setStatus('', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
@@ -1799,21 +1829,21 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var kind = button.getAttribute('data-resource-kind') || '';
var resourceDocumentId = button.getAttribute('data-document-id') || '';
if (!resourceId) return;
if (action === 'resource-purge' && !window.confirm('')) return;
if (action === 'resource-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
var row = button.closest('[data-trash-row]');
if (row) row.remove();
decrement('[data-trash-resource-count]');
setStatus(action === 'resource-restore' ? '' : '', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (!documentId) return;
if (action === 'purge' && !window.confirm('')) return;
if (action === 'purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
fetch('/api/tree/commands', {{
method: 'POST',
@@ -1973,7 +2003,7 @@ fn render_local_trash_workbench_html(
return false;
}});
}}
root.addEventListener('click', function(event) {{
root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
@@ -1981,7 +2011,7 @@ fn render_local_trash_workbench_html(
var documentId = button.getAttribute('data-document-id') || entryId;
var kind = button.getAttribute('data-resource-kind') || '';
if (action === 'local-restore' || action === 'local-purge') {{
if (action === 'local-purge' && !window.confirm('')) return;
if (action === 'local-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
postJson('/api/tree/commands', {{
action: action === 'local-restore' ? 'restore' : 'purge',
@@ -2003,7 +2033,7 @@ fn render_local_trash_workbench_html(
return refresh();
}}).then(function() {{
setStatus(action === 'local-restore' ? '' : '', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
@@ -2014,7 +2044,7 @@ fn render_local_trash_workbench_html(
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
if (rows.length === 0) return;
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
rows.reduce(function(chain, row) {{
return chain.then(function() {{
@@ -2461,6 +2491,14 @@ pub(crate) fn current_actor_display_name(
}
pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
// 7-76PAT 鉴权优先,忽略 cookie 叠加。
if context.auth.auth_method == "pat" {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return None;
}
return Some(actor_id.to_string());
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
@@ -2476,6 +2514,14 @@ pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Op
}
pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String {
if context.auth.auth_method == "pat" {
let t = context.auth.actor_type.trim();
return if t.is_empty() {
"user".to_string()
} else {
t.to_string()
};
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if matches!(
@@ -2489,6 +2535,10 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
// PAT 永不隐式 admin(即使 subject 是 admin 用户,也只按 scopes 授权)。
if context.auth.auth_method == "pat" {
return false;
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
@@ -2573,14 +2623,27 @@ async fn handle_control_plane_auth_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
// 7-76mnote-e2e 注册为 AI 主体(ai_service),非普通测试人设。
let role = if username == "mnote-e2e"
|| email.eq_ignore_ascii_case("mnote.e2e@example.com")
{
Some("ai_service".to_string())
} else {
None
};
let display_name = if role.as_deref() == Some("ai_service") {
"MNote AI".to_string()
} else {
username.to_string()
};
let user = state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(username.to_string()),
email: Some(email.to_string()),
username: username.to_string(),
display_name: username.to_string(),
role: None,
display_name,
role,
password_hash: None,
})
.map_err(|error| control_plane_auth_error(context, error))?;
@@ -3007,6 +3070,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3529,6 +3593,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
app_state
.control_plane()
@@ -3596,6 +3661,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
app_state
.control_plane()
@@ -3903,6 +3969,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -4543,7 +4610,13 @@ mod tests {
assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录"));
// 7-76 P0:开发对标生产,登录页不得再渲染测试快速登录或测试密码 DOM。
assert!(!html.contains("测试账号快速登录"));
assert!(!html.contains("data-auth-test-login"));
assert!(!html.contains("data-test-password"));
assert!(!html.contains("MnoteE2E123!"));
assert!(html.contains("data-auth-submit"));
assert!(html.contains(r#"data-testid="mnote-auth-page""#));
}
#[tokio::test]
@@ -308,6 +308,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -514,6 +514,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -46,8 +46,19 @@ pub(crate) struct OcrFrontmatter {
pub(crate) status: String,
}
fn reject_path_escape(value: &str) -> bool {
value
.replace('\\', "/")
.split('/')
.any(|seg| seg == ".." || seg == ".")
|| value.contains('\0')
}
pub(crate) fn is_local_ocr_sidecar_relative_path(relative_path: &str) -> bool {
let normalized = relative_path.trim().replace('\\', "/");
if normalized.is_empty() || reject_path_escape(&normalized) {
return false;
}
let path = Path::new(&normalized);
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
@@ -79,11 +90,24 @@ pub(crate) fn parse_ocr_frontmatter(markdown: &str) -> Option<OcrFrontmatter> {
if fields.get("mnote_ocr_version").map(String::as_str) != Some("1") {
return None;
}
let owner_document = fields.get("owner_document")?.to_string();
let source_path = fields.get("source_path")?.to_string();
let source_root_relative_path = fields.get("source_root_relative_path")?.to_string();
// Frontmatter 路径字段不得含 `..` / `.` 段,避免后续 join 逃逸工作区。
for candidate in [
owner_document.as_str(),
source_path.as_str(),
source_root_relative_path.as_str(),
] {
if reject_path_escape(candidate) {
return None;
}
}
Some(OcrFrontmatter {
provider: fields.get("provider")?.to_string(),
owner_document: fields.get("owner_document")?.to_string(),
source_path: fields.get("source_path")?.to_string(),
source_root_relative_path: fields.get("source_root_relative_path")?.to_string(),
owner_document,
source_path,
source_root_relative_path,
source_size: fields.get("source_size")?.parse().ok()?,
source_mtime_ms: fields.get("source_mtime_ms")?.parse().ok()?,
status: fields.get("status")?.to_string(),
@@ -153,8 +177,15 @@ mod tests {
assert!(!is_local_ocr_sidecar_relative_path(
"docs/Page.assets/photo.png"
));
assert!(!is_local_ocr_sidecar_relative_path(
"docs/../Page.ocr/photo.png.ocr.md"
));
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
// 含路径逃逸段的 frontmatter 必须拒绝解析。
let bad = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
assert!(parse_ocr_frontmatter(bad).is_none());
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
let parsed = parse_ocr_frontmatter(markdown).expect("frontmatter");
assert_eq!(parsed.provider, "mock");
assert_eq!(
@@ -17,7 +17,6 @@ use core_protocol::{
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[cfg(test)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -1457,6 +1456,7 @@ fn rebuild_local_search_index_with_settings(
) -> Result<LocalSearchIndex, WebError> {
let mut documents = Vec::new();
let mut resources = Vec::new();
let mut visited = BTreeSet::new();
for include_path in &settings.include_paths {
let base_path = if include_path == "." {
root_path.to_path_buf()
@@ -1464,7 +1464,13 @@ fn rebuild_local_search_index_with_settings(
root_path.join(include_path)
};
if base_path.exists() {
collect_markdown_documents(root_path, &base_path, &mut documents, &mut resources)?;
collect_markdown_documents(
root_path,
&base_path,
&mut documents,
&mut resources,
&mut visited,
)?;
}
}
documents.sort_by(|left, right| left.path.cmp(&right.path));
@@ -1964,7 +1970,28 @@ fn collect_markdown_documents(
current: &Path,
documents: &mut Vec<LocalSearchDocument>,
resources: &mut Vec<LocalSearchResource>,
visited: &mut BTreeSet<PathBuf>,
) -> Result<(), WebError> {
// 规范化后必须仍在 root 内;失败则跳过(含断链 / 权限)。
let root_canonical = match root_path.canonicalize() {
Ok(path) => path,
Err(_) => root_path.to_path_buf(),
};
let current_key = match current.canonicalize() {
Ok(path) => path,
Err(_) => current.to_path_buf(),
};
if !current_key.starts_with(&root_canonical) && current != root_path {
// 词法路径可能尚未 canonicalize 到 root;仅当 current 已规范且越界时拒绝。
if current.canonicalize().is_ok() {
return Ok(());
}
}
if !visited.insert(current_key) {
// 目录环(含硬链接/重复 include)直接跳过,避免无限递归。
return Ok(());
}
let entries = match fs::read_dir(current) {
Ok(entries) => entries,
Err(error) => {
@@ -1995,8 +2022,12 @@ fn collect_markdown_documents(
format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
)
})?;
// 不跟随符号链接,防止索引逃逸到 root 之外或形成符号链接环。
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_markdown_documents(root_path, &path, documents, resources)?;
collect_markdown_documents(root_path, &path, documents, resources, visited)?;
continue;
}
if !file_type.is_file() {
@@ -502,6 +502,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::web_shell::{
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
build_page_aggregate_snapshot, escape_script_json, load_file_tree_html, load_sidebar_tree_html,
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::ssr::pages::mindmap::MindmapPage;
@@ -32,14 +32,26 @@ pub async fn mindmap_object_shell(
Query(query): Query<MindmapShellQuery>,
) -> Result<Response, WebError> {
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let source_kind = query.source_kind.as_deref();
// 与路由分支一致:未显式提供时不伪称 local_folder(避免 is_local_folder=false 但 JSON 写 local_folder
let resolved_source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("")
.to_string();
let source_kind = resolved_source_kind.as_str();
let root_uri = query.root_uri.as_deref();
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&doc_id,
query.workspace_id.as_deref(),
source_kind,
if source_kind.is_empty() {
None
} else {
Some(source_kind)
},
root_uri,
)
.await
@@ -53,7 +65,7 @@ pub async fn mindmap_object_shell(
.map(|value| value.head.title.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "思维导图".to_string());
let is_local_folder = source_kind.map(str::trim) == Some("local_folder");
let is_local_folder = source_kind == "local_folder";
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder {
let root_uri = root_uri.unwrap_or_default();
let sidebar_tree_html =
@@ -150,7 +162,7 @@ pub async fn mindmap_object_shell(
"documentId": doc_id,
"mindmapId": mindmap_id
},
"sourceKind": source_kind.unwrap_or("local_folder"),
"sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""),
"revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null,
@@ -167,7 +179,7 @@ pub async fn mindmap_object_shell(
"shell": "mindmap",
"documentId": doc_id,
"mindmapId": mindmap_id,
"sourceKind": source_kind.unwrap_or("local_folder"),
"sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""),
"projection": {
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
@@ -217,7 +229,7 @@ pub async fn mindmap_object_shell(
crate::ssr::MNOTE_CSS,
escape_html(&doc_id),
escape_html(&mindmap_id),
escape_html(source_kind.unwrap_or("local_folder")),
escape_html(source_kind),
escape_html(root_uri.unwrap_or("")),
body_content,
escape_script_json(&editor_bootstrap_json),
@@ -246,10 +258,6 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn render_mindmap_standalone_bootstrap_script() -> String {
r#"<script type="module">
(() => {
@@ -407,6 +415,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -449,6 +458,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
+237 -27
View File
@@ -171,13 +171,39 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
/// profile 段只允许单层安全名,拒绝 `/`、`\`、`..`,防止拼到 profiles/ 外。
fn sanitize_agent_profile_segment(profile: &str) -> Option<&str> {
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
return None;
}
let candidate = home.join("profiles").join(profile);
if profile.contains('/')
|| profile.contains('\\')
|| profile.contains('\0')
|| profile == "."
|| profile == ".."
|| profile
.split(['/', '\\'])
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return None;
}
// 仅允许常见 profile 标识字符,避免奇怪路径段。
if !profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return None;
}
Some(profile)
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let Some(safe) = sanitize_agent_profile_segment(profile) else {
return home.join("config.yaml");
};
let candidate = home.join("profiles").join(safe);
if candidate.exists() {
candidate.join("config.yaml")
} else {
@@ -388,6 +414,13 @@ pub async fn mnote_call(
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
// 7-76:外部 AI 经 tools 调用时按读写工具要求 notes scope。
let required = if is_read_tool(&input.tool_name) {
crate::routes::api_access_token::SCOPE_NOTES_READ
} else {
crate::routes::api_access_token::SCOPE_NOTES_WRITE
};
crate::routes::api_access_token::ensure_scope(&context, required)?;
let response_body = execute_mnote_tool_call(&state, &context, input).await?;
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}
@@ -588,15 +621,6 @@ pub(crate) async fn execute_mnote_tool_call(
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
@@ -905,8 +929,11 @@ fn required_capability_scope(tool_name: &str) -> Vec<String> {
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
// required 为空时由 ensure_tool_capability_scope 短路。
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
// 兼容旧调用方:完全未声明 capabilityScope 不改变既有执行路径。
// 写工具仍由 shared_read / aiAccessScope / commandContext 等合同 fail-closed。
// 注意:显式声明 `[]` 与“未声明”语义不同——空数组表示调用方主动声明无能力,必须拒绝。
return true;
};
let declared = declared
@@ -914,6 +941,10 @@ fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
// 显式空 capabilityScope → fail-closed(禁止 None 与 [] 混同为“放行”)。
if declared.is_empty() {
return false;
}
required.iter().all(|scope| {
declared
.iter()
@@ -1311,6 +1342,7 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)]
mod tests {
use super::{agent_profile_config_path, sanitize_agent_profile_segment};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
@@ -1325,6 +1357,37 @@ mod tests {
crate::test_support::agent_env_lock()
}
#[test]
fn agent_profile_segment_rejects_path_traversal() {
assert!(sanitize_agent_profile_segment("ok-profile").is_some());
assert!(sanitize_agent_profile_segment("../etc").is_none());
assert!(sanitize_agent_profile_segment("a/b").is_none());
assert!(sanitize_agent_profile_segment("..").is_none());
assert!(sanitize_agent_profile_segment("default").is_none());
// 危险段回落到 default config.yaml,路径中不得含攻击串
let path = agent_profile_config_path("../../../etc/passwd");
let s = path.to_string_lossy();
assert!(!s.contains("etc/passwd"), "{s}");
assert!(s.ends_with("config.yaml"), "{s}");
}
#[test]
fn declared_capability_scope_empty_vec_is_fail_closed() {
use super::declared_capability_scope_covers;
let required = vec!["page.write".to_string()];
// 未声明:兼容旧路径
assert!(declared_capability_scope_covers(None, &required));
// 显式空:拒绝
let empty: Vec<String> = vec![];
assert!(!declared_capability_scope_covers(Some(&empty), &required));
// 显式覆盖:通过
let ok = vec!["page.write".to_string()];
assert!(declared_capability_scope_covers(Some(&ok), &required));
// 仅 read 不覆盖 write
let read_only = vec!["page.read".to_string()];
assert!(!declared_capability_scope_covers(Some(&read_only), &required));
}
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -1389,6 +1452,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -1439,6 +1503,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -2333,6 +2398,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"address": "A1",
"value": "after"
}
@@ -2476,6 +2545,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"onlyofficeSessionId": bridge_session_id,
"address": "A1",
"value": "after"
@@ -4951,6 +5024,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["page.write", "block.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"command": "block_replace",
"blockId": "heading_1",
"content": "替换标题"
@@ -4990,6 +5067,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "heading_1",
"revision": 7,
@@ -5041,6 +5122,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "替换后的章节",
"revision": 7,
@@ -5089,6 +5174,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "新增段落",
"revision": 7,
@@ -5137,6 +5226,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5184,6 +5277,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5228,6 +5325,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "越界替换",
"revision": 7,
@@ -5248,6 +5349,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "越界插入",
"revision": 7,
@@ -5268,6 +5373,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5287,6 +5396,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5394,6 +5507,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": block_id,
"anchorBlockId": "p_anchor",
"revision": 7,
@@ -5445,6 +5562,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
"revision": 7,
@@ -5507,6 +5628,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": blocks,
"revision": 7,
@@ -5552,6 +5677,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入"
}
@@ -5591,6 +5720,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入",
"revision": 7,
@@ -5636,6 +5769,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{
"op": "replace",
"blockId": "p_2",
@@ -5678,6 +5815,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"operations": [{
@@ -6016,7 +6157,11 @@ mod tests {
"args": {
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
]
],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6097,6 +6242,10 @@ mod tests {
"wideLayout": true,
"showHeadingNumbers": true,
"hideTitleHeader": false
},
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
@@ -6158,7 +6307,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_save_1",
"dryRun": true,
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
})
.to_string(),
))
@@ -6238,8 +6391,12 @@ mod tests {
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("page.md");
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");
fs::write(&root.join("page.md"), "第一段\n\n第二段\n").expect("write markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("initialize workspace");
let response = app()
.oneshot(
@@ -6248,11 +6405,15 @@ mod tests {
.uri("/api/mnote/tools/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.doc.markdown_edit",
"workspaceId": "ws_demo",
"documentId": path.to_string_lossy(),
"workspaceId": "local-ws-dry-run",
"documentId": "local-md:page.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
@@ -6260,6 +6421,10 @@ mod tests {
"idempotencyKey": "idem_markdown_local_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:page.md"]
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6277,10 +6442,9 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["audit"]["effect"], "dry_run");
assert_eq!(payload["result"]["operationsApplied"], 1);
assert_eq!(payload["result"]["applyResult"]["written"], false);
assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
// dry-run 不得改盘
assert_eq!(
fs::read_to_string(&path).expect("read markdown"),
fs::read_to_string(root.join("page.md")).expect("read markdown"),
"第一段\n\n第二段\n"
);
let _ = fs::remove_dir_all(&root);
@@ -6475,6 +6639,10 @@ mod tests {
"idempotencyKey": "idem_markdown_mapping_empty_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
}
})
@@ -6519,6 +6687,10 @@ mod tests {
"idempotencyKey": "idem_markdown_full_content_online_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"full_content": "章节一\n\n第一段已改\n\n第二段已改"
}
})
@@ -6562,6 +6734,10 @@ mod tests {
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
})
@@ -6612,6 +6788,10 @@ mod tests {
"idempotencyKey": "idem_markdown_precondition",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6676,7 +6856,11 @@ mod tests {
"idempotencyKey": "idem_local_folder_md",
"dryRun": false,
"args": {
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6730,6 +6914,10 @@ mod tests {
"idempotencyKey": "idem_markdown_noop",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
}
})
@@ -6771,6 +6959,10 @@ mod tests {
"idempotencyKey": "idem_markdown_scope",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"allowedTargetBlockIds": ["p_2"],
"operations": [{"search": "第一段", "replace": "不应越权修改"}]
}
@@ -6813,6 +7005,10 @@ mod tests {
"idempotencyKey": "idem_markdown_same_block_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [
{"search": "", "replace": "2"},
{"search": "2段", "replace": "2段落"}
@@ -6861,7 +7057,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_options_1",
"dryRun": true,
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"options": {"wideLayout": true, "pageFont": "serif"}}
})
.to_string(),
))
@@ -6904,7 +7104,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_summary_1",
"dryRun": true,
"args": {"summary": "摘要内容"}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"summary": "摘要内容"}
})
.to_string(),
))
@@ -6958,7 +7162,13 @@ mod tests {
"traceId": "trace_artifact_local",
"idempotencyKey": "idem_artifact_local",
"dryRun": false,
"args": {"summary": "本地摘要"}
"args": {
"summary": "本地摘要",
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
))
+57 -3
View File
@@ -1,5 +1,7 @@
mod ai_settings;
pub(crate) mod api_access_token;
mod bridge;
pub(crate) mod local_agent_install;
pub(crate) mod command_support;
mod compat;
pub(crate) mod dev_hot;
@@ -49,9 +51,10 @@ mod ws;
pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title, write_local_markdown_page_body,
ensure_local_path_write_access, ensure_local_workspace_access,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
@@ -379,6 +382,26 @@ pub fn build_router(state: AppState) -> Router {
"/api/vault/items/{id}/session",
put(vault::put_item_session),
)
.route(
"/api/vault/ai/token",
post(vault::issue_agent_vault_token),
)
.route(
"/api/vault/ai/token/install",
post(vault::install_agent_vault_token),
)
.route(
"/api/vault/ai/token/uninstall",
post(vault::uninstall_agent_vault_token),
)
.route(
"/api/vault/ai/token/local",
get(vault::list_local_agent_vault_tokens),
)
.route(
"/api/vault/ai/token/local/{subject}",
get(vault::detail_local_agent_vault_token),
)
.route(
"/api/vault/extension/token",
post(vault::issue_extension_token),
@@ -388,6 +411,10 @@ pub fn build_router(state: AppState) -> Router {
post(vault::revoke_extension_token),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route(
"/api/vault/ai/repair-folders",
post(vault::repair_ai_folders),
)
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
"/api/vault/ai/items/{id}/resolve",
@@ -546,6 +573,32 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/api/ai-settings/receipts", get(ai_settings::user_receipts))
.route("/api/ai-admin/receipts", get(ai_settings::admin_receipts))
// 7-76 Web PAT 管理
.route(
"/api/ai-tokens",
get(api_access_token::list_tokens).post(api_access_token::create_token),
)
.route(
"/api/ai-tokens/{id}/reveal",
post(api_access_token::reveal_token),
)
.route(
"/api/ai-tokens/{id}/revoke",
post(api_access_token::revoke_token),
)
.route(
"/api/ai-tokens/{id}",
axum::routing::patch(api_access_token::rename_token)
.delete(api_access_token::delete_token),
)
.route(
"/api/ai-tokens/{id}/install",
post(api_access_token::install_token),
)
.route(
"/api/ai-tokens/{id}/uninstall",
post(api_access_token::uninstall_token),
)
.route(
"/api/ai-admin/settings",
get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings),
@@ -854,6 +907,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -227,18 +227,20 @@ fn ensure_navigation_recent_target_exists(
}
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
let relative_path = normalize_navigation_relative_path(Some(relative_path))?
// root 与 target 都 canonicalize,避免 symlink / 未规范化 root 导致 starts_with 误判。
let canonical_root = root_path.canonicalize().unwrap_or_else(|_| root_path.to_path_buf());
let joined = normalize_navigation_relative_path(Some(relative_path))?
.as_deref()
.map(Path::new)
.map(|path| root_path.join(path))
.unwrap_or_else(|| root_path.to_path_buf());
let canonical_target = relative_path.canonicalize().map_err(|error| {
.map(|path| canonical_root.join(path))
.unwrap_or_else(|| canonical_root.clone());
let canonical_target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code(
"navigation_recent_target_unavailable",
format!("最近访问目标不可用: {error}"),
)
})?;
if !canonical_target.starts_with(root_path) {
if !canonical_target.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(
"navigation_recent_root_escape",
"最近访问路径不能越过授权目录",
@@ -379,6 +381,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
+167 -21
View File
@@ -3,8 +3,8 @@ use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
extract_callback_jwt, prepare_callback, prepare_proxy_request, sign_config,
verify_callback_jwt, OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput,
};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
@@ -1025,24 +1025,106 @@ fn resolve_onlyoffice_local_file_path(
"本地文件路径不能越过 root",
));
}
let target = canonical_root
.join(requested)
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
let joined = canonical_root.join(requested);
// 拒绝目标本身为 symlink,降低 canonicalize 与 open 之间的 TOCTOU 替换窗口。
match fs::symlink_metadata(&joined) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
Ok(meta) if !meta.is_file() => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_file",
"本地文件路径必须指向普通文件",
));
}
Ok(_) => {}
Err(error) => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
));
}
}
let target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
if !target.starts_with(&canonical_root) || !target.is_file() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_escape",
"本地文件路径不能越过 root",
));
}
// 二次确认 canonicalize 后路径仍不是 symlink(竞态窗口内被替换)。
if let Ok(meta) = fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
Ok(target)
}
/// 读取本地 OnlyOffice 文件:resolve 后再以 symlink_metadata 校验并 open,缩小 TOCTOU 窗口。
fn read_onlyoffice_local_file_bytes(target: &FsPath) -> Result<Vec<u8>, WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::read(target).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})
}
/// 写回本地 OnlyOffice 文件:写前拒绝 symlink,避免 TOCTOU 导向 root 外。
fn write_onlyoffice_local_file_bytes(target: &FsPath, bytes: &[u8]) -> Result<(), WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::write(target, bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})
}
/// 当配置了 ONLYOFFICE_JWT_SECRET 时,强制校验回调 JWT;未配置时与 sign 一致放行。
fn enforce_onlyoffice_callback_jwt(
body: &Value,
authorization: Option<&str>,
) -> Result<(), WebError> {
let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default();
let token = extract_callback_jwt(body, authorization);
verify_callback_jwt(token.as_deref(), &secret).map_err(|error| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_callback_jwt_invalid",
error,
)
})?;
Ok(())
}
fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue {
let extension = path
.extension()
@@ -1111,12 +1193,7 @@ fn proxy_local_folder_file_open(
let mut response = if *method == Method::HEAD {
Response::new(Body::empty())
} else {
let bytes = fs::read(&target).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})?;
let bytes = read_onlyoffice_local_file_bytes(&target)?;
Response::new(Body::from(bytes))
};
response.headers_mut().insert(
@@ -1297,12 +1374,7 @@ async fn local_folder_onlyoffice_callback(
)
})?;
let bytes = download_onlyoffice_callback_body(download_url).await?;
fs::write(&target, &bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})?;
write_onlyoffice_local_file_bytes(&target, &bytes)?;
Ok(onlyoffice_callback_success(json!({
"localWrite": true,
"bytes": bytes.len(),
@@ -1312,9 +1384,16 @@ async fn local_folder_onlyoffice_callback(
pub async fn callback(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Query(query): Query<OnlyOfficeCallbackQuery>,
Json(body): Json<Value>,
) -> Response {
let authorization = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
if let Err(error) = enforce_onlyoffice_callback_jwt(&body, authorization) {
return onlyoffice_callback_failure(error);
}
let status = body
.get("status")
.and_then(Value::as_i64)
@@ -2008,6 +2087,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
@@ -2040,6 +2120,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
@@ -2083,6 +2164,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2149,6 +2231,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2210,6 +2293,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2277,6 +2361,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2350,6 +2435,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2379,6 +2465,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
@@ -2534,4 +2621,63 @@ mod tests {
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);"));
}
#[tokio::test]
async fn onlyoffice_callback_rejects_invalid_jwt_when_secret_configured() {
std::env::set_var("ONLYOFFICE_JWT_SECRET", "test-onlyoffice-jwt-secret");
let response = callback(
State(test_state(None)),
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
"url": "http://127.0.0.1:8082/cache/files/out.docx",
"token": "not.a.valid.jwt"
})),
)
.await;
std::env::remove_var("ONLYOFFICE_JWT_SECRET");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_callback_jwt_invalid");
}
#[test]
fn resolve_onlyoffice_local_file_path_rejects_symlink_target() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-symlink-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let real = root.join("Page").join("real.docx");
fs::write(&real, b"real").expect("write real");
let link = root.join("Page").join("link.docx");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let err = resolve_onlyoffice_local_file_path(
&format!("file://{}", root.display()),
"Page/link.docx",
)
.expect_err("symlink should fail");
assert_eq!(err.code(), "onlyoffice_local_file_symlink_forbidden");
}
let _ = fs::remove_dir_all(&root);
}
}
@@ -1345,16 +1345,18 @@ fn render_plugin_index(
async function loop() {{
while (!stopped) {{
// 每次迭代用 let 限定 command,避免 var 提升导致 catch 误报上一轮成功命令
let command = null;
try {{
var query = new URLSearchParams({{
const query = new URLSearchParams({{
sessionId: sessionId,
token: bridgeToken,
timeoutMs: "25000"
}});
var response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
const response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
if (response.status === 204) continue;
var command = await response.json();
var result = await executeCommand(command);
command = await response.json();
const result = await executeCommand(command);
await postJson("/api/onlyoffice/bridge/results", {{
sessionId: sessionId,
token: bridgeToken,
@@ -1902,8 +1904,32 @@ fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Res
}
}
/// 将字符串序列化为可安全嵌入 `<script>` 的 JSON 字面量。
/// `serde_json` 只做 JS 字符串转义,不会打断 HTML 解析器对 `</script>` 的识别,
/// 因此必须额外把 `</script`(大小写不敏感)写成 `<\\/script`。
fn json_string(input: &str) -> String {
serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into())
let encoded = serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into());
escape_script_json_literal(&encoded)
}
fn escape_script_json_literal(value: &str) -> String {
let lower = value.to_ascii_lowercase();
let needle = b"</script";
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + needle.len() <= lower_bytes.len() && &lower_bytes[i..i + needle.len()] == needle {
out.push_str("<\\/script");
i += needle.len();
continue;
}
let ch = value[i..].chars().next().expect("valid utf-8 offset");
out.push(ch);
i += ch.len_utf8();
}
out
}
fn now_millis() -> u128 {
@@ -1993,6 +2019,56 @@ mod tests {
assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#));
}
#[test]
fn json_string_escapes_script_close_tag_case_insensitive() {
// 与 web_shell::escape_script_json 一致:匹配大小写不敏感,输出统一为 <\/script
let escaped = r#"<\/script>"#;
assert_eq!(
json_string(r#"</script><img onerror=alert(1)>"#),
format!(r#""{escaped}<img onerror=alert(1)>""#)
);
assert_eq!(
json_string(r#"</SCRIPT>alert(1)"#),
format!(r#""{escaped}alert(1)""#)
);
assert_eq!(
json_string(r#"</ScRiPt>x"#),
format!(r#""{escaped}x""#)
);
assert_eq!(json_string(r#"ok-value"#), r#""ok-value""#);
}
#[tokio::test]
async fn bridge_plugin_index_escapes_script_breaking_payloads() {
let response = plugin_index(Query(BridgePluginIndexQuery {
session_id: Some(r#"x</script><script>alert(1)</script>"#.into()),
api_base: Some("http://127.0.0.1:3000".into()),
token: Some("token-safe".into()),
document_id: Some(r#"doc</ScRiPt>x"#.into()),
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
// 原始 `</script>` 不得出现在注入字面量位置(允许外层 HTML 的正常闭合标签)
assert!(!html.contains(r#"var sessionId = "x</script>"#));
assert!(!html.contains(r#"documentId: "doc</ScRiPt>"#));
assert!(!html.contains(r#"documentId: "doc</script>"#));
// 大小写变体统一转义为 <\/script(小写),打断 HTML 解析器
assert!(html.contains(r#"var sessionId = "x<\/script><script>alert(1)<\/script>";"#));
assert!(html.contains(r#"documentId: "doc<\/script>x""#));
// 每次迭代用 let command,避免 var 提升残留
assert!(html.contains("let command = null;"));
assert!(!html.contains("var command = await response.json()"));
}
#[tokio::test]
async fn bridge_plugin_index_exposes_second_batch_recipe_actions() {
let response = plugin_index(Query(BridgePluginIndexQuery {
@@ -27,7 +27,8 @@ use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use sha2::{Digest, Sha256};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -379,18 +380,28 @@ fn generate_id(prefix: &str) -> String {
format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix())
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 24];
if let Ok(mut random) = fs::File::open("/dev/urandom") {
if random.read_exact(&mut bytes).is_ok() {
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push_str(&format!("{byte:02x}"));
fn fill_secure_random(buf: &mut [u8]) -> bool {
// bridge token / 安全凭证:只接受 CSPRNG。Linux/Unix 用 /dev/urandom。
#[cfg(unix)]
{
if let Ok(mut random) = fs::File::open("/dev/urandom") {
if random.read_exact(buf).is_ok() {
return true;
}
return format!("pi_bridge_{encoded}");
}
}
generate_id("pi_bridge_fallback")
false
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 32];
// 两次尝试 urandom;仍失败则 panic,禁止发出可预测 bridge token。
if !fill_secure_random(&mut bytes) && !fill_secure_random(&mut bytes) {
panic!(
"generate_bridge_token: 无法从 CSPRNG 读取随机字节,拒绝发出可预测 bridge token"
);
}
format!("pi_bridge_{}", hex::encode(bytes))
}
fn random_suffix() -> u64 {
@@ -438,15 +449,17 @@ fn pi_lab_tool_params_hash(params: &Value) -> String {
map.remove("mnoteApproval");
map.remove("mnote_approval");
}
format!("{:x}", stable_hash(&canonical_json_string(&normalized)))
// approval 参数匹配必须抗碰撞;DJB2/32-bit 不够。使用 SHA-256 hex。
let digest = Sha256::digest(canonical_json_string(&normalized).as_bytes());
hex::encode(digest)
}
fn stable_hash(value: &str) -> u64 {
let mut hash = 5381_u32;
for unit in value.encode_utf16() {
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit));
}
u64::from(hash)
// 仅用于 MCP cache 变更检测等非安全场景;取 SHA-256 前 8 字节作 64-bit 指纹。
let digest = Sha256::digest(value.as_bytes());
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(&digest[..8]);
u64::from_be_bytes(bytes)
}
fn approval_key(session_id: &str, approval_id: &str) -> String {
@@ -634,22 +647,63 @@ fn root_can_write(root: &AllowedRoot) -> bool {
permission.contains("write") || permission == "owner"
}
fn canonical_or_parent(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(path.file_name().unwrap_or_default());
/// 词法归一化路径分量:解析 `.` / `..`,避免未 canonicalize 时 `starts_with` 被 `..` 绕过。
fn lexically_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir) | Some(Component::Prefix(_)) => {
// 已在文件系统根,忽略继续上溯
}
Some(Component::ParentDir) | Some(Component::CurDir) | None => {
// 相对路径上溢:保留 `..`,后续 path_is_inside 会拒绝
out.push("..");
}
}
}
Component::Normal(part) => out.push(part),
}
}
path.to_path_buf()
out
}
fn path_contains_parent_dir(path: &Path) -> bool {
path.components()
.any(|component| matches!(component, Component::ParentDir))
}
fn canonical_or_parent(path: &Path) -> PathBuf {
// 先词法归一化,确保即便 canonicalize 失败也不会把含 `..` 的原始路径交给 starts_with。
let normalized = lexically_normalize(path);
if let Ok(canonical) = normalized.canonicalize() {
return canonical;
}
if let Some(parent) = normalized.parent() {
if !path_contains_parent_dir(parent) {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(normalized.file_name().unwrap_or_default());
}
}
}
normalized
}
fn path_is_inside(path: &Path, root: &Path) -> bool {
let canonical_path = canonical_or_parent(path);
let canonical_root = canonical_or_parent(root);
canonical_path.starts_with(canonical_root)
let canonical_path = lexically_normalize(&canonical_or_parent(path));
let canonical_root = lexically_normalize(&canonical_or_parent(root));
// 任一端仍含未解析的 `..` → 拒绝(fail-closed
if path_contains_parent_dir(&canonical_path) || path_contains_parent_dir(&canonical_root) {
return false;
}
canonical_path.starts_with(&canonical_root)
}
fn resolve_root_relative_path(
@@ -813,28 +867,71 @@ fn managed_session_dir(
session_id: &str,
) -> Result<PathBuf, WebError> {
let actor = pi_lab_actor_segment(&context.auth.actor_id);
// session_id 来自客户端;必须消毒,禁止 `..` / 路径分隔符跳出 pi-sessions 目录。
let session_seg = pi_lab_path_segment(session_id, "session");
if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) {
return Ok(root_path
let dir = root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(actor)
.join(session_id));
.join(&actor)
.join(&session_seg);
// 防御:归一化后必须仍落在 pi-sessions/<actor>/ 下
let base = root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
return Ok(dir);
}
Ok(std::env::temp_dir()
let dir = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(actor)
.join(session_id))
.join(&actor)
.join(&session_seg);
let base = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
Ok(dir)
}
fn pi_lab_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
// 只保留安全文件名字符;拒绝 `..` 与路径分隔。
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
}
fn pi_lab_actor_segment(actor_id: &str) -> String {
let actor = actor_id.trim().replace(['/', '\\', ':'], "_");
if actor.is_empty() {
"anonymous".into()
} else {
actor
}
pi_lab_path_segment(actor_id, "anonymous")
}
fn is_pi_lab_warmup_session_id(session_id: &str) -> bool {
@@ -1569,12 +1666,20 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
}
}
fn is_vault_tool_name(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session"
| "mnote.vault.list"
| "mnote.vault.get"
) || tool_name.starts_with("mnote.vault.")
}
/// Strip secret values before control-plane receipt persistence.
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
if tool_name != "mnote.vault.resolve"
&& tool_name != "mnote.vault.login"
&& tool_name != "mnote.vault.session"
{
if !is_vault_tool_name(tool_name) {
return payload.clone();
}
let mut safe = payload.clone();
@@ -1597,6 +1702,24 @@ fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
safe
}
/// vault 工具的 receipt 元字段(diff/version 摘要)可能间接含密文路径或值片段。
fn redact_vault_receipt_meta(
tool_name: &str,
diff_summary: Option<String>,
before_file_version: Option<String>,
after_file_version: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
if !is_vault_tool_name(tool_name) {
return (diff_summary, before_file_version, after_file_version);
}
(
diff_summary.map(|_| "[redacted-vault-meta]".into()),
// file_version 通常是内容哈希;vault 工具侧仍 fail-closed 不落盘自由文本摘要
before_file_version.map(|_| "[redacted]".into()),
after_file_version.map(|_| "[redacted]".into()),
)
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
@@ -2045,7 +2168,11 @@ fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache
}
fn shell_glob_escape_path(path: &str) -> String {
// 路径写入 permission JSON 的 path 规则时,必须把 glob 元字符转义,
// 否则目录名含 `*`/`?` 会被当成通配,扩大到 sibling 路径。
path.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('?', "\\?")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('{', "\\{")
@@ -2463,7 +2590,8 @@ fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String
.and_then(|policies| policies.get(tool_name))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "allow".into());
// 策略缺失时 fail-closed:禁止默认 allow 绕过权限。
.unwrap_or_else(|| "deny".into());
permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy)
}
@@ -2616,12 +2744,13 @@ fn pi_lab_tool_approval_confirmed(
if !confirmed {
return false;
}
// fail-closedapproval 未带 toolName 时不得放行(避免跨工具复用 approval)
if !approval
.get("toolName")
.or_else(|| approval.get("tool_name"))
.and_then(Value::as_str)
.map(|name| name == tool_name)
.unwrap_or(true)
.unwrap_or(false)
{
return false;
}
@@ -3324,16 +3453,48 @@ fn cleanup_expired_sessions() {
}
for (_, session_dir) in expired {
let path = PathBuf::from(session_dir);
if path
.components()
.any(|component| component.as_os_str() == "pi-sessions")
{
let _ = fs::remove_dir_all(path);
if is_safe_pi_session_dir_to_delete(&path) {
let _ = fs::remove_dir_all(&path);
}
}
}
}
/// 过期会话目录删除守卫:词法归一化后不得含 `..`,且路径段必须匹配
/// `…/.mnote/ai/pi-sessions/…` 或 `…/mnote-web/pi-lab/…`;若路径已存在则再对
/// canonicalize 结果复检,防止 `pi-sessions/../../etc` 类穿越。
fn is_safe_pi_session_dir_to_delete(path: &Path) -> bool {
fn looks_like_managed_session_dir(p: &Path) -> bool {
let comps: Vec<_> = p
.components()
.filter_map(|c| c.as_os_str().to_str().map(str::to_string))
.collect();
// workspace 托管:`.mnote/ai/pi-sessions`
if comps.windows(3).any(|w| {
w[0] == ".mnote" && w[1] == "ai" && w[2] == "pi-sessions"
}) {
return true;
}
// 无 root_uri 时的临时目录:`mnote-web/pi-lab`
comps
.windows(2)
.any(|w| w[0] == "mnote-web" && w[1] == "pi-lab")
}
let normalized = lexically_normalize(path);
if path_contains_parent_dir(&normalized) || !looks_like_managed_session_dir(&normalized) {
return false;
}
match fs::canonicalize(path) {
Ok(canon) => {
let cnorm = lexically_normalize(&canon);
!path_contains_parent_dir(&cnorm) && looks_like_managed_session_dir(&cnorm)
}
// 目录不存在:无需删除;canonicalize 失败(权限等)→ fail-closed 不删
Err(_) => false,
}
}
fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> {
let now = now_ms();
let key = format!("{actor_id}:{action}");
@@ -4012,8 +4173,12 @@ async fn send_rpc_command_wait(
pending.insert(key.clone(), tx);
}
// Send command
send_rpc_command(session_id, command).await?;
// Send command;失败时必须清理 pending,避免 map 泄漏
if let Err(error) = send_rpc_command(session_id, command).await {
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.remove(&key);
return Err(error);
}
// Wait for response with timeout
let result = tokio::time::timeout(timeout, rx).await;
@@ -4429,11 +4594,10 @@ fn apply_single_operation(current: &str, op: &Value) -> Result<String, WebError>
}
fn file_version(path: &Path) -> Option<String> {
// 跨进程稳定:DefaultHasher 每进程随机种子,会破坏持久化 fileVersion 可比性。
let bytes = fs::read(path).ok()?;
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
Some(format!("{:016x}", hasher.finish()))
let digest = Sha256::digest(&bytes);
Some(hex::encode(digest))
}
fn write_receipt(
@@ -4668,14 +4832,17 @@ fn mnote_repo_root() -> PathBuf {
}
fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec<PathBuf> {
[
repo_root.join(".codex").join("skills"),
PathBuf::from("/home/lix/.codex/skills"),
PathBuf::from("/home/lix/.agents/skills"),
]
.into_iter()
.filter(|path| path.exists())
.collect()
// 禁止硬编码开发者家目录;仅允许:仓库内 skills、当前用户 HOME/XDG 下的 skills。
let mut dirs = vec![repo_root.join(".codex").join("skills")];
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
dirs.push(home.join(".codex").join("skills"));
dirs.push(home.join(".agents").join("skills"));
dirs.push(home.join(".grok").join("skills"));
}
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) {
dirs.push(xdg.join("mnote").join("skills"));
}
dirs.into_iter().filter(|path| path.exists()).collect()
}
impl PiLabToolFacade {
@@ -5297,7 +5464,8 @@ impl PiLabToolFacade {
let desired = string_param(&params, "desiredOutcome")
.or_else(|| string_param(&params, "goal"))
.unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into());
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(true);
// 最小权限:未显式 allowWrites=true 时只读 sandbox,避免自救默认可写工作区
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(false);
let timeout_secs = u64_param(&params, "timeoutSeconds")
.unwrap_or(300)
.clamp(60, 900);
@@ -5474,10 +5642,11 @@ async fn execute_tool(
session: session.clone(),
};
let started = now_ms();
// 无会话上下文时 fail-closed:禁止默认 allow。
let tool_policy = session
.as_ref()
.map(|session| pi_lab_session_tool_policy(session, &tool_name))
.unwrap_or_else(|| "allow".into());
.unwrap_or_else(|| "deny".into());
if tool_policy == "deny" {
return Err(WebError::new(
StatusCode::FORBIDDEN,
@@ -5589,6 +5758,13 @@ async fn execute_tool(
.map(|items| items.len())
})
.unwrap_or(0);
// vault 工具:receipt / artifact / tool_call 元数据一并脱敏,避免 move 后仍用明文。
let (diff_summary, before_file_version, after_file_version) = redact_vault_receipt_meta(
&tool_name,
diff_summary,
before_file_version,
after_file_version,
);
let receipt = receipt_for(
&context,
session.as_ref(),
@@ -5714,75 +5890,60 @@ pub async fn status(
}
cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?;
let sessions = PI_LAB_SESSIONS.lock().ok();
let current_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
let warmup_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
// 锁中毒 = 共享状态可能已损坏;禁止 .ok() 静默空态(fail-open)。
let sessions = PI_LAB_SESSIONS.lock().map_err(|_| {
WebError::internal("PI_LAB_SESSIONS 锁中毒,共享状态可能已损坏")
.with_context(&context)
})?;
let current_session = sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned();
let warmup_session = sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned();
let active_session_count = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count()
})
.unwrap_or(0);
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count();
let owned_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
let owned_warmup_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let process_count = PI_LAB_PROCESSES
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
// 在同一把进程锁下统计,避免两次独立加锁导致状态窗口不一致。
let (process_count, warmup_process_count) = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_session_ids
let process_count = owned_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
})
.unwrap_or(0);
let warmup_process_count = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_warmup_session_ids
.count();
let warmup_process_count = owned_warmup_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
.count();
(process_count, warmup_process_count)
})
.unwrap_or(0);
.map_err(|_| {
WebError::internal("PI_LAB_PROCESSES 锁中毒,共享状态可能已损坏")
.with_context(&context)
})?;
let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) =
pi_runtime_status_snapshot();
let runtime_error = current_session
@@ -5962,12 +6123,13 @@ pub async fn start(
}
}
}
if kill_session_process(&requested_session.session_id).await {
update_session(&requested_session.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
session.runtime_pid = None;
});
}
// 无论 kill 是否找到进程,都先把会话标为 Aborted,避免旧 runtime 状态残留后
// 与 start_runtime_for_session 并发。
let _ = kill_session_process(&requested_session.session_id).await;
update_session(&requested_session.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
session.runtime_pid = None;
});
let session = start_runtime_for_session(&state, requested_session).await?;
Ok(Json(pi_lab_start_response(&session, false)))
}
@@ -7471,7 +7633,8 @@ fn build_run_runtime_json(session: &PiLabSession) -> String {
"allowedRootsSnapshot": session.allowed_roots_snapshot,
"runtimePolicy": session.runtime_policy_snapshot,
}))
.unwrap_or_default()
// 必须是合法 JSON:空串会让下游 `from_str` 失败;失败时回退 `{}`。
.unwrap_or_else(|_| "{}".to_string())
}
fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput {
@@ -7550,11 +7713,9 @@ fn read_pi_session_jsonl(session: &PiLabSession) -> Result<Vec<Value>, WebError>
let path = Path::new(pi_session_file);
// 安全验证:路径必须在 session pi_session_dir 内
// 安全验证:路径必须在 session pi_session_dir 内path_is_inside 含词法归一化 + `..` fail-closed
let session_dir = Path::new(&session.pi_session_dir);
let canonical_path = canonical_or_parent(path);
let canonical_dir = canonical_or_parent(session_dir);
if !canonical_path.starts_with(&canonical_dir) {
if !path_is_inside(path, session_dir) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_jsonl_path_escape",
@@ -8908,7 +9069,8 @@ pub async fn rename_session(
update_session(&path.session_id, |session| {
session.page_title = Some(title.to_string());
});
let mut pi_rpc_response = Value::Null;
// 不向客户端回传原始 Pi RPC 响应(可能含 runtime 内部状态);仅暴露是否成功。
let mut pi_rpc_ok = false;
if let Some(session) = get_session(&path.session_id) {
if session.runtime_mode != "mock" && session_runtime_is_usable(&session) {
if let Some(response) = send_rpc_command_wait(
@@ -8922,7 +9084,10 @@ pub async fn rename_session(
)
.await?
{
pi_rpc_response = response;
pi_rpc_ok = response
.get("ok")
.and_then(Value::as_bool)
.unwrap_or(true);
}
}
}
@@ -8931,7 +9096,7 @@ pub async fn rename_session(
"schema": "mnote.page_ai_pi.rename_session.v1",
"sessionId": path.session_id,
"title": title,
"piRpcResponse": pi_rpc_response,
"piRpcOk": pi_rpc_ok,
"updatedRuns": renamed.len(),
})))
}
@@ -8945,6 +9110,108 @@ mod tests {
use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput};
use tower::util::ServiceExt;
#[test]
fn lexically_normalize_collapses_parent_dirs() {
let path = PathBuf::from("/root/workspace/../../etc/passwd");
let normalized = lexically_normalize(&path);
assert_eq!(normalized, PathBuf::from("/etc/passwd"));
assert!(!path_contains_parent_dir(&normalized));
}
#[test]
fn path_is_inside_rejects_parent_dir_escape_without_existing_target() {
let root = temp_root("pi-path-inside-root");
// 目标目录不存在时 canonicalize 会失败;旧实现仅靠 starts_with 词法匹配会放行。
let escape = root.join("../../etc/passwd_should_not_escape");
assert!(
!path_is_inside(&escape, &root),
"含 .. 且未 canonicalize 的路径不得判定为 inside root"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn path_is_inside_allows_nested_file_under_root() {
let root = temp_root("pi-path-inside-ok");
let nested = root.join("docs").join("note.md");
fs::create_dir_all(nested.parent().unwrap()).expect("mkdir");
fs::write(&nested, "ok").expect("write");
assert!(path_is_inside(&nested, &root));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_run_runtime_json_is_valid_object_json() {
let session = PiLabSession {
session_id: "sess-1".into(),
mnote_user_id: "u1".into(),
bridge_token: "pi_bridge_test".into(),
status: PiLabSessionStatus::Idle,
provider_session_id: "prov-1".into(),
pi_session_dir: "/tmp/pi".into(),
pi_session_file: Some("/tmp/pi/session.jsonl".into()),
root_uri: Some("file:///tmp".into()),
workspace_id: Some("ws".into()),
page_path: Some("a.md".into()),
page_title: Some("A".into()),
model_provider: Some("openai".into()),
model_id: Some("gpt".into()),
thinking_level: None,
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: None,
runtime_mode: "lab".into(),
runtime_error: None,
created_at_ms: 1,
updated_at_ms: 1,
message_count: 0,
};
let raw = build_run_runtime_json(&session);
assert!(!raw.is_empty(), "不得回退空串");
let parsed: Value = serde_json::from_str(&raw).expect("必须是合法 JSON");
assert!(parsed.is_object());
assert_eq!(parsed["providerSessionId"], "prov-1");
assert_eq!(parsed["workspaceId"], "ws");
// session_id 不进 runtime_json 字段集合(仅 run 元数据)
assert!(parsed.get("sessionId").is_none());
}
#[test]
fn generate_bridge_token_is_unpredictable_hex() {
let a = generate_bridge_token();
let b = generate_bridge_token();
assert!(a.starts_with("pi_bridge_"));
assert_ne!(a, b);
// 32 字节 → 64 hex
assert_eq!(a.len(), "pi_bridge_".len() + 64);
}
#[test]
fn shell_glob_escape_path_escapes_wildcards() {
let escaped = shell_glob_escape_path("/home/user/project*/docs?");
assert_eq!(escaped, r"/home/user/project\*/docs\?");
// 已有的 bracket/brace 转义仍保留
assert_eq!(
shell_glob_escape_path(r"/tmp/a[b]{c}"),
r"/tmp/a\[b\]\{c\}"
);
// 反斜杠先加倍,避免二次解释
assert_eq!(shell_glob_escape_path(r"a\b*"), r"a\\b\*");
}
#[test]
fn pi_lab_tool_params_hash_is_sha256_hex_and_stable() {
let params = json!({"path": "a.md", "mnoteApproval": {"id": "x"}});
let hash = pi_lab_tool_params_hash(&params);
assert_eq!(hash.len(), 64);
// mnoteApproval 被剥离后应与无 approval 的 params 一致
let without = json!({"path": "a.md"});
assert_eq!(hash, pi_lab_tool_params_hash(&without));
// 不同参数不得碰撞(强哈希)
let other = json!({"path": "b.md"});
assert_ne!(hash, pi_lab_tool_params_hash(&other));
}
fn test_state() -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -8965,6 +9232,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
@@ -83,12 +83,25 @@ pub async fn block_edit_workflow(
)
.with_context(&context));
}
let actor_id =
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
state.config().dev_user_id.clone()
// Fail-closed: unauthenticated actors cannot write via the page-ai workflow.
// Dev fixtures may use dev_user_id only when explicitly enabled.
let actor_id = {
let raw = context.auth.actor_id.trim();
if raw.is_empty() || raw == "anonymous" {
if state.config().allow_dev_fixtures {
state.config().dev_user_id.clone()
} else {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_workflow_auth_required",
"页面 AI 工作流需要先登录",
)
.with_context(&context));
}
} else {
context.auth.actor_id.clone()
};
}
};
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
@@ -103,7 +116,17 @@ pub async fn block_edit_workflow(
})
.unwrap_or_default();
let mut markdown_args = json!({
"operations": markdown_operations.clone()
"operations": markdown_operations.clone(),
// 写入守卫 fail-closed:快路径也必须显式授予写权限。
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": [document_id.clone()],
"allowedTargetBlockIds": allowed_target_block_ids.clone(),
},
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
});
if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args {
@@ -172,13 +195,29 @@ struct MarkdownEditPlan {
summary: Option<String>,
}
/// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。
const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4;
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
extract_markdown_plan_from_model_text_depth(text, 0)
}
fn extract_markdown_plan_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<MarkdownEditPlan, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_markdown_plan_from_model_text(content);
return extract_markdown_plan_from_model_text_depth(content, depth + 1);
}
let summary = parsed
.get("summary")
@@ -237,12 +276,26 @@ fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan,
#[allow(dead_code)]
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
extract_operations_from_model_text_depth(text, 0)
}
#[allow(dead_code)]
fn extract_operations_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<Vec<Value>, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_operations_from_model_text(content);
return extract_operations_from_model_text_depth(content, depth + 1);
}
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
return Ok(operations.clone());
@@ -385,7 +438,13 @@ async fn call_block_edit_model(
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_workflow_model_body_read_failed",
format!("页面 AI workflow 模型响应体读取失败: {error}"),
)
.with_context(context)
})?;
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_workflow_model_failed",
@@ -545,14 +604,50 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
/// profile 名仅允许安全文件名字符,拒绝 `..` / 路径分隔,防止 profiles join 穿越。
fn sanitize_workflow_profile_name(profile: &str) -> String {
let trimmed = profile.trim();
if trimmed.is_empty() {
return "default".to_string();
}
let mut out = String::with_capacity(trimmed.len().min(64));
for ch in trimmed.chars().take(64) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return "default".to_string();
}
out
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
}
let candidate = home.join("profiles").join(profile);
if candidate.exists() {
let safe = sanitize_workflow_profile_name(profile);
if safe == "default" {
return home.join("config.yaml");
}
let profiles_root = home.join("profiles");
let candidate = profiles_root.join(&safe);
// 防御:消毒后仍须落在 profiles 目录内(不解析 symlink,仅词法检查)。
if candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return home.join("config.yaml");
}
// 禁止跟随 profiles 下指向外部的 symlinkexists/is_dir 会跟随)。
if candidate.is_symlink() {
return home.join("config.yaml");
}
if candidate.is_dir() {
candidate.join("config.yaml")
} else {
home.join("config.yaml")
@@ -673,6 +768,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -978,4 +1074,56 @@ mod tests {
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[test]
fn profile_config_path_rejects_traversal() {
use super::{profile_config_path, sanitize_workflow_profile_name};
use std::path::Component;
assert_eq!(sanitize_workflow_profile_name("../../etc"), "default");
assert_eq!(sanitize_workflow_profile_name("mnoteai"), "mnoteai");
assert_eq!(sanitize_workflow_profile_name("a/b"), "a_b");
let path = profile_config_path("../../etc/passwd");
assert!(
path.components().all(|c| !matches!(c, Component::ParentDir)),
"profile path must not contain ParentDir: {path:?}"
);
// 危险 profile 回落到 home/config.yaml,不得 join 原始 ../../
assert!(
path.ends_with("config.yaml"),
"expected config.yaml fallback, got {path:?}"
);
let path2 = profile_config_path("mnoteai");
// may or may not exist; must be under profiles/mnoteai or home config
assert!(
path2.components().all(|c| !matches!(c, Component::ParentDir))
);
}
#[test]
fn extract_markdown_plan_rejects_deeply_nested_content() {
use super::extract_markdown_plan_from_model_text;
// 5 层 choices.content 嵌套 → 超过 MODEL_JSON_UNWRAP_MAX_DEPTH(4)
let mut nested = r#"{"operations":[{"search":"a","replace":"b"}]}"#.to_string();
for _ in 0..5 {
nested = format!(
r#"{{"choices":[{{"message":{{"content":{}}}}}]}}"#,
serde_json::to_string(&nested).expect("escape")
);
}
let err = match extract_markdown_plan_from_model_text(&nested) {
Ok(_) => panic!("must reject deeply nested model JSON"),
Err(e) => e,
};
let msg = err.message();
assert!(
msg.contains("嵌套")
|| msg.contains("too_nested")
|| format!("{err:?}").contains("too_nested")
|| format!("{err:?}").contains("嵌套"),
"unexpected err: {err:?}"
);
}
}
@@ -103,10 +103,16 @@ fn stamp_headers(headers: &mut HeaderMap) {
}
}
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
/// Resolve the acting user for trash mutations.
/// Fail-closed: unauthenticated callers only fall back to `dev_user_id` when
/// `allow_dev_fixtures` is enabled (local/dev). Production must present a real actor.
async fn current_user_id(
state: &AppState,
context: &RequestContext,
) -> Result<String, WebError> {
let user_id = context.auth.actor_id.trim();
if !user_id.is_empty() && user_id != "anonymous" {
return user_id.to_string();
return Ok(user_id.to_string());
}
let has_auth_cookie = context
@@ -135,13 +141,23 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
.map(str::trim)
.filter(|value| !value.is_empty())
{
return user_id.to_string();
return Ok(user_id.to_string());
}
}
}
}
state.config().dev_user_id.clone()
if state.config().allow_dev_fixtures {
return Ok(state.config().dev_user_id.clone());
}
Err(WebError::new(
StatusCode::UNAUTHORIZED,
"resource_trash_auth_required",
"资源回收站操作需要先登录",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"))
}
fn require_id<'a>(
@@ -521,7 +537,7 @@ pub async fn media_batch(
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaBatchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let action = body.action.trim();
if action != "restore" && action != "delete" && action != "rename" && action != "move" {
return Err(WebError::bad_request_code(
@@ -600,6 +616,15 @@ pub async fn media_batch(
document_id.as_deref(),
payload,
);
// 先执行 tree runtime command;成功后再做 legacy media store patch
// 避免 patch 已生效而 tree 失败时调用方仍看到成功(与 restore/delete 一致)。
execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await?;
if action == "rename" {
let new_name = require_id(
&context,
@@ -646,25 +671,6 @@ pub async fn media_batch(
)
.await?;
}
let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await;
if action == "rename" || action == "move" {
if let Err(error) = command_result {
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
action = %action,
"tree.resource artifact command 失败,已保留兼容 patch 结果"
);
}
} else {
command_result?;
}
updated += 1;
}
@@ -695,7 +701,7 @@ pub async fn media_purge(
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let asset_id = require_id(&context, &body.asset_id, "assetId")?;
let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?;
let (workspace_id, document_id) = read_asset_workspace_and_document(&asset);
@@ -727,7 +733,7 @@ pub async fn media_empty_trash(
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name(
state.config(),
@@ -775,7 +781,7 @@ pub async fn mindmap_delete(
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
if query.source_kind.as_deref() == Some("local_folder") {
@@ -845,7 +851,7 @@ pub async fn mindmap_trash_action(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
};
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
let local_source_kind = body
@@ -971,7 +977,7 @@ pub async fn table_create(
Extension(context): Extension<RequestContext>,
Json(body): Json<TableCreateRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let document_id = require_id(&context, &body.document_id, "documentId")?;
let workspace_id = fetch_document_workspace_id(&state, &context, document_id)
.await
@@ -1048,7 +1054,7 @@ async fn table_action(
function_name: &'static str,
error_phase: &'static str,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let table_id = require_id(&context, &table_id, "tableId")?;
let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await;
let workspace_id = table_meta
@@ -1106,7 +1112,7 @@ pub async fn table_empty_trash(
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name(
state.config(),
@@ -1198,6 +1204,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
+4 -5
View File
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::web_shell::{escape_script_json, load_sidebar_tree_html};
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
@@ -734,10 +734,6 @@ fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
format!(r#"<div class="search-result-list">{items}</div>"#)
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
@@ -786,6 +782,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -916,6 +913,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}));
let response = app
.oneshot(
@@ -1527,6 +1525,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
+41 -27
View File
@@ -103,37 +103,44 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
.as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME))
.or_else(|| jwt_cookie_claim(&context, &["name", "username"]));
let user_id = if has_forwarded_actor {
actor_id.to_string()
} else {
state.config().dev_user_id.clone()
};
let actor_type = if has_forwarded_actor {
effective_actor_type_for_user(actor_id, &context.auth.actor_type)
} else {
"devFallback".to_string()
};
// Fail-closed: only fall back to dev identity when allow_dev_fixtures is on.
// Production unauthenticated callers get anonymous (not a privileged dev user).
if !has_forwarded_actor {
if state.config().allow_dev_fixtures {
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: state.config().dev_user_id.clone(),
email: state.config().dev_user_email.clone(),
name: state.config().dev_user_name.clone(),
actor_type: "devFallback".to_string(),
auth_mode: "devFallback",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: "anonymous".to_string(),
email: String::new(),
name: "anonymous".to_string(),
actor_type: "anonymous".to_string(),
auth_mode: "anonymous",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
SessionResponse {
ok: true,
owner: "mnote-web",
user_id,
email: if has_forwarded_actor {
actor_email.unwrap_or_default()
} else {
state.config().dev_user_email.clone()
},
name: if has_forwarded_actor {
actor_name.unwrap_or_else(|| actor_id.to_string())
} else {
state.config().dev_user_name.clone()
},
actor_type,
auth_mode: if has_forwarded_actor {
"forwardedActor"
} else {
"devFallback"
},
user_id: actor_id.to_string(),
email: actor_email.unwrap_or_default(),
name: actor_name.unwrap_or_else(|| actor_id.to_string()),
actor_type: effective_actor_type_for_user(actor_id, &context.auth.actor_type),
auth_mode: "forwardedActor",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
}
@@ -152,6 +159,9 @@ fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
}
}
/// 仅用于 **展示** email/name 补全;**绝不**作为 user_id / 鉴权真源。
/// 无签名校验:任意客户端可伪造 JWT payload 中的展示字段。
/// 身份仍以 control-plane session / header actor 为准(见上方 get_session 分支)。
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context
.auth
@@ -239,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -341,6 +352,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -410,6 +422,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -490,6 +503,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -157,13 +157,21 @@ pub async fn delete_shortcut(
)
.with_context(&context)
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "sidebar.shortcut.removed".to_string(),
target_kind: "sidebar_shortcut".to_string(),
target_id: Some(shortcut_id.clone()),
metadata_json: "{}".to_string(),
});
}) {
// 审计失败不回滚业务删除,但必须可观测(避免静默 fail-open)。
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
shortcut_id = %shortcut_id,
"sidebar shortcut delete audit append failed"
);
}
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
@@ -256,7 +264,7 @@ fn append_shortcut_audit(
action: &str,
shortcut: &SidebarShortcutRecord,
) {
let _ = state.control_plane().append_audit(AppendAuditInput {
if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()),
action: action.to_string(),
target_kind: "sidebar_shortcut".to_string(),
@@ -267,7 +275,15 @@ fn append_shortcut_audit(
"targetId": shortcut.target_id,
})
.to_string(),
});
}) {
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
action = %action,
shortcut_id = %shortcut.id,
"sidebar shortcut audit append failed"
);
}
}
trait EmptyStringExt {
+70 -9
View File
@@ -53,6 +53,21 @@ async fn events_with_stream_delta(
let state_for_stream = state.clone();
let context_for_stream = context.clone();
let query_for_stream = query.clone();
let subscription_workspace = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let stream = stream::unfold(
Some(StreamPollState {
app_state: state_for_stream,
@@ -64,6 +79,7 @@ async fn events_with_stream_delta(
initial_emitted: false,
block_delta_rx,
stream_delta_rx,
subscription_workspace,
}),
move |state| async move {
let mut state = state?;
@@ -76,11 +92,17 @@ async fn events_with_stream_delta(
));
}
// Check block.delta broadcast first
// Check block.delta broadcast first(按订阅 workspace 过滤,防跨工作区泄露)
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
}
// 非本工作区:丢弃并继续同一 tick 的后续检查
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -94,12 +116,17 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.stream_delta_rx {
match rx.try_recv() {
Ok(payload) => {
let hint = build_stream_push_delta_hint(
if delta_matches_workspace(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
state.subscription_workspace.as_deref(),
) {
let hint = build_stream_push_delta_hint(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -184,11 +211,19 @@ async fn events_with_stream_delta(
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
// Check block.delta after poll sleep
// Check block.delta after poll sleep(同样按 workspace 过滤)
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -305,6 +340,31 @@ struct StreamPollState {
#[allow(dead_code)]
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
stream_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
/// 订阅工作区;broadcast 推送仅转发匹配项,避免跨工作区泄露。
subscription_workspace: Option<String>,
}
/// 仅转发与当前订阅 workspace 一致的 delta(与 ws.rs 同策略)。
fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) -> bool {
let Some(expected) = subscription_workspace else {
// 无订阅 workspace 时不推送带 workspace 的全局 delta(保守)
return delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none();
};
match delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(delta_ws) => delta_ws == expected,
// 无 workspace 标记的 delta 不转发(避免跨租户噪声)
None => false,
}
}
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
@@ -368,6 +428,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -624,9 +624,17 @@ pub async fn load_stream_overview(
context: &RequestContext,
query: &StreamSnapshotQuery,
) -> Result<(String, Value), WebError> {
// resolve_effective_workspace_id(require=true) 在缺失时已返回 Err,不再用 expect panic。
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let overview = execute_runtime_query_via_legacy_cloud(
config,
context,
@@ -643,8 +651,15 @@ pub async fn load_stream_snapshot(
query: &StreamSnapshotQuery,
) -> Result<Value, WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let scope = resolve_stream_scope(query);
let snapshot = match scope {
@@ -653,8 +668,13 @@ pub async fn load_stream_snapshot(
.await?
}
StreamSnapshotScope::Subtree => {
let root_node_id =
normalize_root_node_id(query).expect("subtree scope 已确保 rootNodeId 存在");
let root_node_id = normalize_root_node_id(query).ok_or_else(|| {
WebError::bad_request_code(
"stream_root_node_required",
"subtree 流需要 rootNodeId",
)
.with_context(context)
})?;
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
let tree = execute_kernel_query(
context,
+109 -29
View File
@@ -263,10 +263,23 @@ fn escape_html(input: &str) -> String {
}
fn escape_inline_json(input: &str) -> String {
input
.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
// 嵌入 HTML/script 时除 <>& 外,还需处理 U+2028/U+2029 与控制字符,
// 避免破坏 JSON 解析或形成 XSS 面。
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("\\u0026"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if c.is_control() && c != '\n' && c != '\r' && c != '\t' => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
fn normalize_title(value: Option<String>) -> String {
@@ -308,22 +321,42 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
}
}
fn normalize_tree_source_kind(raw: Option<&str>) -> &'static str {
match raw.map(str::trim).unwrap_or("") {
"local_folder" | "local-folder" | "local" => "local_folder",
"convex_workspace" | "convex" | "" => "convex_workspace",
// 未知 kind 一律回落到 convex_workspace,避免客户端自报 kind 与 root_uri 脱钩
_ => "convex_workspace",
}
}
fn build_workspace_source_wire(
context: &RequestContext,
workspace_id: &str,
envelope_context: &TreeCommandEnvelopeContext,
) -> bridge_runtime::RuntimeSourceWire {
let source_kind = envelope_context
.source_kind
.clone()
.unwrap_or_else(|| "convex_workspace".into());
let root_uri = envelope_context.root_uri.clone().or_else(|| {
if source_kind == "convex_workspace" {
Some(format!("convex://workspace/{workspace_id}"))
} else {
None
// 不信任请求体自报的任意 source_kind:白名单归一化后再与 root_uri 对齐。
let source_kind = normalize_tree_source_kind(envelope_context.source_kind.as_deref()).to_string();
let root_uri = match source_kind.as_str() {
"local_folder" => {
// local_folder 必须带已由上游鉴权的 root_uri;无则不回落到 convex 假 URI
envelope_context
.root_uri
.as_ref()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty() && !u.contains(".."))
}
});
_ => Some(
envelope_context
.root_uri
.clone()
.filter(|u| {
let t = u.trim();
!t.is_empty() && (t.starts_with("convex://") || t.starts_with("workspace:"))
})
.unwrap_or_else(|| format!("convex://workspace/{workspace_id}")),
),
};
let capabilities =
if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" {
vec![
@@ -895,19 +928,6 @@ fn build_tree_shell_renderer_input(
}
}
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
let mut next = context.clone();
let actor_id = actor_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(actor_id) = actor_id {
next.auth.actor_id = actor_id;
next.auth.actor_type = "user".into();
}
next
}
fn generate_tree_document_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1836,7 +1856,10 @@ pub async fn tree_shell(
Extension(context): Extension<RequestContext>,
Query(query): Query<TreeShellQuery>,
) -> Result<Response, WebError> {
let effective_context = override_actor_context(&context, query.actor_id.as_deref());
// 安全:忽略 query.actor_id,禁止用查询参数冒充任意用户。
// actor 仅来自 RequestContextheader/cookie/session/extension token)。
let _ignored_query_actor_id = query.actor_id.as_deref();
let effective_context = context.clone();
let mode = normalize_tree_mode(query.mode.as_deref());
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
@@ -2120,8 +2143,9 @@ fn create_command_wire(
items,
title: _,
} => {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
// 仅当 items 为空时要求顶层 documentId;已有 items 时用 items.documentId。
let copy_items = if items.is_empty() {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
vec![json!({
"documentId": fallback_document_id,
"recursive": true,
@@ -2366,6 +2390,25 @@ fn apply_local_file_operation_participants(
);
}
}
"restore" => {
// restore 后资源回到 resource 路径;若仅有 previousResource 也尝试清除。
let restore_relative = next_relative_path
.as_deref()
.or(previous_relative_path.as_deref());
let restore_document_id = next_document_id
.as_deref()
.or(previous_document_id.as_deref());
if let (Some(relative_path), Some(document_id)) =
(restore_relative, restore_document_id)
{
let _ = buffer_store.clear_local_folder_markdown_deleted(
workspace_id,
root_uri,
relative_path,
document_id,
);
}
}
_ => {}
}
}
@@ -2375,6 +2418,11 @@ pub async fn tree_command(
Extension(context): Extension<RequestContext>,
body: String,
) -> Result<(StatusCode, Json<Value>), WebError> {
// 7-76PAT 写树需要 tree.write(读投影走其它 GET,不经此入口)。
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_TREE_WRITE,
)?;
let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| {
WebError::bad_request_code(
"tree_command_invalid_json",
@@ -2735,6 +2783,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
@@ -2817,6 +2866,35 @@ mod tests {
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
}
#[tokio::test]
async fn tree_shell_ignores_query_actor_id_impersonation() {
// 攻击面:?actorId= 不得覆盖 middleware 注入的 actorx-mnote-actor-id=user_test)。
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&actorId=attacker_admin&channel=test-shell")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
// 嵌入的 actor 必须是测试中间件身份,而非 query 冒充值
assert!(
html.contains("\"actorId\":\"user_test\"") || html.contains("user_test"),
"tree shell 应使用认证 actor,而非 query.actorId"
);
assert!(
!html.contains("attacker_admin"),
"query.actorId 不得写入 shell 身份上下文"
);
}
#[tokio::test]
async fn tree_shell_returns_interactive_html_document() {
let response = app()
@@ -3080,6 +3158,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
@@ -3825,6 +3904,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
+532 -18
View File
@@ -18,9 +18,10 @@ use crate::routes::vault_store::{
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use control_plane::AppendAuditInput;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -531,6 +532,25 @@ pub async fn list(
let root_uri = require_root_uri(query.root_uri.as_deref())?;
let root = resolve_read_root(&state, &context, root_uri).await?;
let status = parse_status(query.status.as_deref())?;
let (is_ai_vault, ai_actor) = vault_role_meta(&root);
// AI 密码本:打开列表时自动把旧分享副本分组修成 `{分享者}/{源分组}`
// (含 from-liaibo、无前缀的 个人/系统;仅识别到一个分享者时会统一归到其名下)
let folder_repair = if is_ai_vault {
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
} else {
None
};
let (index, items) = vault_store::list_credentials(&root, status)?;
let book = vault_store::load_cipher_book(&root).ok();
let book_ref = book.as_ref();
@@ -538,20 +558,31 @@ pub async fn list(
.iter()
.map(|e| vault_store::project_list_entry_with_cipher(e, book_ref))
.collect();
let (is_ai_vault, ai_actor) = vault_role_meta(&root);
Ok(ok_response(
&context,
json!({
"schema": "mnote.vault.list.v1",
"status": status.as_str(),
"revision": index.revision,
"updatedAt": index.updated_at,
"items": projected,
"isAiVault": is_ai_vault,
"aiVaultActorId": ai_actor,
"vaultRole": if is_ai_vault { "ai" } else { "user" },
}),
))
let mut body = json!({
"schema": "mnote.vault.list.v1",
"status": status.as_str(),
"revision": index.revision,
"updatedAt": index.updated_at,
"items": projected,
"isAiVault": is_ai_vault,
"aiVaultActorId": ai_actor,
"vaultRole": if is_ai_vault { "ai" } else { "user" },
});
if let Some(outcome) = folder_repair {
if let Some(obj) = body.as_object_mut() {
obj.insert(
"folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, body))
}
/// GET /api/vault/items/{id}
@@ -1216,6 +1247,7 @@ pub async fn resolve_item(
}
/// GET /api/vault/ai/list — multi-agent list of AI password book (no rootUri).
/// 打开列表时自动修复旧分享副本分组:`{分享者}/{源分组}`。
pub async fn list_ai(
Extension(context): Extension<RequestContext>,
Query(query): Query<VaultRootQuery>,
@@ -1223,10 +1255,91 @@ pub async fn list_ai(
require_vault_enabled()?;
require_authenticated(&context)?;
let status = parse_status(query.status.as_deref())?;
let result = list_ai_vault_items(status)?;
// 尽力修复旧数据分组(失败不阻断列表)
let repair = repair_ai_folder_paths_best_effort();
let mut result = list_ai_vault_items(status)?;
if let Some(outcome) = repair {
if let Some(obj) = result.as_object_mut() {
obj.insert(
"folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, result))
}
/// POST /api/vault/ai/repair-folders — 显式批量修复 AI 密码本分享副本分组。
/// body 可选:`{ "defaultSourceActor": "liaibo" }`(全库仅剩一人分享时也可自动推断)。
pub async fn repair_ai_folders(
Extension(context): Extension<RequestContext>,
body: Option<Json<Value>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let root = ensure_ai_vault_workspace()?;
let default_actor = body
.as_ref()
.and_then(|Json(v)| v.get("defaultSourceActor").or_else(|| v.get("default_source_actor")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let outcome = vault_store::repair_ai_shared_folder_paths_with_default(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
default_actor,
)?;
let _ = vault_store::append_vault_audit(
&root,
"repair_ai_folders",
&actor,
"",
Some(&format!("updated={}", outcome.updated)),
Some(context.trace.request_id.as_str()),
true,
);
Ok(ok_response(
&context,
json!({
"schema": "mnote.vault.repairAiFolders.v1",
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
))
}
fn repair_ai_folder_paths_best_effort() -> Option<vault_store::RepairAiFolderPathsOutcome> {
let root = ensure_ai_vault_workspace().ok()?;
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
}
/// GET /api/vault/ai/items/{id}
pub async fn get_ai_item(
Extension(context): Extension<RequestContext>,
@@ -1493,6 +1606,388 @@ fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
.collect()
}
/// 归一化 agent vault token scopes 为 CLI 短名(`list/get/resolve/...`)。
///
/// 接受 `vault.list` 等前缀写法;空输入 → 默认读密三件套。
pub(crate) fn normalize_agent_vault_scopes(input: Vec<String>) -> Vec<String> {
let mapped: BTreeSet<String> = input
.into_iter()
.map(|s| {
let t = s.trim();
match t {
"vault.list" => "list".into(),
"vault.get" => "get".into(),
"vault.resolve" => "resolve".into(),
"vault.login" => "login".into(),
"vault.session" => "session".into(),
other => other.to_string(),
}
})
.filter(|s| !s.is_empty())
.collect();
if mapped.is_empty() {
vec!["list".into(), "get".into(), "resolve".into()]
} else {
mapped.into_iter().collect()
}
}
/// POST /api/vault/ai/token — 签发 agent vault token`mnv1.*`)。
///
/// 7-76:用户本人或 admin 代签;与 Web PAT / 扩展 token 分职。
pub async fn issue_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_issue_via_pat_forbidden",
"不能用 Web PAT 签发 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"签发 vault token 需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject_user_id")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_delegate_forbidden",
"只有管理员可以代用户签发 vault token",
)
.with_context(&context));
}
let ttl_secs = map
.and_then(|m| m.get("ttlSecs").or_else(|| m.get("ttl_secs")))
.and_then(Value::as_u64)
.or_else(|| {
map.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
.and_then(Value::as_u64)
.map(|h| h.saturating_mul(3600))
});
let scopes_raw: Vec<String> = map
.and_then(|m| m.get("scopes").or_else(|| m.get("scope")))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.unwrap_or_default();
let scopes = normalize_agent_vault_scopes(scopes_raw);
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let key = mnote_vault_core::load_or_create_hmac_key(&mnote_vault_core::default_hmac_key_path())
.map_err(WebError::from)?;
let issued = mnote_vault_core::issue_token(
&key,
&format!("user:{subject}"),
&subject,
&scope_refs,
ttl_secs,
None,
)
.map_err(WebError::from)?;
let auto_install = map
.and_then(|m| m.get("install").or_else(|| m.get("autoInstall")))
.and_then(Value::as_bool)
.unwrap_or(true);
let mut install_result = None;
if auto_install {
match crate::routes::local_agent_install::install_vault_token_with_meta(
state.config(),
&subject,
&issued.token,
&issued.claims.jti,
&scopes,
Some(&subject),
) {
Ok(v) => install_result = Some(v),
Err(e) => {
// 签发成功但安装失败:仍返回 token,附 error
install_result = Some(json!({
"ok": false,
"installed": false,
"error": e.message(),
"code": e.code(),
}));
}
}
}
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor.clone()),
action: if subject == actor {
"vault_token.issue".into()
} else {
"vault_token.issue.delegated".into()
},
target_kind: "vault_token".into(),
target_id: Some(issued.claims.jti.clone()),
metadata_json: json!({
"subjectUserId": subject,
"scopes": scopes,
"delegated": subject != actor,
"autoInstall": auto_install,
"installed": install_result
.as_ref()
.and_then(|v| v.get("installed"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
.to_string(),
});
Ok(ok_response(
&context,
json!({
"token": issued.token,
"jti": issued.claims.jti,
"actor": issued.claims.actor,
"sub": issued.claims.sub,
"scope": issued.claims.scope,
"exp": issued.claims.exp,
"aud": issued.claims.aud,
"install": install_result,
"warning": if auto_install {
"明文仅此一次完整返回;已尝试一键写入本机 vault-tokens 与 agent-env。"
} else {
"明文仅此一次完整返回;请点击「配置」写入本机,或勿提交仓库。"
},
}),
))
}
/// POST /api/vault/ai/token/install — 一键把 vault token 写到本机
pub async fn install_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_via_pat_forbidden",
"不能用 Web PAT 安装 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"安装 vault token 需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let token = map
.and_then(|m| m.get("token"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("vault_token_required", "需要 token 明文(签发后仅一次)")
})?;
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 本机配置按 subject 隔离;非 admin 只能写自己的 vault-tokens/<self>.token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_forbidden",
"只能配置自己的 vault 本机 token",
)
.with_context(&context));
}
let jti = map
.and_then(|m| m.get("jti"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let result = crate::routes::local_agent_install::install_vault_token(
state.config(),
&subject,
token,
&jti,
)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.install".into(),
target_kind: "vault_token".into(),
target_id: Some(jti),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
"path": result.get("path"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// POST /api/vault/ai/token/uninstall — 删除本机 vault token 文件并清理 agent-env 指针
pub async fn uninstall_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_via_pat_forbidden",
"不能用 Web PAT 取消 vault 配置;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"取消 vault 配置需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 非 admin 只能取消自己的本机 vault 配置,避免交叉删他人 token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_forbidden",
"只能取消自己的 vault 本机配置",
)
.with_context(&context));
}
let result =
crate::routes::local_agent_install::uninstall_vault_token(state.config(), &subject)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.uninstall".into(),
target_kind: "vault_token".into(),
target_id: Some(subject.clone()),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// GET /api/vault/ai/token/local — 本机已配置的 vault token 列表
///
/// 默认只返回**当前登录用户**的本机配置,避免 liaibo / mnote-e2e 交叉。
/// admin 可传 `?all=1` 查看本机全部 subject。
pub async fn list_local_agent_vault_tokens(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let want_all = query
.get("all")
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
let list_all = is_admin && want_all;
Ok(ok_response(
&context,
crate::routes::local_agent_install::list_local_vault_installs(
state.config(),
&actor,
list_all,
),
))
}
/// GET /api/vault/ai/token/local/{subject} — 详情:本机 token 明文 + 配置方法
pub async fn detail_local_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(subject): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_via_pat_forbidden",
"不能用 Web PAT 查看 vault 本机详情;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_forbidden",
"只能查看自己的 vault 本机配置",
)
.with_context(&context));
}
let detail =
crate::routes::local_agent_install::vault_token_detail(state.config(), &subject)?;
Ok(ok_response(&context, detail))
}
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
pub async fn issue_extension_token(
Extension(context): Extension<RequestContext>,
@@ -1671,14 +2166,16 @@ pub async fn share_to_ai(
));
}
// 相对分组覆盖(可选)。省略时使用源条目 folderPath。
// AI 侧最终路径由 compose_ai_shared_folder_path 生成:`{分享者}/{原分组…}`。
// 不再默认写成扁平的 `from-{actor}`(会丢掉用户原有「系统/个人」层级)。
let folder_override = map
.get("folderPath")
.or_else(|| map.get("folder_path"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| Some(format!("from-{}", actor)));
.map(|s| s.to_string());
// Default true: copy missing [Key] fragments into AI cipher-book so templates resolve.
// Existing non-empty target keys are never overwritten.
@@ -2016,6 +2513,23 @@ mod resolve_strategy_tests {
/// Serialize tests that touch the shared AI vault path.
static AI_VAULT_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn normalize_agent_vault_scopes_defaults_and_strips_vault_prefix() {
assert_eq!(
normalize_agent_vault_scopes(vec![]),
vec!["list", "get", "resolve"]
);
assert_eq!(
normalize_agent_vault_scopes(vec![
"vault.list".into(),
"vault.resolve".into(),
"get".into(),
"vault.login".into(),
]),
vec!["get", "list", "login", "resolve"]
);
}
#[test]
fn resolve_rejects_invalid_field_before_io() {
let err =
@@ -112,10 +112,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
// 与生成侧 32 字节/64 hex 一致,拒绝弱 key。
if hex.len() < 64 {
return Err(WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
"extension HMAC key 过短",
"extension HMAC key 过短(需要至少 32 字节 / 64 hex)",
));
}
return hex::decode(hex).map_err(|e| {
@@ -308,6 +309,8 @@ pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebEr
Ok(claims)
}
/// 目前仅 unit test 使用;extension 路由后续接 scope 校验时可去掉 cfg。
#[cfg(test)]
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") {
+57 -20
View File
@@ -9,21 +9,26 @@ use axum::http::StatusCode;
/// Workspace-root-relative path that points at the password vault system space.
///
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments
/// (`..`) are rejected before classification (callers usually already do this).
/// `rel` should use `/` separators. Collapses `.` / empty segments and resolves
/// `..` with a stack (cannot climb above workspace root). This closes
/// `.mnote/./vault/...` and `.mnote/vault/../vault/...` classification gaps.
pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/");
while value.starts_with("./") {
value = value[2..].to_string();
let value = rel.trim().replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for segment in value.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
if !parts.is_empty() {
parts.pop();
}
// Climbing above workspace root is discarded (fail closed at root).
continue;
}
parts.push(segment);
}
value = value.trim_start_matches('/').to_string();
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
parts.join("/")
}
/// Returns true when `rel` is `.mnote/vault` or any path under it.
@@ -32,13 +37,6 @@ pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
if n.is_empty() {
return false;
}
if n
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
// Escape / empty segments are not vault matches; callers reject escape.
return false;
}
n == ".mnote/vault" || n.starts_with(".mnote/vault/")
}
@@ -101,6 +99,23 @@ mod tests {
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
#[test]
fn detects_dot_segment_and_parent_resolved_vault_paths() {
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/cipher-book.json"
));
assert!(is_vault_sensitive_relative_path(
"foo/../.mnote/vault/secret.json"
));
// After resolving out of vault, no longer sensitive.
assert!(!is_vault_sensitive_relative_path(
".mnote/vault/../trash/a.md"
));
}
#[test]
fn denies_cipher_book_and_index_under_vault() {
assert!(is_vault_sensitive_relative_path(
@@ -116,4 +131,26 @@ mod tests {
.expect_err("must deny cipher-book via general file surface");
assert_eq!(err.code(), "vault_path_denied");
}
#[test]
fn normalize_collapses_dot_and_parent_segments() {
assert_eq!(
normalize_workspace_relative_path(".mnote/./vault/secret.json"),
".mnote/vault/secret.json"
);
assert_eq!(
normalize_workspace_relative_path(".mnote/vault/../vault/a"),
".mnote/vault/a"
);
assert_eq!(
normalize_workspace_relative_path("foo/../../.mnote/vault/x"),
".mnote/vault/x"
);
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/secret.json"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/a"
));
}
}
+307 -29
View File
@@ -493,31 +493,57 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
Ok(root)
}
/// 将 session 路径段消毒为安全文件名,拒绝 `..` / 分隔符 / 控制字符。
fn sanitize_session_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
}
/// Normalize account id for session file name (`primary` when empty).
pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
let raw = account_id.map(str::trim).unwrap_or("");
if raw.is_empty() || raw.eq_ignore_ascii_case("primary") {
SESSION_ACCOUNT_PRIMARY.to_string()
} else if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
SESSION_ACCOUNT_PRIMARY.to_string()
} else {
raw.to_string()
return SESSION_ACCOUNT_PRIMARY.to_string();
}
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
return SESSION_ACCOUNT_PRIMARY.to_string();
}
sanitize_session_path_segment(raw, SESSION_ACCOUNT_PRIMARY)
}
/// `sessions/{credId}/` under vault root.
/// 公共相对路径 API;内部读写走 `*_abs`,保留供外部/脚本使用。
#[allow(dead_code)]
pub fn session_dir_rel(credential_id: &str) -> String {
format!("sessions/{credential_id}")
let cred = sanitize_session_path_segment(credential_id, "invalid");
format!("sessions/{cred}")
}
/// `sessions/{credId}/{accountId}.json`
#[allow(dead_code)]
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
let cred = sanitize_session_path_segment(credential_id, "invalid");
let acc = normalize_session_account_id(account_id);
format!("sessions/{credential_id}/{acc}.json")
format!("sessions/{cred}/{acc}.json")
}
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
vault.join("sessions").join(credential_id)
let cred = sanitize_session_path_segment(credential_id, "invalid");
vault.join("sessions").join(cred)
}
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
@@ -577,6 +603,19 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法写入登录态: {error}"),
)
})?;
// 会话含 cookie/tokenrename 前 fsync,降低崩溃半写风险。
f.sync_all().map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法同步登录态到磁盘: {error}"),
)
})?;
// 尽量限制为属主读写(Unix);Windows 上 set_permissions 语义不同,忽略失败。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
}
}
fs::rename(&tmp, path).map_err(|error| {
WebError::bad_request_code(
@@ -584,6 +623,11 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法提交登录态: {error}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
@@ -607,6 +651,8 @@ fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebErro
}
/// Read one session file (no frontmatter fallback).
/// 公共包装;内部路径用 `read_session_file_at`。
#[allow(dead_code)]
pub fn read_session_file(
workspace_root: &Path,
credential_id: &str,
@@ -1670,6 +1716,210 @@ pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
}
}
/// Sanitize one folder segment (username / actor id) for use in `folderPath`.
pub fn sanitize_folder_segment(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() || s == "." || s == ".." {
return None;
}
let cleaned: String = s
.chars()
.map(|c| match c {
'/' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let cleaned = cleaned.trim_matches('_').trim();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
None
} else {
Some(cleaned.to_string())
}
}
fn strip_ai_share_namespace(path: &str, actor_ns: &str) -> Option<String> {
let path = path.trim().trim_matches('/');
if path.is_empty() {
return None;
}
let from_ns = format!("from-{actor_ns}");
if path == actor_ns || path == from_ns {
return None;
}
if let Some(rest) = path
.strip_prefix(&format!("{actor_ns}/"))
.or_else(|| path.strip_prefix(&format!("{from_ns}/")))
{
return normalize_folder_path(Some(rest));
}
Some(path.to_string())
}
/// Compose AI 密码本分组路径:`{sourceUser}/{原用户分组…}`。
pub fn compose_ai_shared_folder_path(
source_actor_id: &str,
relative: Option<&str>,
) -> Option<String> {
let Some(ns) = sanitize_folder_segment(source_actor_id) else {
return normalize_folder_path(relative);
};
let relative = normalize_folder_path(relative)
.and_then(|p| strip_ai_share_namespace(&p, &ns));
match relative {
Some(r) if !r.is_empty() => Some(format!("{ns}/{r}")),
_ => Some(ns),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathChange {
pub id: String,
pub title: String,
pub source_actor_id: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathsOutcome {
pub scanned: usize,
pub updated: usize,
pub skipped: usize,
pub failed: usize,
pub changes: Vec<RepairAiFolderPathChange>,
}
pub fn normalize_share_actor_id(raw: &str) -> Option<String> {
let s = raw.trim();
let s = s
.strip_prefix("user:")
.or_else(|| s.strip_prefix("user/"))
.unwrap_or(s)
.trim();
sanitize_folder_segment(s)
}
pub fn parse_from_actor_folder_prefix(folder_path: Option<&str>) -> Option<String> {
let p = normalize_folder_path(folder_path)?;
let rest = p.strip_prefix("from-")?;
let actor = rest.split('/').next().unwrap_or("").trim();
sanitize_folder_segment(actor)
}
/// 批量修复 AI 密码本分享副本分组为 `{分享者}/{源分组}`。
pub fn repair_ai_shared_folder_paths(
ai_workspace: &Path,
resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
repair_ai_shared_folder_paths_with_default(ai_workspace, resolve_source_workspace, None)
}
pub fn repair_ai_shared_folder_paths_with_default(
ai_workspace: &Path,
mut resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
default_source_actor: Option<&str>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
let (_index, entries) = list_credentials(ai_workspace, VaultItemStatus::Active)?;
let mut scanned = 0usize;
let mut updated = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
let mut changes = Vec::new();
let mut known_actors: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(d) = default_source_actor.and_then(normalize_share_actor_id) {
known_actors.insert(d);
}
let mut records: Vec<VaultCredentialRecord> = Vec::with_capacity(entries.len());
for entry in &entries {
match get_credential(ai_workspace, &entry.id) {
Ok(r) => {
if let Some(origin) = r.shared_from.as_ref() {
if let Some(a) = normalize_share_actor_id(&origin.source_actor_id) {
known_actors.insert(a);
}
}
if let Some(a) = parse_from_actor_folder_prefix(r.folder_path.as_deref()) {
known_actors.insert(a);
}
records.push(r);
}
Err(_) => {
scanned += 1;
failed += 1;
}
}
}
let sole_actor = if known_actors.len() == 1 {
known_actors.iter().next().cloned()
} else {
default_source_actor.and_then(normalize_share_actor_id)
};
for mut record in records {
scanned += 1;
let source_id_hint = record
.shared_from
.as_ref()
.map(|o| o.source_id.clone())
.unwrap_or_default();
let source_actor = record
.shared_from
.as_ref()
.and_then(|o| normalize_share_actor_id(&o.source_actor_id))
.or_else(|| parse_from_actor_folder_prefix(record.folder_path.as_deref()))
.or_else(|| sole_actor.clone());
let Some(source_actor) = source_actor else {
skipped += 1;
continue;
};
let relative_from_source = if !source_id_hint.is_empty() {
resolve_source_workspace(&source_actor)
.and_then(|src_root| get_credential(&src_root, &source_id_hint).ok())
.and_then(|src| src.folder_path)
} else {
None
};
let relative = relative_from_source.or_else(|| {
let p = record.folder_path.as_deref()?;
strip_ai_share_namespace(p, &source_actor)
});
let new_path = compose_ai_shared_folder_path(&source_actor, relative.as_deref());
if record.folder_path == new_path {
skipped += 1;
continue;
}
let from = record.folder_path.clone();
record.folder_path = new_path.clone();
record.updated_at = now_rfc3339();
record.revision = record.revision.saturating_add(1);
if write_record_with_index(ai_workspace, &record).is_err() {
failed += 1;
continue;
}
updated += 1;
changes.push(RepairAiFolderPathChange {
id: record.id.clone(),
title: record.title.clone(),
source_actor_id: source_actor,
from,
to: new_path,
});
}
Ok(RepairAiFolderPathsOutcome {
scanned,
updated,
skipped,
failed,
changes,
})
}
/// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords).
pub fn validate_cipher_key(key: &str) -> Result<(), WebError> {
let key = key.trim();
@@ -1954,11 +2204,32 @@ fn yaml_escape(value: &str) -> String {
if value.is_empty() {
return "\"\"".to_string();
}
if value.chars().any(|c| {
// 双引号标量必须转义控制字符,否则按行解析 frontmatter 时多行值会被截断。
let needs_quote = value.chars().any(|c| {
c.is_whitespace()
|| matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`')
}) {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|| c.is_control()
|| matches!(
c,
':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>'
| '%' | '@' | '`'
)
});
if needs_quote {
let mut escaped = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if c.is_control() => {
escaped.push_str(&format!("\\u{:04x}", c as u32));
}
c => escaped.push(c),
}
}
format!("\"{escaped}\"")
} else {
value.to_string()
}
@@ -3035,11 +3306,11 @@ fn apply_source_fields_to_ai_copy(
target.secrets = source.secrets.clone();
target.fields = source.fields.clone();
target.notes_markdown = source.notes_markdown.clone();
// AI 副本分组必须带分享者命名空间;禁止把用户原 folder_path 原样写回。
target.folder_path = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| source.folder_path.clone())
.or_else(|| target.folder_path.clone());
target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
// Keep AI-side login session always; seed playbook only if missing.
@@ -3150,7 +3421,8 @@ pub fn sync_shared_ai_copy(
/// When `sync_cipher_keys` is true, missing target cipher fragments are copied
/// from the source book (existing non-empty target keys are never overwritten).
/// If source already has `shared_to_ai` and the target still exists, **updates** that copy.
/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`).
/// Tags get `ai-shared` if missing.
/// `folder_path_override`:可选相对分组;AI 侧路径 = `{sourceUser}/{relative}`。
pub fn share_credential_to_workspace(
source_workspace: &Path,
target_workspace: &Path,
@@ -3169,6 +3441,12 @@ pub fn share_credential_to_workspace(
));
}
let relative = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or_else(|| source.folder_path.as_deref());
let ai_folder = compose_ai_shared_folder_path(source_actor_id, relative);
// Re-share / sync existing linked copy when still present.
if let Some(link) = source.shared_to_ai.clone() {
if !link.target_id.is_empty()
@@ -3181,7 +3459,7 @@ pub fn share_credential_to_workspace(
&link.target_id,
source_actor_id,
target_actor_id,
folder_path_override,
ai_folder.as_deref(),
sync_cipher_keys,
);
}
@@ -3201,11 +3479,7 @@ pub fn share_credential_to_workspace(
}
let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
let folder = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| source.folder_path.clone());
let folder = ai_folder;
let mut item = create_credential(
target_workspace,
VaultCreateInput {
@@ -3293,6 +3567,8 @@ pub fn maybe_sync_after_source_update(
if link.target_id.is_empty() {
return Ok(None);
}
let ai_folder =
compose_ai_shared_folder_path(source_actor_id, source.folder_path.as_deref());
match sync_shared_ai_copy(
source_workspace,
source,
@@ -3300,7 +3576,7 @@ pub fn maybe_sync_after_source_update(
&link.target_id,
source_actor_id,
&link.target_actor_id,
None,
ai_folder.as_deref(),
sync_cipher_keys,
) {
Ok(outcome) => Ok(Some(outcome)),
@@ -4228,6 +4504,8 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
///
/// Does **not** rewrite credential markdown with cookie secrets.
/// AI multi-agent session write remains in `mnote-vault-core` (12-2).
/// 兼容封装 → `put_login_session_for_account`;生产路由走后者,本函数主要给测试。
#[cfg(test)]
pub fn put_login_session(
workspace_root: &Path,
id: &str,
@@ -4750,7 +5028,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4761,7 +5039,7 @@ mod tests {
assert_ne!(shared.id, created.id);
assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret"));
assert_eq!(shared.apikey.as_deref(), Some("sk-test"));
assert_eq!(shared.folder_path.as_deref(), Some("from-user"));
assert_eq!(shared.folder_path.as_deref(), Some("user-a/ai/keys"));
assert!(shared.tags.iter().any(|t| t == "ai-shared"));
assert!(shared.tags.iter().any(|t| t == "prod"));
assert!(shared.shared_from.is_some());
@@ -4788,7 +5066,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4824,7 +5102,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4892,7 +5170,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4944,7 +5222,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4984,7 +5262,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -5437,7 +5715,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
false,
"user-a",
@@ -186,10 +186,46 @@ fn map_http_error(status: u16, body: &str) -> WebError {
fn client_token() -> Result<Option<String>, WebError> {
match read_token_from_env_or_file() {
Ok(t) => Ok(Some(t)),
Err(_) => Ok(None),
Err(e) => {
// UDS-only 必须有 tokenauto 模式允许后续失败后回落 local core。
if transport_mode() == TransportMode::UdsOnly {
return Err(WebError::service_unavailable_code(
"vaultd_token_unavailable",
format!("无法读取 vaultd client token: {e}"),
));
}
Ok(None)
}
}
}
/// vaultd 路径段 id:拒 CRLF / 路径穿越 / 查询注入。
fn sanitize_vaultd_item_id(id: &str) -> Result<String, WebError> {
let id = id.trim();
if id.is_empty() || id.len() > 128 {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 无效",
));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 含非法字符",
));
}
if id.contains("..") {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 不得含 ..",
));
}
Ok(id.to_string())
}
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
where
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
@@ -241,6 +277,7 @@ pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
}
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
let id = sanitize_vaultd_item_id(id)?;
with_transport(
|token| {
let path = format!("/v1/items/{id}");
@@ -252,7 +289,7 @@ pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
WebError::internal(format!("vaultd get JSON 无效: {e}"))
})
},
|| vault::get_ai_vault_item(id),
|| vault::get_ai_vault_item(&id),
)
}
@@ -264,7 +301,7 @@ pub fn resolve_ai_vault_secret(
account_id: Option<&str>,
secret_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
let field_owned = field.to_string();
let account = account_id.map(str::to_string);
let secret = secret_id.map(str::to_string);
@@ -305,7 +342,7 @@ pub fn login_ai_vault_credential(
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
with_transport(
|token| {
let path = format!("/v1/items/{id_owned}/login");
@@ -332,7 +369,7 @@ pub fn put_ai_vault_session(
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
let cookie = cookie_header.to_string();
let expires = expires_at.map(str::to_string);
let source_owned = source.to_string();
@@ -388,4 +425,16 @@ mod tests {
let _ = list_ai_vault_items(VaultItemStatus::Active);
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
}
#[test]
fn sanitize_vaultd_item_id_rejects_path_injection() {
assert!(sanitize_vaultd_item_id("../x").is_err());
assert!(sanitize_vaultd_item_id("a/b").is_err());
assert!(sanitize_vaultd_item_id("a?b").is_err());
assert!(sanitize_vaultd_item_id("a\nb").is_err());
assert_eq!(
sanitize_vaultd_item_id("item-1_ok.x").unwrap(),
"item-1_ok.x"
);
}
}
+71 -3
View File
@@ -423,11 +423,22 @@ fn local_markdown_parent_scope_from_document_id(document_id: &str) -> String {
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> String {
document_id
let relative = document_id
.trim()
.strip_prefix("local-md:")
.unwrap_or_default()
.replace("~2F", "/")
.replace("~2F", "/");
// 解码后词法拒 `..` / 绝对路径,避免导航 recent 等路径写入携带穿越串。
let path = std::path::Path::new(relative.as_str());
if relative.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return String::new();
}
relative
}
fn record_navigation_recent_page(
@@ -3240,8 +3251,28 @@ pub(crate) fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
}
/// 将 JSON 嵌入 `<script type="application/json">` 时防止提前闭合标签。
/// HTML 标签名大小写不敏感,必须匹配 `</script` 的任意大小写变体。
pub(crate) fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
let lower = value.to_ascii_lowercase();
let needle = b"</script";
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + needle.len() <= lower_bytes.len() && &lower_bytes[i..i + needle.len()] == needle {
out.push_str("<\\/script");
i += needle.len();
// 保留原串中 `</script` 之后紧跟的字符(如 `>`、空白),不吞掉
continue;
}
// 按 UTF-8 字符推进,避免切断多字节序列
let ch = value[i..].chars().next().expect("valid utf-8 offset");
out.push(ch);
i += ch.len_utf8();
}
out
}
pub(crate) async fn load_workspace_shell_projection(
@@ -3627,6 +3658,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
@@ -3671,6 +3703,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3772,6 +3805,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3914,6 +3948,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -4682,6 +4717,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
@@ -4750,6 +4786,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
@@ -4908,6 +4945,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let workspace_id =
@@ -5463,6 +5501,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
@@ -5624,4 +5663,33 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
}
#[test]
fn escape_script_json_is_case_insensitive_for_script_close() {
use super::escape_script_json;
// 输出串中字面为 `<` + `\` + `/script`HTML 脚本闭合防护)
let escaped_close = r#"<\/script>"#;
assert_eq!(
escape_script_json(r#"{"t":"</script>"}"#),
format!(r#"{{"t":"{escaped_close}"}}"#)
);
assert_eq!(
escape_script_json(r#"{"t":"</SCRIPT>"}"#),
format!(r#"{{"t":"{escaped_close}"}}"#)
);
assert_eq!(
escape_script_json(r#"{"t":"</ScRiPt> alert(1)"}"#),
format!(r#"{{"t":"{escaped_close} alert(1)"}}"#)
);
// 正常 JSON 不受影响
assert_eq!(
escape_script_json(r#"{"ok":true,"n":1}"#),
r#"{"ok":true,"n":1}"#
);
// 多字节 UTF-8 不截断
assert_eq!(
escape_script_json(r#"{"t":"中文</Script>尾"}"#),
format!(r#"{{"t":"中文{escaped_close}尾"}}"#)
);
}
}
+59 -8
View File
@@ -32,17 +32,43 @@ async fn handle_socket(
query: StreamSnapshotQuery,
payload: Value,
) {
let mut stream_delta_rx = state.stream_delta_tx.subscribe();
let subscription_workspace = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let mut stream_delta_rx = Some(state.stream_delta_tx.subscribe());
let _ = socket.send(serialize_snapshot_message(&payload)).await;
loop {
tokio::select! {
biased;
// 优先处理 broadcast 推送的变更通知
delta_result = stream_delta_rx.recv() => {
// 优先处理 broadcast 推送的变更通知channel 关闭后不再 recv,避免 busy spin
delta_result = async {
match stream_delta_rx.as_mut() {
Some(rx) => Some(rx.recv().await),
None => {
std::future::pending::<()>().await;
None
}
}
} => {
match delta_result {
Ok(delta) => {
Some(Ok(delta)) => {
if !delta_matches_workspace(&delta, subscription_workspace.as_deref()) {
continue;
}
if socket
.send(serialize_delta_message(
&delta,
@@ -55,7 +81,7 @@ async fn handle_socket(
break;
}
}
Err(RecvError::Lagged(n)) => {
Some(Err(RecvError::Lagged(n))) => {
// Lagged: 发送 resync 提示让客户端重新加载
let lagged_hint = json!({
"kind": "resync_hint",
@@ -66,9 +92,11 @@ async fn handle_socket(
});
let _ = socket.send(Message::Text(lagged_hint.to_string().into())).await;
}
Err(RecvError::Closed) => {
// Broadcast channel closed, WS stays open for client-initiated resync
Some(Err(RecvError::Closed)) => {
// Broadcast 已关闭:丢弃订阅,避免紧忙循环;WS 仍可处理客户端 resync
stream_delta_rx = None;
}
None => {}
}
}
@@ -87,7 +115,7 @@ async fn handle_socket(
break;
}
// 重订阅 broadcast(可能丢掉了中间的变更)
stream_delta_rx = state.stream_delta_tx.subscribe();
stream_delta_rx = Some(state.stream_delta_tx.subscribe());
}
Err(error) => {
let err_payload = json!({
@@ -129,6 +157,29 @@ async fn handle_socket(
}
}
/// 仅转发与当前订阅 workspace 一致的 delta,避免跨工作区泄露。
fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) -> bool {
let Some(expected) = subscription_workspace else {
// 无订阅 workspace 时不推送带 workspace 的全局 delta(保守)
return delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none();
};
match delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(delta_ws) => delta_ws == expected,
// 无 workspace 标记的 delta 不转发(避免跨租户噪声)
None => false,
}
}
fn serialize_snapshot_message(payload: &Value) -> Message {
Message::Text(payload.to_string().into())
}
+2 -1
View File
@@ -7,7 +7,8 @@ use leptos::prelude::*;
pub fn AdminAccessPolicyPanel(
#[prop(optional)] workspace_name: Option<String>,
#[prop(optional)] share_grants_path: Option<String>,
#[prop(optional, default = true)] is_admin: bool,
// 默认非管理员,避免未显式传入时以 admin 模式渲染(最小权限)。
#[prop(optional, default = false)] is_admin: bool,
#[prop(optional, default = true)] boot_script: bool,
) -> impl IntoView {
let workspace_name = workspace_name
+846 -1
View File
@@ -803,6 +803,231 @@ details.mnote-ai-admin-provider-card[open] > summary.mnote-ai-admin-provider-car
.mnote-ai-admin-subsection-card-body {
padding: 14px 16px 16px;
}
/* 7-76 token 表单 / 密钥卡片(PAT + vault mnv1 */
.mnote-ai-admin-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 0;
}
.mnote-ai-admin-field > span {
color: rgba(0,0,0,0.65);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.mnote-ai-admin-field input[type="text"],
.mnote-ai-admin-field input[type="number"],
.mnote-ai-admin-field select {
width: 100%;
box-sizing: border-box;
height: 32px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font-size: 14px;
line-height: 1.5;
}
.mnote-ai-admin-field input:focus,
.mnote-ai-admin-field select:focus {
outline: none;
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
.mnote-ai-admin-token-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px 14px;
align-items: end;
margin-bottom: 12px;
}
.mnote-ai-admin-token-form--actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.mnote-ai-admin-scope-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px 12px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.mnote-ai-admin-scope-item {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: rgba(0,0,0,0.78);
font-size: 13px;
cursor: pointer;
user-select: none;
}
.mnote-ai-admin-scope-item input {
width: 14px;
height: 14px;
margin: 0;
accent-color: #1677ff;
}
.mnote-ai-admin-scope-item small {
color: rgba(0,0,0,0.45);
font-size: 11px;
}
.mnote-ai-admin-help-list {
margin: 8px 0 0;
padding-left: 18px;
color: rgba(0,0,0,0.55);
font-size: 12px;
line-height: 1.6;
}
.mnote-ai-admin-help-list code {
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 11px;
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
}
.mnote-ai-admin-code {
max-height: 280px;
margin-top: 10px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
padding: 12px;
color: rgba(0,0,0,0.72);
font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mnote-ai-admin-token-card {
margin-top: 14px;
border: 1px solid #91caff;
border-radius: 8px;
background: #e6f4ff;
padding: 12px 14px;
}
.mnote-ai-admin-token-card[hidden] {
display: none !important;
}
.mnote-ai-admin-token-card--error {
border-color: #ffa39e;
background: #fff1f0;
}
.mnote-ai-admin-token-card-title {
margin: 0 0 6px;
color: rgba(0,0,0,0.88);
font-size: 13px;
font-weight: 600;
}
.mnote-ai-admin-token-card-warn {
margin: 0 0 10px;
color: #d46b08;
font-size: 12px;
line-height: 1.5;
}
.mnote-ai-admin-token-card-value {
margin: 0;
padding: 10px 12px;
border: 1px solid #91caff;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-all;
}
.mnote-ai-admin-token-card-meta {
margin: 10px 0 0;
color: rgba(0,0,0,0.55);
font-size: 12px;
line-height: 1.5;
}
.mnote-ai-admin-token-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mnote-ai-admin-token-card details {
margin-top: 10px;
}
.mnote-ai-admin-token-card summary {
cursor: pointer;
color: rgba(0,0,0,0.55);
font-size: 12px;
}
.mnote-ai-admin-modal-backdrop {
position: fixed;
inset: 0;
z-index: 12000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.45);
}
.mnote-ai-admin-modal-backdrop[hidden] {
display: none !important;
}
.mnote-ai-admin-modal {
width: min(560px, 100%);
max-height: min(80vh, 720px);
overflow: auto;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 50px rgba(0,0,0,0.22);
padding: 16px 18px 14px;
}
.mnote-ai-admin-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mnote-ai-admin-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: rgba(0,0,0,0.88);
}
.mnote-ai-admin-modal-label {
margin: 10px 0 4px;
font-size: 12px;
color: #6b7280;
}
.mnote-ai-admin-modal-pre {
margin: 0;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
font: 12px/1.5 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
}
.mnote-ai-admin-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
}
.mnote-ai-admin-chip-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 240px;
}
.mnote-ai-admin-access-scope-row {
display: flex;
justify-content: space-between;
@@ -1062,6 +1287,35 @@ const AI_ADMIN_SCRIPT: &str = r#"
.replace(/'/g, '&#39;');
}
async function copyText(text, okLabel) {
var value = String(text == null ? '' : text);
if (!value) throw new Error('');
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
} else {
var ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
return okLabel || '';
}
function flashButton(btn, label) {
if (!btn) return;
var prev = btn.getAttribute('data-flash-prev') || btn.textContent;
btn.setAttribute('data-flash-prev', prev);
btn.textContent = label;
window.setTimeout(function () {
btn.textContent = btn.getAttribute('data-flash-prev') || prev;
}, 1400);
}
async function requestJson(url, options) {
var response = await fetch(url, {
credentials: 'include',
@@ -2923,6 +3177,395 @@ const AI_ADMIN_SCRIPT: &str = r#"
}
}
// ── 7-76 API 访问令牌 / vault token(列表 + 详情弹窗)──
var patListBody = root.querySelector('[data-ai-pat-list]');
var vaultIssuedList = root.querySelector('[data-ai-vault-issued-list]');
var patFormStatus = root.querySelector('[data-ai-pat-form-status]');
var vaultFormStatus = root.querySelector('[data-ai-vault-form-status]');
var tokenModal = root.querySelector('[data-ai-token-modal]');
var tokenModalTitle = root.querySelector('[data-ai-token-modal-title]');
var tokenModalToken = root.querySelector('[data-ai-token-modal-token]');
var tokenModalHow = root.querySelector('[data-ai-token-modal-how]');
var tokenModalError = root.querySelector('[data-ai-token-modal-error]');
var lastDetailToken = '';
var patRuntime = { environment: 'dev', baseUrl: 'http://127.0.0.1:3000', envFile: '~/.config/mnote/agent-env/users/<subject>/dev.env' };
// 设置页独立壳也加载 mnote-ui-runtime;此处再兜底避免 mnote 未就绪
function uiConfirm(message, options) {
if (window.mnote && typeof window.mnote.confirm === 'function') {
return window.mnote.confirm(message, options);
}
return Promise.resolve(window.confirm(message));
}
function uiAlert(message, options) {
if (window.mnote && typeof window.mnote.alert === 'function') {
return window.mnote.alert(message, options);
}
window.alert(message);
return Promise.resolve();
}
function selectedPatScopes() {
return Array.prototype.slice.call(root.querySelectorAll('[data-ai-pat-scope]:checked'))
.map(function (el) { return el.value; })
.filter(Boolean);
}
function envLabel(env) {
var e = String(env || 'dev');
if (e === 'prod') return '';
if (e === 'dev') return '';
return e;
}
function setFormStatus(el, text, isError) {
if (!el) return;
el.textContent = text || '';
el.style.color = isError ? '#b91c1c' : '#6b7280';
}
function renderScopeChips(scopes) {
var list = scopes || [];
if (!list.length) return '<span class="mnote-ai-admin-tag">-</span>';
return '<div class="mnote-ai-admin-chip-row">' + list.map(function (s) {
return '<span class="mnote-ai-admin-tag">' + escapeHtml(s) + '</span>';
}).join('') + '</div>';
}
function statusTag(item) {
if (item.revokedAt) return '<span class="mnote-ai-admin-tag mnote-ai-admin-tag--red"></span>';
var installed = !!(item.installed || (item.localInstall && item.localInstall.installed));
var active = !!(item.active || (item.localInstall && item.localInstall.active));
if (installed) {
var label = active ? '·' : '';
return '<span class="mnote-ai-admin-tag" style="background:#e8f3ff;color:#1d4ed8;">' + label + '</span>';
}
return '<span class="mnote-ai-admin-tag" style="background:#f3f4f6;color:#6b7280;"></span>';
}
function openTokenModal(opts) {
if (!tokenModal) {
console.warn('token modal missing');
return;
}
var title = (opts && opts.title) || '';
var token = (opts && opts.token) || '';
var how = (opts && opts.howTo) || '';
var err = (opts && opts.error) || '';
lastDetailToken = token;
if (tokenModalTitle) tokenModalTitle.textContent = title;
if (tokenModalToken) tokenModalToken.textContent = token || (err ? '' : '');
if (tokenModalHow) tokenModalHow.textContent = how || '';
if (tokenModalError) {
tokenModalError.textContent = err || '';
tokenModalError.hidden = !err;
}
tokenModal.hidden = false;
}
function closeTokenModal() {
if (tokenModal) tokenModal.hidden = true;
lastDetailToken = '';
if (tokenModalToken) tokenModalToken.textContent = '';
if (tokenModalHow) tokenModalHow.textContent = '';
if (tokenModalError) {
tokenModalError.textContent = '';
tokenModalError.hidden = true;
}
}
function renderPatList(items) {
if (!patListBody) return;
if (!items || !items.length) {
patListBody.innerHTML = '<tr><td colspan="6" class="mnote-ai-admin-empty"></td></tr>';
return;
}
patListBody.innerHTML = items.map(function (item) {
var installed = !!(item.installed || (item.localInstall && item.localInstall.installed));
var id = item.id || '';
var confBtn = '';
if (!item.revokedAt) {
confBtn = installed
? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-pat-uninstall="' + escapeHtml(id) + '"></button> '
: '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--primary" data-ai-pat-install="' + escapeHtml(id) + '"></button> ';
}
var detailBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-pat-detail-btn="' + escapeHtml(id) + '" data-ai-pat-detail-name="' + escapeHtml(item.name || '') + '"></button> ';
var revokeBtn = item.revokedAt ? ''
: '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--danger" data-ai-pat-revoke="' + escapeHtml(id) + '"></button> ';
var deleteBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--danger" data-ai-pat-delete="' + escapeHtml(id) + '"></button>';
return '<tr>'
+ '<td>' + escapeHtml(item.name || '') + '</td>'
+ '<td>' + escapeHtml(item.subjectUserId || '') + '</td>'
+ '<td><code>' + escapeHtml(item.tokenPrefix || '') + '</code></td>'
+ '<td>' + renderScopeChips(item.scopes || []) + '</td>'
+ '<td>' + statusTag(item) + '</td>'
+ '<td>' + confBtn + detailBtn + revokeBtn + deleteBtn + '</td>'
+ '</tr>';
}).join('');
}
async function loadPatList() {
if (!patListBody) return;
// 即使用户面 isAdmin=true 也不默认 all:避免 liaibo/mnote-e2e 列表交叉。
// 仅 /admin/ai 且显式 pageConfig.listAllTokens 时才拉全量(代签运维)。
var listAll = isAdmin && pageConfig.listAllTokens === true;
var q = listAll ? '?all=1' : '';
var payload = await requestJson('/api/ai-tokens' + q, { method: 'GET' });
if (payload && payload.runtime) patRuntime = payload.runtime;
var hint = root.querySelector('[data-ai-pat-runtime]');
if (hint && patRuntime) {
var active = patRuntime.activePatSubject ? (' · active:' + patRuntime.activePatSubject) : '';
hint.textContent = envLabel(patRuntime.environment) + ' · ' + (patRuntime.baseUrl || '') + active;
}
renderPatList(payload && payload.items);
}
function renderVaultLocalList(items) {
if (!vaultIssuedList) return;
if (!items || !items.length) {
vaultIssuedList.innerHTML = '<tr><td colspan="6" class="mnote-ai-admin-empty"></td></tr>';
return;
}
vaultIssuedList.innerHTML = items.map(function (item) {
var subject = item.subject || '';
var confBtn = item.installed
? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-vault-uninstall="' + escapeHtml(subject) + '"></button> '
: '';
var detailBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-vault-detail="' + escapeHtml(subject) + '"></button>';
return '<tr>'
+ '<td>' + escapeHtml(item.name || subject) + '</td>'
+ '<td>' + escapeHtml(subject) + '</td>'
+ '<td><code>' + escapeHtml(item.tokenPrefix || '') + '</code></td>'
+ '<td>' + renderScopeChips(item.scopes || []) + '</td>'
+ '<td>' + statusTag({ installed: item.installed }) + '</td>'
+ '<td>' + confBtn + detailBtn + '</td>'
+ '</tr>';
}).join('');
}
async function loadVaultLocalList() {
if (!vaultIssuedList) return;
try {
// 默认只列当前登录主体;admin 运维全量需 pageConfig.listAllTokens
var listAll = isAdmin && pageConfig.listAllTokens === true;
var q = listAll ? '?all=1' : '';
var payload = await requestJson('/api/vault/ai/token/local' + q, { method: 'GET' });
var data = (payload && payload.result) || payload || {};
if (data.runtime) patRuntime = Object.assign({}, patRuntime, data.runtime);
var hint = root.querySelector('[data-ai-vault-runtime]');
if (hint) {
var active = patRuntime.activeVaultSubject ? (' · active:' + patRuntime.activeVaultSubject) : '';
hint.textContent = envLabel(patRuntime.environment) + ' · ' + (patRuntime.baseUrl || '') + active;
}
renderVaultLocalList(data.items);
} catch (err) {
vaultIssuedList.innerHTML = '<tr><td colspan="6">' + escapeHtml(err.message || '') + '</td></tr>';
}
}
var createPatBtn = root.querySelector('[data-ai-pat-create]');
if (createPatBtn) {
createPatBtn.addEventListener('click', async function () {
setFormStatus(patFormStatus, '');
try {
var nameEl = root.querySelector('[data-ai-pat-name]');
var subjectEl = root.querySelector('[data-ai-pat-subject]');
var ttlEl = root.querySelector('[data-ai-pat-ttl]');
var scopes = selectedPatScopes();
if (!scopes.length) throw new Error(' scope');
var body = {
name: (nameEl && nameEl.value.trim()) || 'default',
scopes: scopes,
ttlDays: ttlEl && ttlEl.value !== '' ? Number(ttlEl.value) : 90
};
if (subjectEl && subjectEl.value.trim()) body.subjectUserId = subjectEl.value.trim();
var payload = await requestJson('/api/ai-tokens', {
method: 'POST',
body: JSON.stringify(body)
});
var rec = payload.record || {};
if (rec.id) {
try {
await requestJson('/api/ai-tokens/' + encodeURIComponent(rec.id) + '/install', { method: 'POST', body: '{}' });
} catch (_) {}
}
await loadPatList();
setFormStatus(patFormStatus, '', false);
} catch (err) {
setFormStatus(patFormStatus, err.message || String(err), true);
}
});
}
var refreshPatBtn = root.querySelector('[data-ai-pat-refresh]');
if (refreshPatBtn) {
refreshPatBtn.addEventListener('click', function () {
loadPatList().catch(function (err) {
if (patListBody) patListBody.innerHTML = '<tr><td colspan="6">' + escapeHtml(err.message || '') + '</td></tr>';
});
});
}
if (tokenModal) {
tokenModal.addEventListener('click', function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.getAttribute('data-ai-token-modal-close') != null || t === tokenModal) {
closeTokenModal();
}
});
}
var modalCopyToken = root.querySelector('[data-ai-token-modal-copy-token]');
if (modalCopyToken) {
modalCopyToken.addEventListener('click', function () {
copyText(lastDetailToken || (tokenModalToken && tokenModalToken.textContent) || '', '')
.then(function (msg) { flashButton(modalCopyToken, msg); })
.catch(function (err) { flashButton(modalCopyToken, err.message || ''); });
});
}
var modalCopyHow = root.querySelector('[data-ai-token-modal-copy-how]');
if (modalCopyHow) {
modalCopyHow.addEventListener('click', function () {
copyText((tokenModalHow && tokenModalHow.textContent) || '', '')
.then(function (msg) { flashButton(modalCopyHow, msg); })
.catch(function (err) { flashButton(modalCopyHow, err.message || ''); });
});
}
if (patListBody) {
patListBody.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
var detailId = t.getAttribute('data-ai-pat-detail-btn');
var detailName = t.getAttribute('data-ai-pat-detail-name') || '';
var revokeId = t.getAttribute('data-ai-pat-revoke');
var deleteId = t.getAttribute('data-ai-pat-delete');
var installId = t.getAttribute('data-ai-pat-install');
var uninstallId = t.getAttribute('data-ai-pat-uninstall');
try {
if (detailId) {
try {
var revealed = await requestJson('/api/ai-tokens/' + encodeURIComponent(detailId) + '/reveal', { method: 'POST', body: '{}' });
openTokenModal({
title: (detailName || 'API ') + ' · ',
token: revealed.token || '',
howTo: revealed.howTo || ''
});
} catch (err) {
openTokenModal({
title: (detailName || 'API ') + ' · ',
token: '',
howTo: '',
error: err.message || String(err)
});
}
return;
}
if (revokeId) {
if (!(await uiConfirm('', { tone: 'danger' }))) return;
await requestJson('/api/ai-tokens/' + encodeURIComponent(revokeId) + '/revoke', { method: 'POST', body: '{}' });
await loadPatList();
}
if (deleteId) {
if (!(await uiConfirm('', { tone: 'danger' }))) return;
await requestJson('/api/ai-tokens/' + encodeURIComponent(deleteId), { method: 'DELETE' });
await loadPatList();
}
if (installId) {
await requestJson('/api/ai-tokens/' + encodeURIComponent(installId) + '/install', { method: 'POST', body: '{}' });
await loadPatList();
}
if (uninstallId) {
await requestJson('/api/ai-tokens/' + encodeURIComponent(uninstallId) + '/uninstall', { method: 'POST', body: '{}' });
await loadPatList();
}
} catch (err) {
setFormStatus(patFormStatus, err.message || String(err), true);
}
});
}
var vaultTokenBtn = root.querySelector('[data-ai-vault-token-create]');
if (vaultTokenBtn) {
vaultTokenBtn.addEventListener('click', async function () {
setFormStatus(vaultFormStatus, '');
try {
var subjectEl = root.querySelector('[data-ai-vault-token-subject]');
var ttlEl = root.querySelector('[data-ai-vault-token-ttl]');
var presetEl = root.querySelector('[data-ai-vault-token-preset]');
var body = { install: true };
if (subjectEl && subjectEl.value.trim()) body.subjectUserId = subjectEl.value.trim();
if (ttlEl && ttlEl.value !== '') body.ttlHours = Number(ttlEl.value);
var preset = presetEl ? presetEl.value : 'read';
if (preset === 'read_login') {
body.scopes = ['list', 'get', 'resolve', 'login', 'session'];
} else {
body.scopes = ['list', 'get', 'resolve'];
}
var payload = await requestJson('/api/vault/ai/token', {
method: 'POST',
body: JSON.stringify(body)
});
var result = payload && (payload.result || payload);
var installInfo = (result && result.install) || {};
await loadVaultLocalList();
if (installInfo.ok === false) {
setFormStatus(vaultFormStatus, installInfo.error || '', true);
} else {
setFormStatus(vaultFormStatus, '', false);
}
} catch (err) {
setFormStatus(vaultFormStatus, err.message || String(err), true);
}
});
}
if (vaultIssuedList) {
vaultIssuedList.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
var subject = t.getAttribute('data-ai-vault-uninstall');
var detailSubject = t.getAttribute('data-ai-vault-detail');
if (detailSubject) {
try {
var payload = await requestJson('/api/vault/ai/token/local/' + encodeURIComponent(detailSubject), { method: 'GET' });
var data = (payload && payload.result) || payload || {};
openTokenModal({
title: (data.name || detailSubject) + ' · Vault ',
token: data.token || '',
howTo: data.howTo || ''
});
} catch (err) {
openTokenModal({
title: detailSubject + ' · Vault ',
token: '',
howTo: '',
error: err.message || String(err)
});
}
return;
}
if (!subject) return;
if (!(await uiConfirm(' token ', { tone: 'danger' }))) return;
try {
await requestJson('/api/vault/ai/token/uninstall', {
method: 'POST',
body: JSON.stringify({ subjectUserId: subject })
});
await loadVaultLocalList();
setFormStatus(vaultFormStatus, '');
} catch (err) {
setFormStatus(vaultFormStatus, err.message || String(err), true);
}
});
}
var vaultLocalRefresh = root.querySelector('[data-ai-vault-local-refresh]');
if (vaultLocalRefresh) {
vaultLocalRefresh.addEventListener('click', function () { loadVaultLocalList(); });
}
loadVaultLocalList();
// ── 启动 ──
loadEffective().catch(function (err) {
if (effectiveJson) setText(effectiveJson, { error: err.message || '' });
@@ -2940,6 +3583,9 @@ const AI_ADMIN_SCRIPT: &str = r#"
// 静默处理,各个容器已显示错误状态
console.warn('loadConfig :', err && err.message);
});
loadPatList().catch(function (err) {
if (patListBody) patListBody.innerHTML = '<tr><td colspan="7">' + escapeHtml(err.message || '') + '</td></tr>';
});
if (isAdmin) {
loadUsers().catch(function (err) {
if (userSettingsEditor) userSettingsEditor.innerHTML = '<div class="mnote-ai-admin-empty">' + escapeHtml(err.message || '') + '</div>';
@@ -2977,8 +3623,10 @@ pub fn AiManagementPage(
"个人设置 — 查看 AI 配置、当前目录权限并提交申请"
};
// listAllTokens:仅管理员页默认 true(代签运维看全量);用户页 false,避免配置交叉
let config_json = format!(
r#"{{"isAdmin":{},"workspaceName":"{}"}}"#,
r#"{{"isAdmin":{},"listAllTokens":{},"workspaceName":"{}"}}"#,
if is_admin { "true" } else { "false" },
if is_admin { "true" } else { "false" },
workspace_name.replace('"', "\\\"")
);
@@ -3025,6 +3673,8 @@ pub fn AiManagementPage(
<a href="#ai-admin-mcp" data-ai-admin-nav data-nav-icon="service">"MCP"</a>
<a href="#ai-admin-pi-extensions" data-ai-admin-nav data-nav-icon="tools">"Pi 扩展"</a>
<a href="#ai-admin-access" data-ai-admin-nav data-nav-icon="folder">"目录权限"</a>
<a href="#ai-admin-api-tokens" data-ai-admin-nav data-nav-icon="tools">"API 访问令牌"</a>
<a href="#ai-admin-vault-ai" data-ai-admin-nav data-nav-icon="db">"密码箱 AI 访问"</a>
<a href="#ai-admin-knowledge" data-ai-admin-nav data-nav-icon="db">"知识库"</a>
{if is_admin {
view! { <a href="#ai-admin-channels" data-ai-admin-nav data-nav-icon="channel">"渠道管理"</a> }.into_any()
@@ -3309,6 +3959,181 @@ pub fn AiManagementPage(
</div>
</section>
<section id="ai-admin-api-tokens" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-api-tokens">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"API 访问令牌"</h2>
<p class="mnote-ai-admin-section-desc">
"Web PATmnpat1.*)访问笔记 API · 与密码箱 mnv1 分职"
</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-token-form">
<label class="mnote-ai-admin-field">
<span>"名称"</span>
<input type="text" data-ai-pat-name placeholder="paseo-agent" />
</label>
<label class="mnote-ai-admin-field">
<span>"有效天数(-1 不过期)"</span>
<input type="number" data-ai-pat-ttl value="90" min="-1" />
</label>
{if is_admin {
view! {
<label class="mnote-ai-admin-field">
<span>"代签主体(可选)"</span>
<input type="text" data-ai-pat-subject placeholder="默认自己" />
</label>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<div class="mnote-ai-admin-field" style="margin-bottom:10px;">
<span>"Scope"</span>
<div class="mnote-ai-admin-scope-grid" data-ai-pat-scope-grid>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="notes.read" checked />
<span>"notes.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="notes.write" checked />
<span>"notes.write"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="tree.read" checked />
<span>"tree.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="tree.write" checked />
<span>"tree.write"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="ai.settings.read" />
<span>"ai.settings.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="ai.usage.read" />
<span>"ai.usage.read"</span>
</label>
</div>
</div>
<div class="mnote-ai-admin-token-form--actions" style="align-items:center;gap:12px;">
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-pat-create>"创建并配置"</button>
<span data-ai-pat-form-status style="font-size:12px;color:#6b7280;"></span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin:18px 0 8px;">
<div>
<strong style="font-size:13px;">"已签发"</strong>
<span data-ai-pat-runtime style="font-size:12px;color:#8B8782;margin-left:8px;"></span>
</div>
<button type="button" class="mnote-ai-admin-btn" data-ai-pat-refresh>"刷新"</button>
</div>
<div class="mnote-ai-admin-table-wrap">
<table class="mnote-ai-admin-table">
<thead>
<tr>
<th>"名称"</th>
<th>"主体"</th>
<th>"前缀"</th>
<th>"Scope"</th>
<th>"状态"</th>
<th>"操作"</th>
</tr>
</thead>
<tbody data-ai-pat-list>
<tr><td colspan="6" class="mnote-ai-admin-skeleton">"加载…"</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section id="ai-admin-vault-ai" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-vault-ai">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"密码箱 AI 访问"</h2>
<p class="mnote-ai-admin-section-desc">
"Vault tokenmnv1.*)本机 agent 读密 · 与 Web PAT 分职"
</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-token-form">
{if is_admin {
view! {
<label class="mnote-ai-admin-field">
<span>"代签主体(可选)"</span>
<input type="text" data-ai-vault-token-subject placeholder="默认自己;代签填用户 id" />
</label>
}.into_any()
} else {
view! {}.into_any()
}}
<label class="mnote-ai-admin-field">
<span>"TTL 小时(空=不过期)"</span>
<input type="number" data-ai-vault-token-ttl placeholder="168" min="1" />
</label>
<label class="mnote-ai-admin-field">
<span>"用途"</span>
<select data-ai-vault-token-preset>
<option value="read">"读密 list/get/resolve"</option>
<option value="read_login">"读密 + login/session"</option>
</select>
</label>
</div>
<div class="mnote-ai-admin-token-form--actions" style="align-items:center;gap:12px;">
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-vault-token-create>"签发并配置"</button>
<span data-ai-vault-form-status style="font-size:12px;color:#6b7280;"></span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin:18px 0 8px;">
<div>
<strong style="font-size:13px;">"已配置"</strong>
<span data-ai-vault-runtime style="font-size:12px;color:#8B8782;margin-left:8px;"></span>
</div>
<button type="button" class="mnote-ai-admin-btn" data-ai-vault-local-refresh>"刷新"</button>
</div>
<div class="mnote-ai-admin-table-wrap">
<table class="mnote-ai-admin-table">
<thead>
<tr>
<th>"名称"</th>
<th>"主体"</th>
<th>"前缀"</th>
<th>"Scope"</th>
<th>"状态"</th>
<th>"操作"</th>
</tr>
</thead>
<tbody data-ai-vault-issued-list>
<tr><td colspan="6" class="mnote-ai-admin-skeleton">"加载…"</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<div class="mnote-ai-admin-modal-backdrop" data-ai-token-modal hidden>
<div class="mnote-ai-admin-modal" role="dialog" aria-modal="true">
<div class="mnote-ai-admin-modal-head">
<h3 class="mnote-ai-admin-modal-title" data-ai-token-modal-title>"详情"</h3>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-token-modal-close>"关闭"</button>
</div>
<p class="mnote-ai-admin-modal-label">"Token / API Key"</p>
<pre class="mnote-ai-admin-modal-pre" data-ai-token-modal-token></pre>
<p class="mnote-ai-admin-modal-label">"配置方法"</p>
<pre class="mnote-ai-admin-modal-pre" data-ai-token-modal-how></pre>
<p data-ai-token-modal-error hidden style="margin:10px 0 0;color:#b91c1c;font-size:12px;"></p>
<div class="mnote-ai-admin-modal-actions">
<button type="button" class="mnote-ai-admin-btn" data-ai-token-modal-copy-how>"复制配置方法"</button>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-token-modal-copy-token>"复制 token"</button>
<button type="button" class="mnote-ai-admin-btn" data-ai-token-modal-close>"关闭"</button>
</div>
</div>
</div>
<section id="ai-admin-knowledge" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-knowledge">
<div class="mnote-ai-admin-section-header">
<div>
@@ -3437,6 +4262,24 @@ mod tests {
assert!(html.contains("Pi 扩展包"));
assert!(html.contains("工具权限"));
assert!(html.contains("MNote 设置"));
assert!(html.contains("API 访问令牌"));
assert!(html.contains("密码箱 AI 访问"));
assert!(html.contains("data-testid=\"mnote-ai-admin-api-tokens\""));
assert!(html.contains("data-testid=\"mnote-ai-admin-vault-ai\""));
assert!(html.contains("data-ai-pat-scope"));
assert!(html.contains("data-ai-pat-create"));
assert!(html.contains("data-ai-token-modal"));
assert!(html.contains("data-ai-pat-runtime"));
assert!(html.contains("data-ai-vault-token-create"));
assert!(html.contains("data-ai-vault-issued-list"));
assert!(html.contains("data-ai-vault-runtime"));
assert!(AI_ADMIN_STYLE.contains(".mnote-ai-admin-field"));
assert!(AI_ADMIN_SCRIPT.contains("selectedPatScopes"));
assert!(AI_ADMIN_SCRIPT.contains("loadVaultLocalList"));
assert!(AI_ADMIN_SCRIPT.contains("data-ai-pat-install"));
assert!(AI_ADMIN_SCRIPT.contains("body.scopes = ['list', 'get', 'resolve']"));
assert!(AI_ADMIN_SCRIPT.contains("install: true"));
assert!(AI_ADMIN_SCRIPT.contains("openTokenModal"));
}
#[test]
@@ -3452,7 +4295,9 @@ mod tests {
assert!(html.contains("目录权限"));
// 内嵌脚本包含 action 字符串;用户态由 isAdmin=false 禁用编辑。
assert!(html.contains(r#""isAdmin":false"#));
assert!(html.contains(r#""listAllTokens":false"#));
assert!(AI_ADMIN_SCRIPT.contains("var disabledAttr = isAdmin ? '' : ' disabled'"));
assert!(AI_ADMIN_SCRIPT.contains("listAllTokens"));
}
#[test]
+4 -52
View File
@@ -1,11 +1,10 @@
//! MNOTE 登录页面组件
//!
//! 7-76 P0:开发对标生产。登录页仅标准账号密码登录/注册,
//! 不再渲染「测试账号快速登录」或任何测试密码 DOM。
use leptos::prelude::*;
const TEST_ACCOUNT_EMAIL: &str = "mnote.e2e@example.com";
const TEST_ACCOUNT_PASSWORD: &str = "MnoteE2E123!";
const TEST_ACCOUNT_NAME: &str = "mnote-e2e";
/// MNOTE 登录页面
#[component]
pub fn AuthPage() -> impl IntoView {
@@ -71,16 +70,6 @@ pub fn AuthPage() -> impl IntoView {
</label>
<p class="mnote-auth-message" role="status" aria-live="polite" data-auth-message></p>
<button class="mnote-auth-submit" type="submit" data-auth-submit>"登录"</button>
<button
class="mnote-auth-quick-login"
type="button"
data-auth-test-login
data-test-email=TEST_ACCOUNT_EMAIL
data-test-password=TEST_ACCOUNT_PASSWORD
data-test-name=TEST_ACCOUNT_NAME
>
"测试账号快速登录"
</button>
</form>
<button class="mnote-auth-switch" type="button" data-auth-switch data-flow="signIn">
"没有账号?注册"
@@ -107,10 +96,9 @@ const AUTH_SCRIPT: &str = r#"
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
var submit = root.querySelector('[data-auth-submit]');
var quickLogin = root.querySelector('[data-auth-test-login]');
var switcher = root.querySelector('[data-auth-switch]');
var message = root.querySelector('[data-auth-message]');
if (!form || !flowInput || !submit || !quickLogin || !switcher || !message) return;
if (!form || !flowInput || !submit || !switcher || !message) return;
function setMessage(text, type) {
message.textContent = text || '';
@@ -119,7 +107,6 @@ const AUTH_SCRIPT: &str = r#"
function setBusy(isBusy) {
submit.disabled = !!isBusy;
quickLogin.disabled = !!isBusy;
switcher.disabled = !!isBusy;
}
@@ -146,7 +133,6 @@ const AUTH_SCRIPT: &str = r#"
if (usernameInput) {
usernameInput.required = isSignUp;
}
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
setMessage('', '');
@@ -218,40 +204,6 @@ const AUTH_SCRIPT: &str = r#"
}
});
quickLogin.addEventListener('click', async function () {
var email = String(quickLogin.dataset.testEmail || '').trim();
var password = String(quickLogin.dataset.testPassword || '');
var username = String(quickLogin.dataset.testName || '').trim();
if (!email || !password || !username) {
setMessage('', 'error');
return;
}
var passwordInput = root.querySelector('#password');
if (accountInput) accountInput.value = email;
if (passwordInput) passwordInput.value = password;
setBusy(true);
setMessage('...', 'info');
try {
await requestAuth('signIn', { account: email, password: password });
setMessage('...', 'success');
window.location.assign('/');
return;
} catch (_signInError) {
setMessage('...', 'info');
}
try {
await requestAuth('signUp', { email: email, username: username, password: password });
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '', 'error');
setBusy(false);
}
});
setFlow('signIn');
})();
"#;
@@ -609,8 +609,11 @@ mod tests {
#[test]
fn mnote_ui_runtime_exposes_toast_api() {
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.toast"));
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.confirm"));
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.alert"));
assert!(MNOTE_UI_RUNTIME_JS.contains("data-mnote-toast-region"));
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote:toast"));
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote-dialog-backdrop"));
}
#[test]
+5
View File
@@ -14,6 +14,7 @@
/// - `styles/pages/home.css`, `shells.css`, `auth.css`: 页面级样式
/// - `styles/components/main.css`: 核心组件样式(编辑器、侧栏、树、附件等)
/// - `styles/components/toast.css`: 全局 toast 与 portal 反馈样式
/// - `styles/components/dialog.css`: 全局居中 confirm / alert
/// - `styles/components/search.css`: 搜索弹窗组件
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
@@ -33,6 +34,8 @@ pub const MNOTE_CSS: &str = concat!(
"\n",
include_str!("styles/components/toast.css"),
"\n",
include_str!("styles/components/dialog.css"),
"\n",
include_str!("styles/components/search.css"),
"\n",
include_str!("styles/components/page-ai.css"),
@@ -153,6 +156,8 @@ mod tests {
assert!(MNOTE_CSS.contains("--wolai-state-focus-ring"));
assert!(MNOTE_CSS.contains(".mnote-toast-region"));
assert!(MNOTE_CSS.contains(".mnote-toast--success"));
assert!(MNOTE_CSS.contains(".mnote-dialog-backdrop"));
assert!(MNOTE_CSS.contains(".mnote-dialog__btn--primary"));
}
#[test]
@@ -0,0 +1,113 @@
/* 全局居中确认 / 提示(替代浏览器 confirm / alert */
.mnote-dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--wolai-z-modal, 1400);
display: flex;
align-items: center;
justify-content: center;
padding: var(--wolai-spacing-lg, 20px);
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(2px);
}
.mnote-dialog-backdrop[hidden] {
display: none !important;
}
.mnote-dialog {
width: min(400px, 100%);
border-radius: var(--wolai-radius-lg, 10px);
border: 1px solid var(--wolai-border, rgba(31, 35, 40, 0.12));
background: var(--wolai-bg, #ffffff);
box-shadow: var(--wolai-shadow-overlay, 0 20px 48px rgba(15, 23, 42, 0.22));
color: var(--wolai-text-primary, #1f2328);
overflow: hidden;
animation: mnote-dialog-in 140ms ease-out;
}
@keyframes mnote-dialog-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.mnote-dialog__body {
padding: 18px 18px 8px;
}
.mnote-dialog__title {
margin: 0 0 8px;
font: var(--mnote-font-weight-semibold, 600) var(--mnote-font-size-md, 14px) /
var(--mnote-line-height-normal, 1.4) var(--wolai-font-sans, system-ui, sans-serif);
color: var(--wolai-text-primary, #1f2328);
}
.mnote-dialog__message {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font: var(--mnote-font-weight-regular, 400) var(--mnote-font-size-sm, 13px) /
1.55 var(--wolai-font-sans, system-ui, sans-serif);
color: var(--wolai-text-secondary, #4b5563);
}
.mnote-dialog__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px;
}
.mnote-dialog__btn {
min-width: 72px;
min-height: 32px;
border-radius: var(--wolai-radius-md, 6px);
border: 1px solid var(--wolai-border, rgba(31, 35, 40, 0.12));
padding: 6px 14px;
background: var(--wolai-bg, #ffffff);
color: var(--wolai-text-primary, #1f2328);
font: var(--mnote-font-weight-medium, 500) var(--mnote-font-size-sm, 13px) /
1.2 var(--wolai-font-sans, system-ui, sans-serif);
cursor: pointer;
}
.mnote-dialog__btn:hover {
background: var(--wolai-bg-hover, #f6f7f9);
}
.mnote-dialog__btn:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 1px;
}
.mnote-dialog__btn--primary {
border-color: var(--wolai-primary, #2563eb);
background: var(--wolai-primary, #2563eb);
color: #ffffff;
}
.mnote-dialog__btn--primary:hover {
background: var(--wolai-primary-hover, #1d4ed8);
}
.mnote-dialog__btn--danger {
border-color: #dc2626;
background: #dc2626;
color: #ffffff;
}
.mnote-dialog__btn--danger:hover {
background: #b91c1c;
}
.mnote-dialog--danger .mnote-dialog__title {
color: #991b1b;
}
@@ -2699,14 +2699,14 @@
overflow: hidden;
}
.mnote-weknora-kb-page {
.mnote-krag-kb-page {
gap: 0;
padding: 0;
border-radius: 10px;
background: #F6F7F9;
}
.mnote-weknora-kb-page-header {
.mnote-krag-kb-page-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -2716,7 +2716,7 @@
background: #FFFFFF;
}
.mnote-weknora-kb-page-title {
.mnote-krag-kb-page-title {
display: flex;
min-width: 0;
flex-direction: row;
@@ -2724,7 +2724,7 @@
align-items: baseline;
}
.mnote-weknora-kb-page-eyebrow {
.mnote-krag-kb-page-eyebrow {
display: none;
color: #2F7D4A;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
@@ -2732,25 +2732,25 @@
text-transform: uppercase;
}
.mnote-weknora-kb-page-title strong {
.mnote-krag-kb-page-title strong {
color: #111827;
font-size: 17px;
line-height: 22px;
}
.mnote-weknora-kb-page-title span:last-child {
.mnote-krag-kb-page-title span:last-child {
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-kb-page-actions {
.mnote-krag-kb-page-actions {
display: flex;
gap: 8px;
align-items: center;
}
.mnote-weknora-kb-page-actions button {
.mnote-krag-kb-page-actions button {
display: inline-flex;
width: 32px;
height: 32px;
@@ -2763,12 +2763,12 @@
cursor: pointer;
}
.mnote-weknora-kb-page-actions button:hover:not(:disabled) {
.mnote-krag-kb-page-actions button:hover:not(:disabled) {
background: #F3F4F6;
color: #111827;
}
.mnote-weknora-kb-page-status {
.mnote-krag-kb-page-status {
padding: 6px 16px;
border-bottom: 1px solid #E5E7EB;
background: #F7FBF8;
@@ -2777,24 +2777,24 @@
line-height: 18px;
}
.mnote-weknora-kb-page-body {
.mnote-krag-kb-page-body {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
min-height: min(710px, calc(100vh - 122px));
max-height: calc(100vh - 96px);
}
.mnote-weknora-kb-page-body[data-kb-list-hidden="true"] {
.mnote-krag-kb-page-body[data-kb-list-hidden="true"] {
grid-template-columns: minmax(0, 1fr);
}
.mnote-weknora-kb-list-pane,
.mnote-weknora-kb-detail-pane {
.mnote-krag-kb-list-pane,
.mnote-krag-kb-detail-pane {
min-height: 0;
overflow: auto;
}
.mnote-weknora-kb-list-pane {
.mnote-krag-kb-list-pane {
display: flex;
flex-direction: column;
gap: 12px;
@@ -2803,31 +2803,31 @@
background: #FFFFFF;
}
.mnote-weknora-kb-list-pane[hidden] {
.mnote-krag-kb-list-pane[hidden] {
display: none;
}
.mnote-weknora-kb-list-head {
.mnote-krag-kb-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mnote-weknora-kb-list-head div {
.mnote-krag-kb-list-head div {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.mnote-weknora-kb-list-head strong {
.mnote-krag-kb-list-head strong {
color: #111827;
font-size: 14px;
line-height: 20px;
}
.mnote-weknora-kb-list-head span {
.mnote-krag-kb-list-head span {
min-width: 22px;
border-radius: 999px;
background: #EEF2FF;
@@ -2837,10 +2837,10 @@
text-align: center;
}
.mnote-weknora-kb-list-head button,
.mnote-krag-kb-list-head button,
.mnote-knowledge-rag-kb-manager button,
.mnote-weknora-doc-toolbar button,
.mnote-weknora-doc-actions button {
.mnote-krag-doc-toolbar button,
.mnote-krag-doc-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -2856,46 +2856,46 @@
padding: 0 10px;
}
.mnote-weknora-kb-list-head button {
.mnote-krag-kb-list-head button {
width: 30px;
padding: 0;
}
.mnote-weknora-kb-list-head button:hover,
.mnote-krag-kb-list-head button:hover,
.mnote-knowledge-rag-kb-manager button:hover:not(:disabled),
.mnote-weknora-doc-toolbar button:hover,
.mnote-weknora-doc-actions button:hover {
.mnote-krag-doc-toolbar button:hover,
.mnote-krag-doc-actions button:hover {
background: #F3F4F6;
color: #111827;
}
.mnote-knowledge-rag-kb-manager button:disabled,
.mnote-weknora-doc-toolbar button:disabled,
.mnote-weknora-doc-actions button:disabled {
.mnote-krag-doc-toolbar button:disabled,
.mnote-krag-doc-actions button:disabled {
opacity: .48;
cursor: not-allowed;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action {
.mnote-krag-doc-toolbar button.mnote-knowledge-rag-primary-action,
.mnote-krag-doc-actions button.mnote-knowledge-rag-primary-action {
border-color: #2F7D4A;
background: #2F7D4A;
color: #FFFFFF;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action:hover,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action:hover {
.mnote-krag-doc-toolbar button.mnote-knowledge-rag-primary-action:hover,
.mnote-krag-doc-actions button.mnote-knowledge-rag-primary-action:hover {
background: #24683C;
color: #FFFFFF;
}
.mnote-weknora-kb-card-wrap {
.mnote-krag-kb-card-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.mnote-weknora-kb-card {
.mnote-krag-kb-card {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
@@ -2910,13 +2910,13 @@
text-align: left;
}
.mnote-weknora-kb-card.is-active {
.mnote-krag-kb-card.is-active {
border-color: #2F7D4A;
background: #F0F9F4;
box-shadow: inset 3px 0 0 #2F7D4A;
}
.mnote-weknora-kb-card-star {
.mnote-krag-kb-card-star {
display: inline-flex;
width: 28px;
height: 28px;
@@ -2927,21 +2927,21 @@
color: #3730A3;
}
.mnote-weknora-kb-card-main {
.mnote-krag-kb-card-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.mnote-weknora-kb-card-main strong,
.mnote-weknora-kb-card-main em {
.mnote-krag-kb-card-main strong,
.mnote-krag-kb-card-main em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-weknora-kb-card-main strong {
.mnote-krag-kb-card-main strong {
color: #111827;
font-size: 13px;
font-style: normal;
@@ -2949,21 +2949,21 @@
line-height: 19px;
}
.mnote-weknora-kb-card-main em {
.mnote-krag-kb-card-main em {
color: #6B7280;
font-size: 11px;
font-style: normal;
line-height: 16px;
}
.mnote-weknora-kb-card-main span {
.mnote-krag-kb-card-main span {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.mnote-weknora-kb-card-main i {
.mnote-krag-kb-card-main i {
display: inline-flex;
align-items: center;
gap: 3px;
@@ -2973,16 +2973,16 @@
line-height: 16px;
}
.mnote-weknora-kb-card-main i.is-processing {
.mnote-krag-kb-card-main i.is-processing {
color: #B45309;
}
.mnote-weknora-kb-card-main .material-symbols-outlined {
.mnote-krag-kb-card-main .material-symbols-outlined {
font-size: 14px;
}
.mnote-weknora-kb-card-status,
.mnote-weknora-kb-type-pill {
.mnote-krag-kb-card-status,
.mnote-krag-kb-type-pill {
display: inline-flex;
align-items: center;
gap: 4px;
@@ -2995,7 +2995,7 @@
white-space: nowrap;
}
.mnote-weknora-kb-create-card {
.mnote-krag-kb-create-card {
display: flex;
flex-direction: column;
gap: 8px;
@@ -3005,11 +3005,11 @@
background: #F9FAFB;
}
.mnote-weknora-kb-create-card[hidden] {
.mnote-krag-kb-create-card[hidden] {
display: none;
}
.mnote-weknora-kb-create-title {
.mnote-krag-kb-create-title {
display: flex;
align-items: center;
gap: 6px;
@@ -3018,7 +3018,7 @@
line-height: 19px;
}
.mnote-weknora-kb-detail-pane {
.mnote-krag-kb-detail-pane {
display: flex;
flex-direction: column;
gap: 10px;
@@ -3026,7 +3026,7 @@
padding: 10px 14px 14px;
}
.mnote-weknora-kb-detail-hero {
.mnote-krag-kb-detail-hero {
order: 1;
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3038,22 +3038,22 @@
background: #FFFFFF;
}
.mnote-weknora-kb-detail-hero[hidden] {
.mnote-krag-kb-detail-hero[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero select[hidden] {
.mnote-krag-kb-detail-hero select[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero h2 {
.mnote-krag-kb-detail-hero h2 {
margin: 0 0 2px;
color: #111827;
font-size: 16px;
line-height: 22px;
}
.mnote-weknora-kb-breadcrumb {
.mnote-krag-kb-breadcrumb {
display: none;
align-items: center;
gap: 7px;
@@ -3063,7 +3063,7 @@
line-height: 18px;
}
.mnote-weknora-kb-breadcrumb strong {
.mnote-krag-kb-breadcrumb strong {
min-width: 0;
overflow: hidden;
color: #111827;
@@ -3072,14 +3072,14 @@
white-space: nowrap;
}
.mnote-weknora-kb-detail-hero p {
.mnote-krag-kb-detail-hero p {
margin: 0;
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-kb-type-pill {
.mnote-krag-kb-type-pill {
display: none;
}
@@ -3323,15 +3323,15 @@
background: #F3F4F6;
}
.mnote-weknora-doc-toolbar,
.mnote-weknora-doc-actions {
.mnote-krag-doc-toolbar,
.mnote-krag-doc-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.mnote-weknora-doc-layout {
.mnote-krag-doc-layout {
display: flex;
min-height: 0;
flex: 1 1 auto;
@@ -3339,7 +3339,7 @@
gap: 10px;
}
.mnote-weknora-doc-sidebar {
.mnote-krag-doc-sidebar {
display: flex;
flex-direction: column;
gap: 10px;
@@ -3350,38 +3350,38 @@
background: #FFFFFF;
}
.mnote-weknora-doc-sidebar[hidden] {
.mnote-krag-doc-sidebar[hidden] {
display: none;
}
.mnote-weknora-doc-sidebar-head {
.mnote-krag-doc-sidebar-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.mnote-weknora-doc-sidebar-head strong {
.mnote-krag-doc-sidebar-head strong {
color: #111827;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-doc-sidebar-head span {
.mnote-krag-doc-sidebar-head span {
color: #9CA3AF;
font-size: 10px;
line-height: 14px;
}
.mnote-weknora-doc-source-tags,
.mnote-weknora-doc-tags {
.mnote-krag-doc-source-tags,
.mnote-krag-doc-tags {
display: flex;
flex-direction: column;
gap: 6px;
}
.mnote-weknora-doc-source-tags button,
.mnote-weknora-doc-tags span {
.mnote-krag-doc-source-tags button,
.mnote-krag-doc-tags span {
display: inline-flex;
align-items: center;
gap: 7px;
@@ -3396,21 +3396,21 @@
text-align: left;
}
.mnote-weknora-doc-source-tags button {
.mnote-krag-doc-source-tags button {
cursor: pointer;
}
.mnote-weknora-doc-source-tags button[data-active="true"] {
.mnote-krag-doc-source-tags button[data-active="true"] {
border-color: #A7D8B8;
background: #F0F9F4;
color: #166534;
}
.mnote-weknora-doc-source-tags .material-symbols-outlined {
.mnote-krag-doc-source-tags .material-symbols-outlined {
font-size: 16px;
}
.mnote-weknora-doc-main {
.mnote-krag-doc-main {
display: flex;
flex: 1 1 auto;
min-height: 0;
@@ -3419,7 +3419,7 @@
gap: 10px;
}
.mnote-weknora-doc-toolbar {
.mnote-krag-doc-toolbar {
position: sticky;
top: 0;
z-index: 1;
@@ -3427,7 +3427,7 @@
background: #F6F7F9;
}
.mnote-weknora-view-switch {
.mnote-krag-view-switch {
display: inline-flex;
align-items: center;
border: 1px solid #E5E7EB;
@@ -3436,7 +3436,7 @@
padding: 2px;
}
.mnote-weknora-view-switch button {
.mnote-krag-view-switch button {
width: 28px;
min-height: 26px;
border: 0;
@@ -3446,12 +3446,12 @@
padding: 0;
}
.mnote-weknora-view-switch button.is-active {
.mnote-krag-view-switch button.is-active {
background: #EAF7EA;
color: #166534;
}
.mnote-weknora-doc-search {
.mnote-krag-doc-search {
display: flex;
flex: 1 1 260px;
min-width: 220px;
@@ -3464,7 +3464,7 @@
background: #FFFFFF;
}
.mnote-weknora-doc-search input {
.mnote-krag-doc-search input {
min-width: 0;
flex: 1;
border: 0;
@@ -3474,7 +3474,7 @@
font-size: 12px;
}
.mnote-weknora-doc-table {
.mnote-krag-doc-table {
flex: 1 1 auto;
min-height: 440px;
overflow: auto;
@@ -3483,14 +3483,14 @@
background: #FFFFFF;
}
.mnote-weknora-doc-table[data-weknora-document-view="grid"] {
.mnote-krag-doc-table[data-krag-document-view="grid"] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
padding: 10px;
}
.mnote-weknora-doc-table::before {
.mnote-krag-doc-table::before {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
@@ -3504,11 +3504,11 @@
white-space: pre;
}
.mnote-weknora-doc-table[data-weknora-document-view="grid"]::before {
.mnote-krag-doc-table[data-krag-document-view="grid"]::before {
display: none;
}
.mnote-weknora-placeholder-panel {
.mnote-krag-placeholder-panel {
display: flex;
min-height: 220px;
flex-direction: column;
@@ -3521,13 +3521,13 @@
color: #6B7280;
}
.mnote-weknora-placeholder-panel strong {
.mnote-krag-placeholder-panel strong {
color: #111827;
font-size: 16px;
line-height: 24px;
}
.mnote-weknora-placeholder-panel span {
.mnote-krag-placeholder-panel span {
max-width: 560px;
font-size: 12px;
line-height: 19px;
@@ -3591,7 +3591,7 @@
line-height: 18px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -3600,7 +3600,7 @@
overflow: hidden;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card div {
border: 0;
border-right: 1px solid #E5E7EB;
border-radius: 0;
@@ -3608,7 +3608,7 @@
padding: 10px 12px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div:last-child {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card div:last-child {
border-right: 0;
}
@@ -3753,14 +3753,14 @@
white-space: nowrap;
}
.mnote-knowledge-rag-source-main .mnote-weknora-source-aux {
.mnote-knowledge-rag-source-main .mnote-krag-source-aux {
display: none;
flex-wrap: wrap;
gap: 6px;
font-family: inherit;
}
.mnote-weknora-source-aux i {
.mnote-krag-source-aux i {
display: inline-flex;
max-width: 180px;
overflow: hidden;
@@ -3839,18 +3839,18 @@
}
@media (max-width: 900px) {
.mnote-weknora-kb-page-body,
.mnote-weknora-kb-detail-hero,
.mnote-weknora-doc-layout {
.mnote-krag-kb-page-body,
.mnote-krag-kb-detail-hero,
.mnote-krag-doc-layout {
grid-template-columns: 1fr;
}
.mnote-weknora-kb-list-pane {
.mnote-krag-kb-list-pane {
border-right: 0;
border-bottom: 1px solid #E5E7EB;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -1034,9 +1034,18 @@
height: 28px;
}
.mnote-vault-urls-view .mnote-vault-field-value a {
.mnote-vault-urls-view .mnote-vault-field-value a,
.mnote-vault-field-value a.mnote-vault-url-link,
a.mnote-vault-url-link {
color: #2383e2;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
cursor: pointer;
}
a.mnote-vault-url-link:hover {
color: #0b6bcb;
}
.mnote-vault-slot-group {
@@ -29,7 +29,8 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
rows.iter()
.map(|row| {
let test_id = match row.row_kind.as_str() {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
// markdown 与 document 同属文档行(command_document_id 已同等对待)
"document" | "markdown" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
};
@@ -49,7 +50,7 @@ fn escape_html(input: &str) -> String {
fn row_test_id(row_kind: &str) -> &'static str {
match row_kind {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
"document" | "markdown" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
}
@@ -223,22 +223,28 @@ impl FileTreeRuntimeState {
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
// 无有效行:清拖拽态后返回
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
// 目标无效:保留拖拽态,允许用户继续尝试
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
// 目标有效后再清拖拽态
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(
state,
[
@@ -257,18 +263,22 @@ impl FileTreeRuntimeState {
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
// 目标无效:保留拖拽态
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(
state,
[
@@ -214,8 +214,9 @@ impl PageTreeRuntimeState {
)],
),
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
let node_id = node_id.trim().to_string();
let title = title.trim().to_string();
if node_id.trim().is_empty() || title.is_empty() {
if node_id.is_empty() || title.is_empty() {
return transition(self.clone(), []);
}
transition(
@@ -51,11 +51,21 @@ fn render_picker_row(
row: &PickerRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
) {
// aria-expanded 仅出现在可展开节点;叶子节点省略,符合 WAI-ARIA。
let expanded_attr = if row.expandable {
if row.expanded {
r#" aria-expanded="true""#
} else {
r#" aria-expanded="false""#
}
} else {
""
};
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}"{expanded_attr} data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
expanded_attr = expanded_attr,
active = row.active,
tabindex = if row.active { "0" } else { "-1" },
title = escape_html(&row.title),