feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup
Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token read path design, vault transport split, and retire obsolete filetree smokes. Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
Generated
+25
@@ -2388,6 +2388,30 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mnote-vault"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"mnote-vault-core",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mnote-vault-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"hex",
|
||||
"hmac",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mnote-web"
|
||||
version = "0.1.0"
|
||||
@@ -2404,6 +2428,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"leptos",
|
||||
"mnote-editor-core",
|
||||
"mnote-vault-core",
|
||||
"notify",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
|
||||
@@ -8,6 +8,8 @@ members = [
|
||||
"crates/event-log",
|
||||
"crates/mnote-editor-core",
|
||||
"crates/mnote-cli",
|
||||
"crates/mnote-vault-core",
|
||||
"crates/mnote-vault",
|
||||
"crates/mnote-web",
|
||||
"crates/index-fts",
|
||||
"crates/tree-shell-runtime-wasm",
|
||||
|
||||
@@ -813,6 +813,17 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
||||
// 登录成功后对齐同用户全部 password_* identity 的哈希。
|
||||
// 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。
|
||||
let now = now_text();
|
||||
let _ = conn.execute(
|
||||
"UPDATE auth_identities
|
||||
SET password_hash = ?1, updated_at = ?2
|
||||
WHERE user_id = ?3
|
||||
AND provider IN ('password_username', 'password_email')
|
||||
AND password_hash != ?1",
|
||||
params![expected_hash, now, user.id],
|
||||
)?;
|
||||
drop(conn);
|
||||
|
||||
let session = self.create_session(CreateSessionInput {
|
||||
@@ -4869,6 +4880,50 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_password_syncs_sibling_password_identity_hashes() {
|
||||
let store = store();
|
||||
let current_password = ["current", "secret"].join("-");
|
||||
let stale_password = ["stale", "secret"].join("-");
|
||||
create_password_identity(&store, "dana", "dana@example.com", ¤t_password);
|
||||
|
||||
// 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。
|
||||
{
|
||||
let conn = store.lock_conn().expect("lock");
|
||||
conn.execute(
|
||||
"UPDATE auth_identities
|
||||
SET password_hash = ?1
|
||||
WHERE provider = 'password_username' AND provider_subject = 'dana'",
|
||||
params![password_hash_v1(&stale_password)],
|
||||
)
|
||||
.expect("stale username hash");
|
||||
}
|
||||
|
||||
store
|
||||
.authenticate_password(AuthenticatePasswordInput {
|
||||
account: "dana@example.com".to_string(),
|
||||
password: current_password.clone(),
|
||||
session_id: None,
|
||||
token_hash: session_token_hash("dana-email-repair"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("email login should still work");
|
||||
|
||||
store
|
||||
.authenticate_password(AuthenticatePasswordInput {
|
||||
account: "dana".to_string(),
|
||||
password: current_password,
|
||||
session_id: None,
|
||||
token_hash: session_token_hash("dana-username-after-sync"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("username login should work after sibling hash sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_tool_events_append_and_list() {
|
||||
let store = store();
|
||||
|
||||
@@ -1337,6 +1337,17 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
|
||||
// 登录成功后对齐同用户全部 password_* identity 的哈希。
|
||||
// 避免仅更新邮箱 identity 后,用户名登录仍用旧密码(liaibo 线上漂移过)。
|
||||
let now = now_text();
|
||||
let _ = conn.execute(
|
||||
"UPDATE auth_identities
|
||||
SET password_hash = ?1, updated_at = ?2
|
||||
WHERE user_id = ?3
|
||||
AND provider IN ('password_username', 'password_email')
|
||||
AND password_hash != ?1",
|
||||
params![expected_hash, now, user.id],
|
||||
)?;
|
||||
drop(conn);
|
||||
|
||||
let session = self.create_session(CreateSessionInput {
|
||||
@@ -5235,6 +5246,50 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_password_syncs_sibling_password_identity_hashes() {
|
||||
let store = store();
|
||||
let current_password = ["current", "secret"].join("-");
|
||||
let stale_password = ["stale", "secret"].join("-");
|
||||
create_password_identity(&store, "dana", "dana@example.com", ¤t_password);
|
||||
|
||||
// 模拟仅邮箱 identity 被改密、用户名 identity 仍是旧哈希的漂移。
|
||||
{
|
||||
let conn = store.lock_conn().expect("lock");
|
||||
conn.execute(
|
||||
"UPDATE auth_identities
|
||||
SET password_hash = ?1
|
||||
WHERE provider = 'password_username' AND provider_subject = 'dana'",
|
||||
params![password_hash_v1(&stale_password)],
|
||||
)
|
||||
.expect("stale username hash");
|
||||
}
|
||||
|
||||
store
|
||||
.authenticate_password(AuthenticatePasswordInput {
|
||||
account: "dana@example.com".to_string(),
|
||||
password: current_password.clone(),
|
||||
session_id: None,
|
||||
token_hash: session_token_hash("dana-email-repair"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("email login should still work");
|
||||
|
||||
store
|
||||
.authenticate_password(AuthenticatePasswordInput {
|
||||
account: "dana".to_string(),
|
||||
password: current_password,
|
||||
session_id: None,
|
||||
token_hash: session_token_hash("dana-username-after-sync"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("username login should work after sibling hash sync");
|
||||
}
|
||||
|
||||
// --- Fault injection tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "mnote-vault-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Local-first password vault store + agent token (no mnote-web)"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
@@ -0,0 +1,899 @@
|
||||
//! AI password-book path (list / get / resolve / login / session) without mnote-web.
|
||||
|
||||
use crate::error::VaultError;
|
||||
use crate::store::{
|
||||
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
|
||||
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
|
||||
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
|
||||
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
|
||||
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
||||
};
|
||||
use crate::token::DEFAULT_ACTOR;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DEFAULT_DATA_BASE: &str = "/mnt/Data1T/Mnote_data";
|
||||
|
||||
pub fn ai_vault_actor_id() -> String {
|
||||
std::env::var("MNOTE_AI_VAULT_ACTOR")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_ACTOR.to_string())
|
||||
}
|
||||
|
||||
/// Encode actor id for managed path segment (aligned with mnote-web).
|
||||
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(),
|
||||
other => format!("~{:02x}", other as u32),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn managed_data_base() -> PathBuf {
|
||||
std::env::var("MNOTE_DATA_DIR")
|
||||
.or_else(|_| std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_BASE))
|
||||
}
|
||||
|
||||
pub fn ai_vault_workspace_root() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("MNOTE_VAULT_WORKSPACE") {
|
||||
let p = p.trim();
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let actor = encode_actor_segment(&ai_vault_actor_id());
|
||||
managed_data_base()
|
||||
.join("users")
|
||||
.join(actor)
|
||||
.join("workspaces")
|
||||
.join("my-space")
|
||||
}
|
||||
|
||||
pub fn ensure_ai_vault_workspace() -> Result<PathBuf, VaultError> {
|
||||
let root = ai_vault_workspace_root();
|
||||
if !root.exists() {
|
||||
std::fs::create_dir_all(root.join(".mnote")).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_ai_root_unavailable",
|
||||
format!("无法创建 AI 密码本工作区 {}: {e}", root.display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_ai_root_unavailable",
|
||||
format!("无法访问 AI 密码本工作区: {e}"),
|
||||
)
|
||||
})?;
|
||||
let _ = ensure_vault_directories(&root)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn normalize_secret_field(field: &str) -> Result<&'static str, VaultError> {
|
||||
match field.trim() {
|
||||
"password" => Ok("password"),
|
||||
"apikey" | "apiKey" | "api_key" => Ok("apikey"),
|
||||
"token" => Ok("token"),
|
||||
"username" => Ok("username"),
|
||||
"email" => Ok("email"),
|
||||
other => Err(VaultError::bad_request_code(
|
||||
"vault_resolve_field_invalid",
|
||||
format!("不支持字段: {other};允许 password|apikey|token|username|email"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, VaultError> {
|
||||
let root = ensure_ai_vault_workspace()?;
|
||||
let (index, items) = list_credentials(&root, status)?;
|
||||
let book = load_cipher_book(&root).ok();
|
||||
let book_ref = book.as_ref();
|
||||
let projected: Vec<Value> = items
|
||||
.iter()
|
||||
.map(|e| project_list_entry_with_cipher(e, book_ref))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"schema": "mnote.vault.list.v1",
|
||||
"status": status.as_str(),
|
||||
"revision": index.revision,
|
||||
"updatedAt": index.updated_at,
|
||||
"items": projected,
|
||||
"isAiVault": true,
|
||||
"aiVaultActorId": ai_vault_actor_id(),
|
||||
"vaultRole": "ai",
|
||||
"transport": "local-core",
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn get_ai_vault_item(credential_id: &str) -> Result<Value, VaultError> {
|
||||
let root = ensure_ai_vault_workspace()?;
|
||||
let record = get_credential(&root, credential_id)?;
|
||||
Ok(json!({
|
||||
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
||||
"isAiVault": true,
|
||||
"aiVaultActorId": ai_vault_actor_id(),
|
||||
"transport": "local-core",
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn resolve_ai_vault_secret(
|
||||
credential_id: &str,
|
||||
field: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
secret_id: Option<&str>,
|
||||
) -> Result<Value, VaultError> {
|
||||
let field_norm = normalize_secret_field(field)?;
|
||||
let account_id = account_id.map(str::trim).filter(|v| !v.is_empty());
|
||||
let secret_id = secret_id.map(str::trim).filter(|v| !v.is_empty());
|
||||
let root = ensure_ai_vault_workspace()?;
|
||||
let record = match get_credential(&root, credential_id) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"resolve",
|
||||
actor,
|
||||
credential_id,
|
||||
Some(field_norm),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if record.status != VaultItemStatus::Active {
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"resolve",
|
||||
actor,
|
||||
credential_id,
|
||||
Some(field_norm),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_resolve_inactive",
|
||||
"只能 resolve 在用条目",
|
||||
));
|
||||
}
|
||||
let secret = resolve_record_secret_field(&record, field_norm, account_id, secret_id);
|
||||
let secret_proj = match project_secret_revealed(&root, secret) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"resolve",
|
||||
actor,
|
||||
credential_id,
|
||||
Some(field_norm),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"resolve",
|
||||
actor,
|
||||
credential_id,
|
||||
Some(field_norm),
|
||||
request_id,
|
||||
true,
|
||||
);
|
||||
let value = secret_proj
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let state = secret_proj
|
||||
.get("state")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("absent");
|
||||
let mut out = json!({
|
||||
"schema": "mnote.vault.resolve.v1",
|
||||
"id": credential_id,
|
||||
"field": field_norm,
|
||||
"resolved": state == "revealed",
|
||||
"state": state,
|
||||
"value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null },
|
||||
"usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])),
|
||||
"missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])),
|
||||
"template": secret_proj.get("template").cloned().unwrap_or(Value::Null),
|
||||
"aiVaultActorId": ai_vault_actor_id(),
|
||||
"transcriptHint": format!("已解析 {field_norm}"),
|
||||
"note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG",
|
||||
"transport": "local-core",
|
||||
});
|
||||
if let Some(aid) = account_id {
|
||||
out["accountId"] = json!(aid);
|
||||
}
|
||||
if let Some(sid) = secret_id {
|
||||
out["secretId"] = json!(sid);
|
||||
}
|
||||
let _ = store::vault_root(Path::new(&root));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn default_session_expires_hours() -> i64 {
|
||||
std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.filter(|h| *h > 0)
|
||||
.unwrap_or(168) // 7 days
|
||||
}
|
||||
|
||||
fn session_expires_rfc3339_from_now() -> String {
|
||||
let hours = default_session_expires_hours();
|
||||
let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours);
|
||||
t.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| now_rfc3339())
|
||||
}
|
||||
|
||||
fn origin_from_credential_url(url: Option<&str>) -> Result<String, VaultError> {
|
||||
let raw = url
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_login_url_required",
|
||||
"条目缺少 url,无法推导登录 origin",
|
||||
)
|
||||
})?;
|
||||
let with_scheme = if raw.contains("://") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("https://{raw}")
|
||||
};
|
||||
let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_login_url_invalid",
|
||||
format!("无法解析 url: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(port) = parsed.port() {
|
||||
return Ok(format!(
|
||||
"{}://{}:{}",
|
||||
parsed.scheme(),
|
||||
parsed.host_str().unwrap_or(""),
|
||||
port
|
||||
));
|
||||
}
|
||||
let origin = format!(
|
||||
"{}://{}",
|
||||
parsed.scheme(),
|
||||
parsed.host_str().unwrap_or("")
|
||||
);
|
||||
if origin.ends_with("://") {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_url_invalid",
|
||||
"url 缺少 host",
|
||||
));
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn pick_login_account(
|
||||
workspace_root: &Path,
|
||||
record: &VaultCredentialRecord,
|
||||
preferred: &str,
|
||||
) -> String {
|
||||
let pref = preferred.trim().to_ascii_lowercase();
|
||||
let expand = |raw: Option<&str>| -> Option<String> {
|
||||
let t = raw.map(str::trim).filter(|s| !s.is_empty())?;
|
||||
match resolve_secret_with_cipher_book(workspace_root, Some(t)) {
|
||||
Ok(Some(resolved)) => {
|
||||
let v = resolved.value.trim().to_string();
|
||||
if v.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
_ => Some(t.to_string()),
|
||||
}
|
||||
};
|
||||
let email = expand(record.email.as_deref()).or_else(|| {
|
||||
record
|
||||
.accounts
|
||||
.first()
|
||||
.and_then(|a| expand(a.email.as_deref()))
|
||||
});
|
||||
let user = expand(record.username.as_deref()).or_else(|| {
|
||||
record
|
||||
.accounts
|
||||
.first()
|
||||
.and_then(|a| expand(a.username.as_deref()))
|
||||
});
|
||||
match pref.as_str() {
|
||||
"email" => email.or(user).unwrap_or_default(),
|
||||
"username" => user.or(email).unwrap_or_default(),
|
||||
_ => email.or(user).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String {
|
||||
use std::collections::BTreeMap;
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for part in existing.split(';') {
|
||||
let p = part.trim();
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = p.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
for sc in set_cookies {
|
||||
let first = sc.split(';').next().unwrap_or("").trim();
|
||||
if let Some((k, v)) = first.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
map.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// Write browser-captured cookies into AI password book (no mnote-web).
|
||||
pub fn put_ai_vault_session(
|
||||
credential_id: &str,
|
||||
cookie_header: &str,
|
||||
expires_at: Option<&str>,
|
||||
source: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, VaultError> {
|
||||
let root = ensure_ai_vault_workspace()?;
|
||||
let cookie = cookie_header.trim();
|
||||
if cookie.is_empty() {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_session_cookie_required",
|
||||
"cookieHeader 不能为空",
|
||||
));
|
||||
}
|
||||
let now = time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| now_rfc3339());
|
||||
let expires = expires_at
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(session_expires_rfc3339_from_now);
|
||||
let session = VaultLoginSession {
|
||||
cookie_header: Some(cookie.to_string()),
|
||||
expires_at: Some(expires.clone()),
|
||||
last_login_at: Some(now),
|
||||
source: Some(source.trim().to_string()),
|
||||
};
|
||||
let record = put_login_session(&root, credential_id, session)?;
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"session_put",
|
||||
actor,
|
||||
credential_id,
|
||||
Some(source),
|
||||
request_id,
|
||||
true,
|
||||
);
|
||||
Ok(json!({
|
||||
"schema": "mnote.vault.session.v1",
|
||||
"id": credential_id,
|
||||
"ok": true,
|
||||
"hasLoginSession": true,
|
||||
"expiresAt": expires,
|
||||
"source": source,
|
||||
"item": project_item_l0_with_cipher(&record, Some(&root)),
|
||||
"transcriptHint": "已回写登录态",
|
||||
"transport": "local-core",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Local login against AI password book (no mnote-web):
|
||||
/// reuse fresh session → else api_first password login → save session.
|
||||
/// Cloudflare / captcha → human_required (agent uses browser tools then `session`).
|
||||
pub fn login_ai_vault_credential(
|
||||
credential_id: &str,
|
||||
force_refresh: bool,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, VaultError> {
|
||||
let root = ensure_ai_vault_workspace()?;
|
||||
let mut record = get_credential(&root, credential_id)?;
|
||||
if record.status != VaultItemStatus::Active {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_inactive",
|
||||
"只能登录在用条目",
|
||||
));
|
||||
}
|
||||
|
||||
if record.login_playbook.is_none() {
|
||||
let pb = VaultLoginPlaybook::default_for_credential(
|
||||
record.email.as_deref(),
|
||||
record.username.as_deref(),
|
||||
);
|
||||
record = put_login_playbook(&root, credential_id, pb)?;
|
||||
}
|
||||
let playbook = record.login_playbook.clone().unwrap_or_else(|| {
|
||||
VaultLoginPlaybook::default_for_credential(
|
||||
record.email.as_deref(),
|
||||
record.username.as_deref(),
|
||||
)
|
||||
});
|
||||
|
||||
if playbook.is_human_required() && !force_refresh {
|
||||
if let Some(sess) = record.login_session.as_ref() {
|
||||
if login_session_is_fresh(sess) {
|
||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_reuse",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("session"),
|
||||
request_id,
|
||||
true,
|
||||
);
|
||||
return Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": true,
|
||||
"mode": "session",
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": sess.expires_at,
|
||||
"loginPlaybook": playbook,
|
||||
"transcriptHint": "已复用登录态",
|
||||
"note": "playbook=human_required;有可用 session 直接复用",
|
||||
"transport": "local-core",
|
||||
}));
|
||||
}
|
||||
}
|
||||
return Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": false,
|
||||
"mode": "human_required",
|
||||
"ok": false,
|
||||
"code": "vault_login_human_required",
|
||||
"message": "需要人完成验证码/Cloudflare 后回写登录态",
|
||||
"loginPlaybook": playbook,
|
||||
"humanInstructions": {
|
||||
"local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 mnote-vault session --id <id> --cookie-header '…'",
|
||||
"remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写",
|
||||
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||
},
|
||||
"transcriptHint": "需要人工验证后回写 session",
|
||||
"transport": "local-core",
|
||||
}));
|
||||
}
|
||||
|
||||
if !force_refresh {
|
||||
if let Some(sess) = record.login_session.as_ref() {
|
||||
if login_session_is_fresh(sess) {
|
||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_reuse",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("session"),
|
||||
request_id,
|
||||
true,
|
||||
);
|
||||
return Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": true,
|
||||
"mode": "session",
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": sess.expires_at,
|
||||
"source": sess.source,
|
||||
"loginPlaybook": {
|
||||
"mode": playbook.mode,
|
||||
"preferredAccount": playbook.preferred_account,
|
||||
},
|
||||
"accountHint": pick_login_account(&root, &record, &playbook.preferred_account),
|
||||
"url": record.url,
|
||||
"transcriptHint": "已复用登录态",
|
||||
"transport": "local-core",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let password_resolved = resolve_ai_vault_secret(
|
||||
credential_id,
|
||||
"password",
|
||||
actor,
|
||||
request_id,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
if password_resolved
|
||||
.get("resolved")
|
||||
.and_then(Value::as_bool)
|
||||
!= Some(true)
|
||||
{
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_no_password",
|
||||
"条目无可用 password,无法自动登录",
|
||||
));
|
||||
}
|
||||
let password = password_resolved
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let account = pick_login_account(&root, &record, &playbook.preferred_account);
|
||||
if account.is_empty() {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_no_account",
|
||||
"条目缺少 email/username",
|
||||
));
|
||||
}
|
||||
|
||||
let mode = playbook.mode.trim().to_ascii_lowercase();
|
||||
if mode == "browser" {
|
||||
return Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": false,
|
||||
"mode": "browser",
|
||||
"ok": false,
|
||||
"code": "vault_login_browser_required",
|
||||
"message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写",
|
||||
"loginPlaybook": playbook,
|
||||
"account": account,
|
||||
"passwordResolved": true,
|
||||
"url": record.url,
|
||||
"transcriptHint": "需要浏览器按 playbook 登录后回写 session",
|
||||
"humanInstructions": {
|
||||
"local": "chrome-bridge 登录后 mnote-vault session",
|
||||
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||
},
|
||||
"transport": "local-core",
|
||||
}));
|
||||
}
|
||||
|
||||
// api_first (default) — outbound HTTP only; does not need mnote-web.
|
||||
let origin = origin_from_credential_url(record.url.as_deref())?;
|
||||
let api_path = playbook
|
||||
.api_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("/api/auth");
|
||||
let login_url = if api_path.starts_with("http") {
|
||||
api_path.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}{}",
|
||||
origin.trim_end_matches('/'),
|
||||
if api_path.starts_with('/') {
|
||||
api_path.to_string()
|
||||
} else {
|
||||
format!("/{api_path}")
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"action": "auth:signIn",
|
||||
"args": {
|
||||
"provider": "password",
|
||||
"params": {
|
||||
"password": password,
|
||||
"flow": "signIn",
|
||||
"account": account,
|
||||
"email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null },
|
||||
"name": if account.contains('@') {
|
||||
account.split('@').next().unwrap_or(&account)
|
||||
} else {
|
||||
account.as_str()
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
VaultError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}"))
|
||||
})?;
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("cloudflare")
|
||||
|| msg.to_ascii_lowercase().contains("403")
|
||||
{
|
||||
VaultError::bad_request_code(
|
||||
"vault_login_human_required",
|
||||
format!("登录请求失败,可能需人机验证: {e}"),
|
||||
)
|
||||
} else {
|
||||
VaultError::bad_request_code(
|
||||
"vault_login_http_failed",
|
||||
format!("登录请求失败: {e}"),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let set_cookies: Vec<String> = response
|
||||
.headers()
|
||||
.get_all(reqwest::header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok().map(str::to_string))
|
||||
.collect();
|
||||
let resp_text = response.text().unwrap_or_default();
|
||||
|
||||
let lower = resp_text.to_ascii_lowercase();
|
||||
if status.as_u16() == 403
|
||||
|| lower.contains("cloudflare")
|
||||
|| lower.contains("cf-challenge")
|
||||
|| lower.contains("captcha")
|
||||
|| lower.contains("just a moment")
|
||||
{
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_challenge",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("human"),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": false,
|
||||
"mode": "human_required",
|
||||
"ok": false,
|
||||
"code": "vault_login_human_required",
|
||||
"message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session",
|
||||
"httpStatus": status.as_u16(),
|
||||
"loginPlaybook": playbook,
|
||||
"url": record.url,
|
||||
"transcriptHint": "需要人工验证后回写 session",
|
||||
"humanInstructions": {
|
||||
"local": "chrome-bridge 完成验证登录 → mnote-vault session",
|
||||
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
|
||||
},
|
||||
"transport": "local-core",
|
||||
}));
|
||||
}
|
||||
|
||||
let mut json_ok = false;
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&resp_text) {
|
||||
if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) {
|
||||
json_ok = true;
|
||||
}
|
||||
if let Some(err) = v.get("error").and_then(Value::as_str) {
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_failed",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("api"),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_rejected",
|
||||
format!("远端登录拒绝: {err}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !status.is_success() && !json_ok && set_cookies.is_empty() {
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_failed",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("api"),
|
||||
request_id,
|
||||
false,
|
||||
);
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_rejected",
|
||||
format!("远端登录失败 HTTP {}", status.as_u16()),
|
||||
));
|
||||
}
|
||||
|
||||
let cookie_header = merge_set_cookie_into_header("", &set_cookies);
|
||||
if cookie_header.is_empty() {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_login_no_cookie",
|
||||
"登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写",
|
||||
));
|
||||
}
|
||||
|
||||
let now = time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "now".into());
|
||||
let expires = session_expires_rfc3339_from_now();
|
||||
let session = VaultLoginSession {
|
||||
cookie_header: Some(cookie_header.clone()),
|
||||
expires_at: Some(expires.clone()),
|
||||
last_login_at: Some(now),
|
||||
source: Some("api".into()),
|
||||
};
|
||||
let _ = put_login_session(&root, credential_id, session)?;
|
||||
let _ = append_vault_audit(
|
||||
&root,
|
||||
"login_ok",
|
||||
actor,
|
||||
credential_id,
|
||||
Some("api"),
|
||||
request_id,
|
||||
true,
|
||||
);
|
||||
|
||||
Ok(json!({
|
||||
"schema": "mnote.vault.login.v1",
|
||||
"id": credential_id,
|
||||
"reused": false,
|
||||
"mode": "api",
|
||||
"ok": true,
|
||||
"cookieHeader": cookie_header,
|
||||
"expiresAt": expires,
|
||||
"account": account,
|
||||
"url": record.url,
|
||||
"loginPlaybook": {
|
||||
"mode": playbook.mode,
|
||||
"preferredAccount": playbook.preferred_account,
|
||||
},
|
||||
"transcriptHint": "已登录并保存登录态",
|
||||
"note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh",
|
||||
"transport": "local-core",
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{
|
||||
create_credential, purge_credential, soft_delete_credential, VaultCreateInput,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static AI_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_temp_workspace<F, R>(f: F) -> R
|
||||
where
|
||||
F: FnOnce(&Path) -> R,
|
||||
{
|
||||
let _guard = AI_TEST_LOCK.lock().unwrap();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!("mnote-vault-ai-session-{nanos}"));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::env::set_var("MNOTE_VAULT_WORKSPACE", &root);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(root.as_path())));
|
||||
std::env::remove_var("MNOTE_VAULT_WORKSPACE");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_session_then_login_reuses_without_http() {
|
||||
with_temp_workspace(|root| {
|
||||
let created = create_credential(
|
||||
root,
|
||||
VaultCreateInput {
|
||||
title: "session-reuse-core".into(),
|
||||
url: Some("https://example.com/auth".into()),
|
||||
email: Some("a@example.com".into()),
|
||||
password: Some("secret-pass".into()),
|
||||
tags: vec!["ai-shared".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("create");
|
||||
let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap();
|
||||
let put = put_ai_vault_session(
|
||||
&created.id,
|
||||
"mnote_session=abc; other=1",
|
||||
Some(&expires),
|
||||
"human_bridge",
|
||||
"tester",
|
||||
Some("req-session"),
|
||||
)
|
||||
.expect("session put");
|
||||
assert_eq!(put["ok"], true);
|
||||
assert_eq!(put["hasLoginSession"], true);
|
||||
assert_eq!(put["transport"], "local-core");
|
||||
|
||||
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
||||
.expect("login reuse");
|
||||
assert_eq!(login["reused"], true);
|
||||
assert_eq!(login["mode"], "session");
|
||||
assert_eq!(login["cookieHeader"], "mnote_session=abc; other=1");
|
||||
assert_eq!(login["transport"], "local-core");
|
||||
|
||||
let _ = soft_delete_credential(root, &created.id);
|
||||
let _ = purge_credential(root, &created.id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_required_without_session_returns_instructions() {
|
||||
with_temp_workspace(|root| {
|
||||
let created = create_credential(
|
||||
root,
|
||||
VaultCreateInput {
|
||||
title: "human-required-core".into(),
|
||||
url: Some("https://example.com/login".into()),
|
||||
email: Some("h@example.com".into()),
|
||||
password: Some("pw".into()),
|
||||
tags: vec!["ai-shared".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("create");
|
||||
let mut pb = VaultLoginPlaybook::default_for_credential(Some("h@example.com"), None);
|
||||
pb.mode = "human_required".into();
|
||||
let _ = put_login_playbook(root, &created.id, pb).expect("playbook");
|
||||
|
||||
let login = login_ai_vault_credential(&created.id, false, "tester", None)
|
||||
.expect("login human");
|
||||
assert_eq!(login["reused"], false);
|
||||
assert_eq!(login["mode"], "human_required");
|
||||
assert_eq!(login["ok"], false);
|
||||
assert_eq!(login["code"], "vault_login_human_required");
|
||||
assert!(login["humanInstructions"]["cli"].as_str().unwrap().contains(&created.id));
|
||||
|
||||
let _ = soft_delete_credential(root, &created.id);
|
||||
let _ = purge_credential(root, &created.id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_session_rejects_empty_cookie() {
|
||||
with_temp_workspace(|root| {
|
||||
let created = create_credential(
|
||||
root,
|
||||
VaultCreateInput {
|
||||
title: "empty-cookie".into(),
|
||||
email: Some("e@example.com".into()),
|
||||
password: Some("pw".into()),
|
||||
tags: vec!["ai-shared".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("create");
|
||||
let err = put_ai_vault_session(&created.id, " ", None, "api", "tester", None)
|
||||
.expect_err("empty cookie");
|
||||
assert_eq!(err.code, "vault_session_cookie_required");
|
||||
let _ = soft_delete_credential(root, &created.id);
|
||||
let _ = purge_credential(root, &created.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Vault errors without axum — map to HTTP-like codes for CLI / vaultd.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
|
||||
/// Logical status (subset used by vault_store).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VaultStatus {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
Locked,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl VaultStatus {
|
||||
pub fn as_u16(self) -> u16 {
|
||||
match self {
|
||||
Self::BadRequest => 400,
|
||||
Self::Unauthorized => 401,
|
||||
Self::Forbidden => 403,
|
||||
Self::NotFound => 404,
|
||||
Self::Conflict => 409,
|
||||
Self::Locked => 423,
|
||||
Self::Unavailable => 503,
|
||||
Self::Internal => 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VaultError {
|
||||
pub status: VaultStatus,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl VaultError {
|
||||
pub fn new(status: VaultStatus, code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
code,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::BadRequest, "bad_request", message)
|
||||
}
|
||||
|
||||
pub fn bad_request_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::BadRequest, code, message)
|
||||
}
|
||||
|
||||
pub fn not_found_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::NotFound, code, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::Unauthorized, code, message)
|
||||
}
|
||||
|
||||
pub fn forbidden_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::Forbidden, code, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(VaultStatus::Internal, "internal_error", message)
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
/// Compatible with mnote-web `WebError::code()` (store was copied as-is).
|
||||
pub fn code(&self) -> &str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status(&self) -> VaultStatus {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VaultError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VaultError {}
|
||||
|
||||
// Compatibility aliases used by store.rs (former WebError + StatusCode patterns).
|
||||
impl VaultError {
|
||||
/// Former `WebError::new(StatusCode::NOT_FOUND, code, msg)`.
|
||||
pub fn from_status(
|
||||
status: VaultStatus,
|
||||
code: &'static str,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(status, code, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Minimal frontmatter splitter (copied from mnote-web local_markdown_parser).
|
||||
|
||||
/// Split YAML frontmatter between leading `---\\n` and next `\\n---\\n`.
|
||||
pub fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
|
||||
let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown);
|
||||
if !normalized.starts_with("---\n") {
|
||||
return (None, normalized);
|
||||
}
|
||||
let rest = &normalized[4..];
|
||||
if let Some(end) = rest.find("\n---\n") {
|
||||
let frontmatter = rest[..end].to_string();
|
||||
let body = &rest[end + 5..];
|
||||
return (Some(frontmatter), body);
|
||||
}
|
||||
(None, normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_basic() {
|
||||
let (fm, body) = split_frontmatter("---\nid: x\n---\n\nhello\n");
|
||||
assert_eq!(fm.as_deref(), Some("id: x"));
|
||||
assert!(body.contains("hello"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Local-first password vault core (12-2).
|
||||
//!
|
||||
//! File SSOT under `{workspace}/.mnote/vault/`. Read path for agents does **not**
|
||||
//! require mnote-web — use token + list/get/resolve APIs in this crate / `mnote-vault` CLI.
|
||||
|
||||
pub mod ai;
|
||||
pub mod error;
|
||||
pub mod frontmatter;
|
||||
pub mod store;
|
||||
pub mod token;
|
||||
|
||||
pub use ai::{
|
||||
ai_vault_actor_id, ai_vault_workspace_root, ensure_ai_vault_workspace, get_ai_vault_item,
|
||||
list_ai_vault_items, login_ai_vault_credential, put_ai_vault_session, resolve_ai_vault_secret,
|
||||
};
|
||||
pub use error::{VaultError, VaultStatus};
|
||||
pub use store::{VaultCredentialRecord, VaultItemStatus};
|
||||
pub use token::{
|
||||
default_hmac_key_path, default_sock_path, default_token_path, issue_token, load_or_create_hmac_key,
|
||||
read_token_from_env_or_file, require_actor, require_scope, verify_token, write_token_file,
|
||||
VaultTokenClaims,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
//! Agent capability tokens for local vault access (12-2).
|
||||
//!
|
||||
//! Format: `mnv1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
|
||||
//! Token is **not** a master key — only authorizes list/get/resolve against vaultd/CLI.
|
||||
|
||||
use crate::error::{VaultError, VaultStatus};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const TOKEN_VERSION: u32 = 1;
|
||||
pub const DEFAULT_TOKEN_DIR: &str = ".config/mnote/vault-tokens";
|
||||
pub const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vaultd-hmac.key";
|
||||
pub const DEFAULT_ACTOR: &str = "mnote-e2e";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VaultTokenClaims {
|
||||
pub v: u32,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub sub: String,
|
||||
pub actor: String,
|
||||
pub scope: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<String>,
|
||||
pub iat: u64,
|
||||
/// 0 = no expiry
|
||||
pub exp: u64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssuedToken {
|
||||
pub token: String,
|
||||
pub claims: VaultTokenClaims,
|
||||
}
|
||||
|
||||
pub fn default_hmac_key_path() -> PathBuf {
|
||||
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
|
||||
}
|
||||
|
||||
pub fn default_token_path() -> PathBuf {
|
||||
dirs_path_home()
|
||||
.join(DEFAULT_TOKEN_DIR)
|
||||
.join("default.token")
|
||||
}
|
||||
|
||||
/// Default UDS path for mnote-vaultd (12-2 §5.1).
|
||||
/// Order: `MNOTE_VAULT_SOCK` → `$XDG_RUNTIME_DIR/mnote-vaultd.sock` → `/tmp/mnote-vaultd-$UID.sock`.
|
||||
pub fn default_sock_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("MNOTE_VAULT_SOCK") {
|
||||
let p = p.trim();
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
let runtime = runtime.trim();
|
||||
if !runtime.is_empty() {
|
||||
return PathBuf::from(runtime).join("mnote-vaultd.sock");
|
||||
}
|
||||
}
|
||||
let uid = std::env::var("UID")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or_else(current_uid);
|
||||
PathBuf::from(format!("/tmp/mnote-vaultd-{uid}.sock"))
|
||||
}
|
||||
|
||||
fn current_uid() -> u32 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
extern "C" {
|
||||
fn getuid() -> u32;
|
||||
}
|
||||
// SAFETY: getuid has no preconditions.
|
||||
unsafe { getuid() }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_path_home() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
|
||||
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, VaultError> {
|
||||
if path.exists() {
|
||||
let raw = fs::read_to_string(path).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_hmac_key_read_failed",
|
||||
format!("无法读取 HMAC key {}: {e}", path.display()),
|
||||
)
|
||||
})?;
|
||||
let hex = raw.trim();
|
||||
if hex.len() < 32 {
|
||||
return Err(VaultError::bad_request_code(
|
||||
"vault_hmac_key_invalid",
|
||||
"HMAC key 过短",
|
||||
));
|
||||
}
|
||||
return hex::decode(hex).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_hmac_key_invalid",
|
||||
format!("HMAC key 非 hex: {e}"),
|
||||
)
|
||||
});
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_hmac_key_write_failed",
|
||||
format!("无法创建目录: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom_fill(&mut bytes)?;
|
||||
let hex = hex::encode(bytes);
|
||||
fs::write(path, format!("{hex}\n")).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_hmac_key_write_failed",
|
||||
format!("无法写入 HMAC key: {e}"),
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn getrandom_fill(buf: &mut [u8]) -> Result<(), VaultError> {
|
||||
use std::io::Read;
|
||||
// Prefer /dev/urandom for zero extra deps if getrandom crate not used.
|
||||
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
|
||||
VaultError::internal(format!("open /dev/urandom: {e}"))
|
||||
})?;
|
||||
f.read_exact(buf)
|
||||
.map_err(|e| VaultError::internal(format!("read urandom: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn issue_token(
|
||||
hmac_key: &[u8],
|
||||
sub: &str,
|
||||
actor: &str,
|
||||
scopes: &[&str],
|
||||
ttl_secs: Option<u64>,
|
||||
workspace: Option<String>,
|
||||
) -> Result<IssuedToken, VaultError> {
|
||||
let iat = now_unix();
|
||||
let exp = match ttl_secs {
|
||||
Some(0) | None => 0,
|
||||
Some(ttl) => iat.saturating_add(ttl),
|
||||
};
|
||||
let claims = VaultTokenClaims {
|
||||
v: TOKEN_VERSION,
|
||||
iss: "mnote-vaultd".into(),
|
||||
aud: "local-vaultd".into(),
|
||||
sub: sub.trim().to_string(),
|
||||
actor: actor.trim().to_string(),
|
||||
scope: scopes.iter().map(|s| (*s).to_string()).collect(),
|
||||
workspace,
|
||||
iat,
|
||||
exp,
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
};
|
||||
let token = encode_token(hmac_key, &claims)?;
|
||||
Ok(IssuedToken { token, claims })
|
||||
}
|
||||
|
||||
pub fn encode_token(hmac_key: &[u8], claims: &VaultTokenClaims) -> Result<String, VaultError> {
|
||||
let payload = serde_json::to_vec(claims).map_err(|e| {
|
||||
VaultError::internal(format!("token serialize: {e}"))
|
||||
})?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
|
||||
let mut mac = HmacSha256::new_from_slice(hmac_key)
|
||||
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let sig = mac.finalize().into_bytes();
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
|
||||
Ok(format!("mnv1.{payload_b64}.{sig_b64}"))
|
||||
}
|
||||
|
||||
pub fn verify_token(hmac_key: &[u8], token: &str) -> Result<VaultTokenClaims, VaultError> {
|
||||
let token = token.trim();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 || parts[0] != "mnv1" {
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_invalid",
|
||||
"token 格式无效(期望 mnv1.<payload>.<sig>)",
|
||||
));
|
||||
}
|
||||
let payload_b64 = parts[1];
|
||||
let sig_b64 = parts[2];
|
||||
let mut mac = HmacSha256::new_from_slice(hmac_key)
|
||||
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let expected = mac.finalize().into_bytes();
|
||||
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
|
||||
VaultError::unauthorized_code("vault_token_invalid", "token 签名解码失败")
|
||||
})?;
|
||||
if sig.len() != expected.len()
|
||||
|| sig
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||
!= 0
|
||||
{
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_invalid",
|
||||
"token 签名校验失败",
|
||||
));
|
||||
}
|
||||
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
|
||||
VaultError::unauthorized_code("vault_token_invalid", "token payload 解码失败")
|
||||
})?;
|
||||
let claims: VaultTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
|
||||
VaultError::unauthorized_code("vault_token_invalid", "token payload JSON 无效")
|
||||
})?;
|
||||
if claims.v != TOKEN_VERSION {
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_invalid",
|
||||
format!("不支持的 token 版本 {}", claims.v),
|
||||
));
|
||||
}
|
||||
if claims.aud != "local-vaultd" {
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_invalid",
|
||||
"token aud 不匹配",
|
||||
));
|
||||
}
|
||||
if claims.exp != 0 && now_unix() > claims.exp {
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_expired",
|
||||
"token 已过期",
|
||||
));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn require_scope(claims: &VaultTokenClaims, need: &str) -> Result<(), VaultError> {
|
||||
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
|
||||
if set.contains(need) || set.contains("*") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(VaultError::forbidden_code(
|
||||
"vault_scope_denied",
|
||||
format!("token 缺少 scope: {need}"),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn require_actor(claims: &VaultTokenClaims, expected: &str) -> Result<(), VaultError> {
|
||||
if claims.actor.trim() == expected.trim() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(VaultError::forbidden_code(
|
||||
"vault_actor_mismatch",
|
||||
format!(
|
||||
"token.actor={} 与 AI 本 actor={} 不一致",
|
||||
claims.actor, expected
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn read_token_from_env_or_file() -> Result<String, VaultError> {
|
||||
if let Ok(t) = std::env::var("MNOTE_VAULT_TOKEN") {
|
||||
let t = t.trim().to_string();
|
||||
if !t.is_empty() {
|
||||
return Ok(t);
|
||||
}
|
||||
}
|
||||
let path = std::env::var("MNOTE_VAULT_TOKEN_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_token_path());
|
||||
if !path.exists() {
|
||||
return Err(VaultError::new(
|
||||
VaultStatus::Unauthorized,
|
||||
"vault_token_missing",
|
||||
format!(
|
||||
"未找到 token(设 MNOTE_VAULT_TOKEN 或运行 issue-token)。期望文件: {}",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|e| {
|
||||
VaultError::unauthorized_code(
|
||||
"vault_token_missing",
|
||||
format!("无法读取 {}: {e}", path.display()),
|
||||
)
|
||||
})?;
|
||||
let t = raw.trim().to_string();
|
||||
if t.is_empty() {
|
||||
return Err(VaultError::unauthorized_code(
|
||||
"vault_token_missing",
|
||||
"token 文件为空",
|
||||
));
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
pub fn write_token_file(path: &Path, token: &str) -> Result<(), VaultError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_token_write_failed",
|
||||
format!("无法创建目录: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", token.trim())).map_err(|e| {
|
||||
VaultError::bad_request_code(
|
||||
"vault_token_write_failed",
|
||||
format!("无法写入 token: {e}"),
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn issue_verify_roundtrip() {
|
||||
let key = b"0123456789abcdef0123456789abcdef";
|
||||
let issued = issue_token(
|
||||
key,
|
||||
"agent:test",
|
||||
"mnote-e2e",
|
||||
&["list", "get", "resolve"],
|
||||
Some(3600),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let claims = verify_token(key, &issued.token).unwrap();
|
||||
assert_eq!(claims.actor, "mnote-e2e");
|
||||
require_scope(&claims, "resolve").unwrap();
|
||||
assert!(require_scope(&claims, "admin").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_sig_fails() {
|
||||
let key = b"0123456789abcdef0123456789abcdef";
|
||||
let issued = issue_token(key, "a", "mnote-e2e", &["list"], None, None).unwrap();
|
||||
let mut t = issued.token;
|
||||
t.push('x');
|
||||
assert!(verify_token(key, &t).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "mnote-vault"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Local vault CLI: issue-token / list / get / resolve / login / session / serve (no mnote-web)"
|
||||
|
||||
[[bin]]
|
||||
name = "mnote-vault"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
mnote-vault-core = { path = "../mnote-vault-core" }
|
||||
serde_json = "1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ hyper = "1"
|
||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
mnote-vault-core = { path = "../mnote-vault-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] }
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -11,6 +11,7 @@ var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree';
|
||||
|
||||
var draggingFileTreeRowIds = [];
|
||||
var activeFileTreeDropRow = null;
|
||||
var activeFileTreeDropPosition = null;
|
||||
|
||||
// ─── 纯 helper ─────────────────────────────────────────
|
||||
|
||||
@@ -22,10 +23,34 @@ function filetreeDragPayload(rowIds) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wolai-style drop zones on a row:
|
||||
* - top 25% → before (sibling reorder line)
|
||||
* - bottom 25% → after
|
||||
* - middle → inside folders/dirs; leaf rows treat middle as after
|
||||
*/
|
||||
function fileTreeDropPosition(event, row) {
|
||||
if (!(row instanceof HTMLElement) || !event) return 'inside';
|
||||
var rect = row.getBoundingClientRect();
|
||||
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||
if (ratio < 0.25) return 'before';
|
||||
if (ratio > 0.75) return 'after';
|
||||
var kind = String(row.getAttribute('data-row-kind') || '').trim();
|
||||
if (kind === 'folder' || kind === 'directory' || kind === 'doc' || kind === 'document') {
|
||||
// documents can host children in page/file tree hybrid; folders always accept inside
|
||||
if (kind === 'folder' || kind === 'directory') return 'inside';
|
||||
// expandable document rows also accept nest-into
|
||||
if (row.getAttribute('aria-expanded') != null && row.querySelector('.tree-toggle')) return 'inside';
|
||||
}
|
||||
// non-container leaf: middle zone still sorts after the row
|
||||
return 'after';
|
||||
}
|
||||
|
||||
function filetreeDropDetail(targetRow, fileTree, deps) {
|
||||
deps = deps || {};
|
||||
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
|
||||
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
|
||||
var dropPosition = deps.dropPosition || null;
|
||||
return {
|
||||
workspaceId: resolveWorkspaceId(targetRow || fileTree),
|
||||
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
|
||||
@@ -36,6 +61,7 @@ function filetreeDropDetail(targetRow, fileTree, deps) {
|
||||
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
|
||||
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
|
||||
uploadIntent: 'filetree.folder.drop',
|
||||
dropPosition: dropPosition || (targetRow ? 'inside' : 'inside'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,8 +85,21 @@ function parseFileTreeDragPayload(raw) {
|
||||
function clearFileTreeDropFeedback() {
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
|
||||
activeFileTreeDropRow.removeAttribute('data-drop-position');
|
||||
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'false');
|
||||
}
|
||||
activeFileTreeDropRow = null;
|
||||
activeFileTreeDropPosition = null;
|
||||
}
|
||||
|
||||
function setFileTreeDropFeedback(row, position) {
|
||||
clearFileTreeDropFeedback();
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
activeFileTreeDropRow = row;
|
||||
activeFileTreeDropPosition = position || 'inside';
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'true');
|
||||
activeFileTreeDropRow.setAttribute('data-drop-position', activeFileTreeDropPosition);
|
||||
}
|
||||
|
||||
function resetFileTreeDragState() {
|
||||
@@ -95,10 +134,27 @@ function handleFileTreeDragOver(event, deps) {
|
||||
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
clearFileTreeDropFeedback();
|
||||
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
var row = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (row instanceof HTMLElement) {
|
||||
var position = hasFiles ? 'inside' : fileTreeDropPosition(event, row);
|
||||
// external file drops only nest into containers
|
||||
if (hasFiles) {
|
||||
var kind = String(row.getAttribute('data-row-kind') || '').trim();
|
||||
if (kind !== 'folder' && kind !== 'directory' && kind !== 'doc' && kind !== 'document') {
|
||||
// drop onto leaf file → treat as after (parent folder context resolved at drop)
|
||||
position = 'after';
|
||||
} else if (kind === 'folder' || kind === 'directory') {
|
||||
position = 'inside';
|
||||
}
|
||||
}
|
||||
setFileTreeDropFeedback(row, position);
|
||||
} else {
|
||||
clearFileTreeDropFeedback();
|
||||
activeFileTreeDropRow = fileTree;
|
||||
if (activeFileTreeDropRow instanceof HTMLElement) {
|
||||
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
|
||||
}
|
||||
activeFileTreeDropPosition = 'inside';
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
|
||||
@@ -134,9 +190,16 @@ function handleFileTreeDrop(event, deps) {
|
||||
if (!files.length && !rowIds.length) return;
|
||||
|
||||
event.preventDefault();
|
||||
var dropPosition = activeFileTreeDropPosition
|
||||
|| (targetRow ? fileTreeDropPosition(event, targetRow) : 'inside');
|
||||
if (files.length) {
|
||||
var fileKind = targetRow ? String(targetRow.getAttribute('data-row-kind') || '').trim() : '';
|
||||
if (fileKind === 'folder' || fileKind === 'directory') dropPosition = 'inside';
|
||||
}
|
||||
var detail = filetreeDropDetail(targetRow, fileTree, {
|
||||
resolveWorkspaceId: resolveWorkspaceId,
|
||||
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
|
||||
dropPosition: dropPosition,
|
||||
});
|
||||
clearFileTreeDropFeedback();
|
||||
if (files.length) {
|
||||
@@ -149,6 +212,7 @@ function handleFileTreeDrop(event, deps) {
|
||||
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
|
||||
rowIds: rowIds,
|
||||
copy: copyModifier,
|
||||
dropPosition: dropPosition,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -159,14 +223,18 @@ function handleFileTreeDrop(event, deps) {
|
||||
|
||||
window.__mnoteFileTreeDndRuntime = {
|
||||
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
|
||||
draggingFileTreeRowIds: draggingFileTreeRowIds,
|
||||
activeFileTreeDropRow: activeFileTreeDropRow,
|
||||
get draggingFileTreeRowIds() { return draggingFileTreeRowIds; },
|
||||
set draggingFileTreeRowIds(value) { draggingFileTreeRowIds = value; },
|
||||
get activeFileTreeDropRow() { return activeFileTreeDropRow; },
|
||||
get activeFileTreeDropPosition() { return activeFileTreeDropPosition; },
|
||||
filetreeDragPayload: filetreeDragPayload,
|
||||
filetreeDropDetail: filetreeDropDetail,
|
||||
fileTreeDropPosition: fileTreeDropPosition,
|
||||
filetreeHasFiles: filetreeHasFiles,
|
||||
filetreeHasInternalDrag: filetreeHasInternalDrag,
|
||||
parseFileTreeDragPayload: parseFileTreeDragPayload,
|
||||
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
|
||||
setFileTreeDropFeedback: setFileTreeDropFeedback,
|
||||
resetFileTreeDragState: resetFileTreeDragState,
|
||||
startFileTreeDrag: startFileTreeDrag,
|
||||
handleFileTreeDragOver: handleFileTreeDragOver,
|
||||
|
||||
@@ -10,15 +10,22 @@ function getSidebarFileTreeClipboard() {
|
||||
return sidebarFileTreeClipboard;
|
||||
}
|
||||
|
||||
function setSidebarFileTreeClipboard(action, rowIds) {
|
||||
function setSidebarFileTreeClipboard(action, rowIds, options) {
|
||||
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
|
||||
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
|
||||
// Keep product runtimeState / host clipboard in sync (menu paste reads runtimeState).
|
||||
if (options && typeof options.onClipboardChange === 'function') {
|
||||
options.onClipboardChange(sidebarFileTreeClipboard);
|
||||
}
|
||||
return sidebarFileTreeClipboard;
|
||||
}
|
||||
|
||||
function clearSidebarFileTreeClipboard() {
|
||||
function clearSidebarFileTreeClipboard(options) {
|
||||
sidebarFileTreeClipboard = null;
|
||||
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
||||
if (options && typeof options.onClipboardChange === 'function') {
|
||||
options.onClipboardChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keyboard handler ───────────────────────────────────
|
||||
@@ -31,6 +38,7 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
|
||||
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
|
||||
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
|
||||
var onClipboardChange = deps.onClipboardChange || null;
|
||||
|
||||
var keyTarget = event.target;
|
||||
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
|
||||
@@ -59,7 +67,9 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
}).filter(Boolean);
|
||||
if (selectedRowIds.length > 0) {
|
||||
event.preventDefault();
|
||||
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds);
|
||||
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds, {
|
||||
onClipboardChange: onClipboardChange,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -92,7 +102,7 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
// ─── 导出 ───────────────────────────────────────────────
|
||||
|
||||
window.__mnoteFileTreeKeyboardRuntime = {
|
||||
sidebarFileTreeClipboard: sidebarFileTreeClipboard,
|
||||
get sidebarFileTreeClipboard() { return sidebarFileTreeClipboard; },
|
||||
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
|
||||
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
|
||||
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
|
||||
|
||||
@@ -183,6 +183,49 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
runtimeState.activeTreeContextMenu = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product clipboard used by menu paste + keyboard paste.
|
||||
* Keep keyboard-module clipboard and runtimeState in lockstep so
|
||||
* 「粘贴到此处」enables after either Ctrl+C/X or menu cut/copy.
|
||||
*/
|
||||
function currentSidebarFileTreeClipboard() {
|
||||
if (runtimeState.sidebarFileTreeClipboard
|
||||
&& Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds)
|
||||
&& runtimeState.sidebarFileTreeClipboard.rowIds.length) {
|
||||
return runtimeState.sidebarFileTreeClipboard;
|
||||
}
|
||||
var kb = window.__mnoteFileTreeKeyboardRuntime;
|
||||
var fromKb = kb && typeof kb.getSidebarFileTreeClipboard === 'function'
|
||||
? kb.getSidebarFileTreeClipboard()
|
||||
: (kb && kb.sidebarFileTreeClipboard) || null;
|
||||
if (fromKb && Array.isArray(fromKb.rowIds) && fromKb.rowIds.length) {
|
||||
// Heal dual-clipboard drift (keyboard module wrote first).
|
||||
runtimeState.sidebarFileTreeClipboard = fromKb;
|
||||
return fromKb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setSidebarFileTreeClipboard(action, rowIds) {
|
||||
var next = action && Array.isArray(rowIds) && rowIds.length
|
||||
? { action: action, rowIds: rowIds.slice() }
|
||||
: null;
|
||||
runtimeState.sidebarFileTreeClipboard = next;
|
||||
var kb = window.__mnoteFileTreeKeyboardRuntime;
|
||||
if (kb && typeof kb.setSidebarFileTreeClipboard === 'function') {
|
||||
if (next) {
|
||||
kb.setSidebarFileTreeClipboard(next.action, next.rowIds);
|
||||
} else if (typeof kb.clearSidebarFileTreeClipboard === 'function') {
|
||||
kb.clearSidebarFileTreeClipboard();
|
||||
}
|
||||
} else if (next) {
|
||||
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', next.action);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function copyTreeContextValue(value, actionName) {
|
||||
var text = String(value || '');
|
||||
var done = function() {
|
||||
@@ -618,6 +661,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'cut' || action === 'copy') {
|
||||
var clipboardRows = selectedSidebarFileTreeRows();
|
||||
if (!clipboardRows.length && trigger && trigger.closest) {
|
||||
var single = trigger.closest('.tree-row[data-shell-mode="filetree"]');
|
||||
if (single instanceof HTMLElement) clipboardRows = [single];
|
||||
}
|
||||
var clipboardRowIds = clipboardRows.map(function(row) {
|
||||
return String(row.getAttribute('data-row-id') || '').trim();
|
||||
}).filter(Boolean);
|
||||
if (!clipboardRowIds.length) {
|
||||
recordFileTreeActionStatus('skipped', Object.assign({}, detail, { reason: 'empty-selection' }));
|
||||
return;
|
||||
}
|
||||
setSidebarFileTreeClipboard(action, clipboardRowIds);
|
||||
recordFileTreeAction(action, Object.assign({}, detail, {
|
||||
sourceRowIds: clipboardRowIds,
|
||||
clipboardAction: action,
|
||||
}));
|
||||
recordFileTreeActionStatus('applied', Object.assign({}, detail, {
|
||||
sourceRowIds: clipboardRowIds,
|
||||
clipboardAction: action,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (action === 'paste-into') {
|
||||
var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
|
||||
recordFileTreeAction('paste-into', detail);
|
||||
@@ -1228,7 +1295,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
||||
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'cut', icon: 'content_cut', label: '剪切', shortcut: 'Ctrl+X', when: '!workspace.readonly' },
|
||||
{ action: 'copy', icon: 'content_copy', label: '复制', shortcut: 'Ctrl+C' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', shortcut: 'Ctrl+V', disabled: !currentSidebarFileTreeClipboard(), title: currentSidebarFileTreeClipboard() ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||
@@ -1929,9 +1999,16 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount');
|
||||
if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps());
|
||||
if (!documentId) return 0;
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]');
|
||||
var row = document.querySelector(
|
||||
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"],'
|
||||
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(documentId) + '"],'
|
||||
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(documentId) + '"],'
|
||||
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-node-id="' + cssEscape(documentId) + '"]'
|
||||
);
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : [];
|
||||
var children = node
|
||||
? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"]')
|
||||
: [];
|
||||
return children.length;
|
||||
}
|
||||
|
||||
@@ -1944,6 +2021,45 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}, targetRow) || null;
|
||||
}
|
||||
|
||||
function fileTreeParentIdFromAttr(parentAttr) {
|
||||
var raw = String(parentAttr || '').trim();
|
||||
if (!raw) return null;
|
||||
if (raw.indexOf('doc:') === 0) return raw.slice(4);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wolai-style move target:
|
||||
* - inside → nest under target (folder/dir/doc)
|
||||
* - before/after → same parent as target, sortOrder = sibling index (+1 for after)
|
||||
*/
|
||||
function resolveFileTreeMoveTarget(targetRow, position) {
|
||||
if (!(targetRow instanceof HTMLElement)) {
|
||||
return { parentId: null, sortOrder: 0 };
|
||||
}
|
||||
var pos = position || 'inside';
|
||||
if (pos === 'inside') {
|
||||
var nestParentId = fileTreeMoveTargetParentId(targetRow);
|
||||
return {
|
||||
parentId: nestParentId,
|
||||
sortOrder: nestParentId ? fileTreeChildCount(nestParentId) : 0,
|
||||
};
|
||||
}
|
||||
var parentAttr = targetRow.getAttribute('data-parent-id') || '';
|
||||
var parentId = fileTreeParentIdFromAttr(parentAttr);
|
||||
var siblings = Array.from(
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')
|
||||
).filter(function(row) {
|
||||
return (row.getAttribute('data-parent-id') || '') === parentAttr;
|
||||
});
|
||||
var index = siblings.indexOf(targetRow);
|
||||
if (index < 0) index = 0;
|
||||
return {
|
||||
parentId: parentId,
|
||||
sortOrder: Math.max(0, index + (pos === 'after' ? 1 : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function fileTreeMoveSourceId(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
return fileTreeRowDocumentId(row)
|
||||
@@ -1956,7 +2072,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var rows = fileTreeRowsByRowIds(rowIds || []);
|
||||
if (rows.length === 0) return false;
|
||||
var copy = Boolean(options && options.copy);
|
||||
var targetParentId = fileTreeMoveTargetParentId(targetRow);
|
||||
var dropPosition = (options && options.dropPosition) || 'inside';
|
||||
var resolved = resolveFileTreeMoveTarget(targetRow, dropPosition);
|
||||
var targetParentId = resolved.parentId;
|
||||
var baseSortOrder = typeof resolved.sortOrder === 'number' ? resolved.sortOrder : 0;
|
||||
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
||||
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
|
||||
if (!writable) return false;
|
||||
@@ -1983,7 +2102,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
workspaceId: workspaceId,
|
||||
documentId: sourceId,
|
||||
parentId: targetParentId,
|
||||
sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i
|
||||
sortOrder: baseSortOrder + i
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
|
||||
@@ -2014,21 +2133,24 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}
|
||||
|
||||
async function pasteSidebarFileTreeClipboard(trigger) {
|
||||
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
|
||||
var clipboard = currentSidebarFileTreeClipboard();
|
||||
if (!clipboard) return false;
|
||||
var targetRow = trigger instanceof HTMLElement ? trigger : null;
|
||||
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
|
||||
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
|
||||
}
|
||||
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
|
||||
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
|
||||
var action = clipboard.action === 'cut' ? 'move' : 'copy';
|
||||
recordFileTreeAction('paste', {
|
||||
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
|
||||
documentId: targetDocumentId,
|
||||
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
|
||||
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
|
||||
sourceRowIds: clipboard.rowIds,
|
||||
clipboardAction: clipboard.action
|
||||
});
|
||||
var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' });
|
||||
if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null;
|
||||
var ok = await moveSidebarFileTreeRows(clipboard.rowIds, targetRow, { copy: action === 'copy' });
|
||||
if (ok && action === 'move') {
|
||||
setSidebarFileTreeClipboard(null, []);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -2206,6 +2328,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
postSidebarFileTreeJson,
|
||||
fileTreeRowsByRowIds,
|
||||
fileTreeChildCount,
|
||||
resolveFileTreeMoveTarget,
|
||||
moveSidebarFileTreeRows,
|
||||
pasteSidebarFileTreeClipboard,
|
||||
deleteSelectedSidebarFileTreeRows,
|
||||
|
||||
@@ -186,12 +186,29 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
||||
function clearPageDropFeedback() {
|
||||
if (activePageDropRow instanceof HTMLElement) {
|
||||
activePageDropRow.setAttribute('data-drop-feedback', 'false');
|
||||
activePageDropRow.setAttribute('data-drop-target', 'false');
|
||||
activePageDropRow.removeAttribute('data-drop-position');
|
||||
}
|
||||
activePageDropRow = null;
|
||||
}
|
||||
|
||||
function setActivePageDropRow(row) {
|
||||
activePageDropRow = row instanceof HTMLElement ? row : null;
|
||||
function setActivePageDropRow(row, position) {
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
clearPageDropFeedback();
|
||||
return;
|
||||
}
|
||||
if (activePageDropRow && activePageDropRow !== row) {
|
||||
clearPageDropFeedback();
|
||||
}
|
||||
activePageDropRow = row;
|
||||
activePageDropRow.setAttribute('data-drop-feedback', 'true');
|
||||
activePageDropRow.setAttribute('data-drop-target', 'true');
|
||||
// Always refresh position so before↔after on the same row updates the edge line.
|
||||
if (position) {
|
||||
activePageDropRow.setAttribute('data-drop-position', position);
|
||||
} else {
|
||||
activePageDropRow.removeAttribute('data-drop-position');
|
||||
}
|
||||
}
|
||||
|
||||
function clearPageDragState() {
|
||||
|
||||
@@ -522,6 +522,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
'<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>' +
|
||||
'<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="插入密文">' +
|
||||
'<option value="">插入密文…</option>' +
|
||||
'</select></label>' +
|
||||
'<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />' +
|
||||
'<div class="mnote-vault-tabs" role="tablist">' +
|
||||
'<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>' +
|
||||
@@ -3698,12 +3703,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var targetRow = detail.targetRowId
|
||||
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
|
||||
: null;
|
||||
var dropPosition = detail.dropPosition || 'inside';
|
||||
recordFileTreeAction('internal-drop', {
|
||||
rowId: detail.targetRowId || '',
|
||||
sourceRowIds: rowIds,
|
||||
copy: Boolean(detail.copy)
|
||||
copy: Boolean(detail.copy),
|
||||
dropPosition: dropPosition
|
||||
});
|
||||
void moveSidebarFileTreeRows(rowIds, targetRow, {
|
||||
copy: Boolean(detail.copy),
|
||||
dropPosition: dropPosition,
|
||||
});
|
||||
void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) });
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', function(event) {
|
||||
@@ -3746,6 +3756,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
|
||||
deleteSelectedSidebarFileTreeRows: deleteSelectedSidebarFileTreeRows,
|
||||
pasteSidebarFileTreeClipboard: pasteSidebarFileTreeClipboard,
|
||||
onClipboardChange: function(nextClipboard) {
|
||||
sidebarFileTreeClipboard = nextClipboard;
|
||||
},
|
||||
});
|
||||
if (handled) return;
|
||||
} else {
|
||||
@@ -3953,10 +3966,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var sourceNodeId = readPageDragNodeId(event);
|
||||
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
|
||||
event.preventDefault();
|
||||
clearPageDropFeedback();
|
||||
pageRow.setAttribute('data-drop-feedback', 'true');
|
||||
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
|
||||
setActivePageDropRow(pageRow);
|
||||
var pagePosition = pageDropPosition(event, pageRow);
|
||||
setActivePageDropRow(pageRow, pagePosition);
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,28 @@ impl WebError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map independent vault-core errors into HTTP WebError (12-2 P1a shared read path).
|
||||
impl From<mnote_vault_core::VaultError> for WebError {
|
||||
fn from(err: mnote_vault_core::VaultError) -> Self {
|
||||
use mnote_vault_core::VaultStatus;
|
||||
let status = match err.status {
|
||||
VaultStatus::BadRequest => StatusCode::BAD_REQUEST,
|
||||
VaultStatus::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
VaultStatus::Forbidden => StatusCode::FORBIDDEN,
|
||||
VaultStatus::NotFound => StatusCode::NOT_FOUND,
|
||||
VaultStatus::Conflict => StatusCode::CONFLICT,
|
||||
VaultStatus::Locked => StatusCode::from_u16(423).unwrap_or(StatusCode::FORBIDDEN),
|
||||
VaultStatus::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||
VaultStatus::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let mut web = WebError::new(status, err.code, err.message);
|
||||
if let Some(details) = err.details {
|
||||
web = web.with_details(details);
|
||||
}
|
||||
web
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = ErrorBody {
|
||||
|
||||
@@ -131,7 +131,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-vault",
|
||||
title: "密码箱 / AI 密码本",
|
||||
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。",
|
||||
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
||||
category: "security",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
|
||||
@@ -1419,6 +1419,12 @@ 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>
|
||||
<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="插入密文">
|
||||
<option value="">插入密文…</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
|
||||
<div class="mnote-vault-tabs" role="tablist">
|
||||
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
|
||||
|
||||
@@ -189,7 +189,10 @@ fn local_page_tree_snapshot_scan_test_loads_for_key(
|
||||
root_uri: &str,
|
||||
parent_relative_path: &str,
|
||||
) -> u64 {
|
||||
let key = format!("{root_uri}\n{parent_relative_path}");
|
||||
// Match load_local_folder_page_tree_snapshot_for_scope cache_key shape:
|
||||
// "{root_source_uri}\n{parent_relative_path}\n{reveal_relative_path}".
|
||||
// Callers pass the same root_uri used for load; reveal counters are empty here.
|
||||
let key = format!("{root_uri}\n{parent_relative_path}\n");
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
@@ -8554,16 +8557,17 @@ fn append_page_tree_reveal_rows(
|
||||
}
|
||||
let depth = local_folder_relative_depth(ancestor);
|
||||
// Parent id for children of this ancestor directory.
|
||||
let parent_node_id = if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, ancestor)
|
||||
{
|
||||
Some(sibling_md)
|
||||
} else {
|
||||
Some(local_directory_group_id(ancestor))
|
||||
};
|
||||
// Must match how shallow PageTree projects the directory itself
|
||||
// (nested bundle Dir/Dir.md, sibling Name.md, or local-dir page-group).
|
||||
// Previously only sibling .md was checked, so nested bundles got
|
||||
// parentNodeId=local-dir:… while the parent row was local-md:…/Dir.md;
|
||||
// groupRowsByParent then promoted children to roots (duplicate roots after delete/reveal).
|
||||
let parent_node_id = Some(page_tree_node_id_for_directory(root, ancestor));
|
||||
// Ensure the ancestor group/page row itself is expanded.
|
||||
for row in rows.iter_mut() {
|
||||
if row.relative_path == *ancestor
|
||||
|| row.node_id == local_directory_group_id(ancestor)
|
||||
|| row.node_id == parent_node_id.as_deref().unwrap_or_default()
|
||||
|| row.document_id.as_deref()
|
||||
== parent_node_id
|
||||
.as_deref()
|
||||
@@ -8628,6 +8632,31 @@ fn append_page_tree_reveal_rows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PageTree node id for a directory, aligned with `scan_markdown_page_tree_shallow`:
|
||||
/// 1. nested page bundle `Dir/Dir.md` → `local-md:…/Dir/Dir.md`
|
||||
/// 2. sibling markdown `parent/Name.md` with directory `parent/Name/` → that page id
|
||||
/// 3. otherwise page-group → `local-dir:…`
|
||||
fn page_tree_node_id_for_directory(root: &Path, directory_relative: &str) -> String {
|
||||
let normalized = directory_relative
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Ok(directory) = resolve_metadata_relative_path(root, &normalized) {
|
||||
if let Some(nested_main) = nested_bundle_main_markdown(&directory) {
|
||||
if let Ok(relative) = normalize_relative_path(root, &nested_main) {
|
||||
return local_markdown_path_page_id(&relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, &normalized) {
|
||||
return sibling_md;
|
||||
}
|
||||
local_directory_group_id(&normalized)
|
||||
}
|
||||
|
||||
fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option<String> {
|
||||
let parent = Path::new(ancestor_relative).parent()?;
|
||||
let name = Path::new(ancestor_relative).file_name()?.to_str()?;
|
||||
@@ -13953,6 +13982,38 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 12-2 / 12-1:通用 local file open 不得读 `.mnote/vault/**`(须走 vault API / mnote-vault)。
|
||||
#[tokio::test]
|
||||
async fn local_file_open_rejects_vault_system_path() {
|
||||
let root = temp_root("mnote-local-file-open-vault-deny");
|
||||
let vault_entry = root.join(".mnote/vault/entries");
|
||||
std::fs::create_dir_all(&vault_entry).expect("vault dir");
|
||||
std::fs::write(vault_entry.join("secret.md"), "password: leak").expect("write vault");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
let query = LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: ".mnote/vault/entries/secret.md".into(),
|
||||
download: None,
|
||||
};
|
||||
|
||||
let error = open_local_file(State(test_state()), Extension(context), Query(query))
|
||||
.await
|
||||
.expect_err("must deny vault path on general file open");
|
||||
assert_eq!(error.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "vault_path_denied");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_read_grant() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
@@ -14804,6 +14865,102 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_page_tree_reveal_nested_bundle_keeps_single_root_parent() {
|
||||
// Regression: delete/watch full sidebar refresh uses reveal. Nested
|
||||
// Root/Root.md must own Root/Child/Child.md via parentNodeId, not
|
||||
// local-dir:Root (which groupRowsByParent promotes to duplicate roots).
|
||||
let root = temp_root("mnote-page-tree-reveal-nested-bundle-parent");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create nested dirs");
|
||||
std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md");
|
||||
std::fs::write(
|
||||
root.join("Root").join("Child").join("Child.md"),
|
||||
"# Child\n",
|
||||
)
|
||||
.expect("write Child.md");
|
||||
// Second sibling under Root so multi-child root promotion would be obvious.
|
||||
std::fs::create_dir_all(root.join("Root").join("Sibling")).expect("create Sibling");
|
||||
std::fs::write(
|
||||
root.join("Root").join("Sibling").join("Sibling.md"),
|
||||
"# Sibling\n",
|
||||
)
|
||||
.expect("write Sibling.md");
|
||||
|
||||
let reveal_doc = local_markdown_path_page_id("Root/Child/Child.md");
|
||||
assert_eq!(reveal_doc, "local-md:Root~2FChild~2FChild.md");
|
||||
let revealed = load_local_folder_page_tree_snapshot_with_reveal(
|
||||
&root_uri,
|
||||
Some(reveal_doc.as_str()),
|
||||
)
|
||||
.expect("reveal snapshot");
|
||||
let items = revealed.projection["items"].as_array().expect("items");
|
||||
|
||||
let root_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FRoot.md"))
|
||||
.expect("Root nested-bundle page in reveal snapshot");
|
||||
assert!(
|
||||
root_node["parentNodeId"].is_null()
|
||||
|| root_node["parentNodeId"].as_str().map(str::is_empty).unwrap_or(false),
|
||||
"Root page must remain a tree root: {root_node}"
|
||||
);
|
||||
|
||||
let child_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FChild~2FChild.md"))
|
||||
.expect("Child must be present after reveal");
|
||||
assert_eq!(
|
||||
child_node["parentNodeId"].as_str(),
|
||||
Some("local-md:Root~2FRoot.md"),
|
||||
"Child parent must match nested-bundle Root page id, not local-dir:Root: {child_node}"
|
||||
);
|
||||
|
||||
let sibling_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FSibling~2FSibling.md"))
|
||||
.expect("Sibling of revealed child must also nest under Root");
|
||||
assert_eq!(
|
||||
sibling_node["parentNodeId"].as_str(),
|
||||
Some("local-md:Root~2FRoot.md"),
|
||||
"Sibling parent must match Root page id: {sibling_node}"
|
||||
);
|
||||
|
||||
// No orphan local-dir:Root page-group row that would fight the local-md parent.
|
||||
assert!(
|
||||
items.iter().all(|item| {
|
||||
item["nodeId"].as_str() != Some("local-dir:Root")
|
||||
&& !item["rowId"]
|
||||
.as_str()
|
||||
.map(|id| id.contains("page-group:Root"))
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
"reveal must not invent a local-dir/page-group Root: {items:?}"
|
||||
);
|
||||
|
||||
// groupRowsByParent contract: only Root is a root; Child/Sibling hang under it.
|
||||
let ids: std::collections::BTreeSet<String> = items
|
||||
.iter()
|
||||
.filter_map(|item| item["nodeId"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for item in items {
|
||||
let parent = item["parentNodeId"].as_str().unwrap_or("");
|
||||
if parent.is_empty() || !ids.contains(parent) {
|
||||
roots.push(item["nodeId"].as_str().unwrap_or("").to_string());
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
roots,
|
||||
vec!["local-md:Root~2FRoot.md".to_string()],
|
||||
"groupRowsByParent-equivalent must keep a single root after nested-bundle reveal: {roots:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
|
||||
let root = temp_root("mnote-local-file-tree-nested-bundle");
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) mod ui_preferences;
|
||||
mod vault;
|
||||
mod vault_path;
|
||||
mod vault_store;
|
||||
mod vault_transport;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
|
||||
@@ -4846,7 +4846,8 @@ impl PiLabToolFacade {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
|
||||
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
|
||||
crate::routes::vault::list_ai_vault_items(status)
|
||||
// 12-2: UDS vaultd first, then in-process core (not HTTP :3000).
|
||||
crate::routes::vault_transport::list_ai_vault_items(status)
|
||||
}
|
||||
|
||||
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
|
||||
@@ -4855,7 +4856,7 @@ impl PiLabToolFacade {
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
|
||||
})?;
|
||||
crate::routes::vault::get_ai_vault_item(&id)
|
||||
crate::routes::vault_transport::get_ai_vault_item(&id)
|
||||
}
|
||||
|
||||
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
|
||||
@@ -4870,9 +4871,13 @@ impl PiLabToolFacade {
|
||||
let field = string_param(¶ms, "field").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_field_required",
|
||||
"mnote.vault.resolve 需要 field=password|apikey|token",
|
||||
"mnote.vault.resolve 需要 field=password|apikey|token|username|email",
|
||||
)
|
||||
})?;
|
||||
let account_id = string_param(¶ms, "accountId")
|
||||
.or_else(|| string_param(¶ms, "account_id"));
|
||||
let secret_id =
|
||||
string_param(¶ms, "secretId").or_else(|| string_param(¶ms, "secret_id"));
|
||||
let actor = {
|
||||
let id = self.context.auth.actor_id.trim();
|
||||
if id.is_empty() {
|
||||
@@ -4888,11 +4893,13 @@ impl PiLabToolFacade {
|
||||
"密码箱 resolve 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::resolve_ai_vault_secret(
|
||||
crate::routes::vault_transport::resolve_ai_vault_secret(
|
||||
&id,
|
||||
&field,
|
||||
&actor,
|
||||
Some(self.context.trace.request_id.as_str()),
|
||||
account_id.as_deref(),
|
||||
secret_id.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4923,7 +4930,7 @@ impl PiLabToolFacade {
|
||||
"密码箱 login 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::login_ai_vault_credential(
|
||||
crate::routes::vault_transport::login_ai_vault_credential(
|
||||
&id,
|
||||
force,
|
||||
&actor,
|
||||
@@ -4966,7 +4973,7 @@ impl PiLabToolFacade {
|
||||
"密码箱 session 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::put_ai_vault_session(
|
||||
crate::routes::vault_transport::put_ai_vault_session(
|
||||
&id,
|
||||
&cookie,
|
||||
expires.as_deref(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,4 +100,20 @@ mod tests {
|
||||
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
||||
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_cipher_book_and_index_under_vault() {
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/cipher-book.json"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/vault-index.json"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/audit.jsonl"
|
||||
));
|
||||
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/cipher-book.json")
|
||||
.expect_err("must deny cipher-book via general file surface");
|
||||
assert_eq!(err.code(), "vault_path_denied");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
//! Pi / agent vault transport: prefer vaultd UDS, fall back to in-process core.
|
||||
//!
|
||||
//! Aligns with 12-2 §5.4 / §6.4 — same sock→core policy as `mnote-vault` CLI.
|
||||
//! Does **not** depend on HTTP to :3000 for list/get/resolve/login/session data plane.
|
||||
//!
|
||||
//! Env:
|
||||
//! - `MNOTE_VAULT_PI_TRANSPORT=auto|uds|local` (default `auto`)
|
||||
//! - `MNOTE_VAULT_SOCK` / token env handled by `mnote-vault-core::token`
|
||||
|
||||
use crate::error::WebError;
|
||||
use crate::routes::vault;
|
||||
use crate::routes::vault_store::VaultItemStatus;
|
||||
use mnote_vault_core::default_sock_path;
|
||||
use mnote_vault_core::read_token_from_env_or_file;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TransportMode {
|
||||
/// UDS if reachable, else local core.
|
||||
Auto,
|
||||
/// UDS only (fail if sock down).
|
||||
UdsOnly,
|
||||
/// In-process core only (skip sock).
|
||||
LocalOnly,
|
||||
}
|
||||
|
||||
fn transport_mode() -> TransportMode {
|
||||
match std::env::var("MNOTE_VAULT_PI_TRANSPORT")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("uds") | Some("remote") | Some("sock") => TransportMode::UdsOnly,
|
||||
Some("local") | Some("core") | Some("embedded") => TransportMode::LocalOnly,
|
||||
_ => TransportMode::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn sock_reachable(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
std::os::unix::net::UnixStream::connect(path).is_ok()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn uds_http(
|
||||
sock: &Path,
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
) -> Result<(u16, String), WebError> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
let mut stream = UnixStream::connect(sock).map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("无法连接 vaultd sock {}: {e}", sock.display()),
|
||||
)
|
||||
})?;
|
||||
let body_bytes = body.unwrap_or("").as_bytes();
|
||||
let mut req = format!(
|
||||
"{method} {path_and_query} HTTP/1.1\r\nHost: mnote-vaultd\r\nConnection: close\r\n"
|
||||
);
|
||||
if let Some(token) = bearer {
|
||||
req.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
if body.is_some() {
|
||||
req.push_str("Content-Type: application/json\r\n");
|
||||
req.push_str(&format!("Content-Length: {}\r\n", body_bytes.len()));
|
||||
} else {
|
||||
req.push_str("Content-Length: 0\r\n");
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
stream
|
||||
.write_all(req.as_bytes())
|
||||
.and_then(|_| {
|
||||
if !body_bytes.is_empty() {
|
||||
stream.write_all(body_bytes)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("写 sock 失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("读 sock 失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
parse_http_response(&text)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn uds_http(
|
||||
_sock: &Path,
|
||||
_method: &str,
|
||||
_path_and_query: &str,
|
||||
_body: Option<&str>,
|
||||
_bearer: Option<&str>,
|
||||
) -> Result<(u16, String), WebError> {
|
||||
Err(WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
"UDS 仅支持 Unix",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_http_response(text: &str) -> Result<(u16, String), WebError> {
|
||||
let (head, body) = text
|
||||
.split_once("\r\n\r\n")
|
||||
.or_else(|| text.split_once("\n\n"))
|
||||
.unwrap_or((text, ""));
|
||||
let status_line = head.lines().next().unwrap_or("");
|
||||
let status: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(500);
|
||||
Ok((status, body.to_string()))
|
||||
}
|
||||
|
||||
fn map_http_error(status: u16, body: &str) -> WebError {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(body) {
|
||||
let code = v
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("vaultd_error");
|
||||
let message = v
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(body)
|
||||
.to_string();
|
||||
let http_status = axum::http::StatusCode::from_u16(status)
|
||||
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
// Prefer stable vault_* codes when present.
|
||||
let code_static: &'static str = match code {
|
||||
"vault_token_missing" => "vault_token_missing",
|
||||
"vault_token_invalid" => "vault_token_invalid",
|
||||
"vault_token_expired" => "vault_token_expired",
|
||||
"vault_scope_denied" => "vault_scope_denied",
|
||||
"vault_actor_mismatch" => "vault_actor_mismatch",
|
||||
"vault_item_not_found" => "vault_item_not_found",
|
||||
"vault_resolve_field_invalid" => "vault_resolve_field_invalid",
|
||||
"vault_resolve_inactive" => "vault_resolve_inactive",
|
||||
"vaultd_unavailable" => "vaultd_unavailable",
|
||||
"bad_request" => "bad_request",
|
||||
"vault_session_inactive" => "vault_session_inactive",
|
||||
"vault_login_no_url" => "vault_login_no_url",
|
||||
"vault_login_human_required" => "vault_login_human_required",
|
||||
_ if code.starts_with("vault_") => "vault_error",
|
||||
_ => "vaultd_error",
|
||||
};
|
||||
let mut err = WebError::new(http_status, code_static, message);
|
||||
if code_static == "vault_error" {
|
||||
err = err.with_details(json!({ "upstreamCode": code }));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
WebError::new(
|
||||
axum::http::StatusCode::from_u16(status)
|
||||
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
"vaultd_error",
|
||||
format!("HTTP {status}: {body}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn client_token() -> Result<Option<String>, WebError> {
|
||||
match read_token_from_env_or_file() {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
|
||||
where
|
||||
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
|
||||
G: FnOnce() -> Result<Value, WebError>,
|
||||
{
|
||||
let mode = transport_mode();
|
||||
if mode == TransportMode::LocalOnly {
|
||||
return via_local();
|
||||
}
|
||||
let sock = default_sock_path();
|
||||
if sock_reachable(&sock) {
|
||||
let token = client_token()?;
|
||||
match via_uds(token.as_deref()) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) if mode == TransportMode::UdsOnly => return Err(e),
|
||||
Err(_) => {
|
||||
// Soft fallback to in-process core (same as CLI uds_fallback_local).
|
||||
}
|
||||
}
|
||||
} else if mode == TransportMode::UdsOnly {
|
||||
return Err(WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("vaultd sock 不可达: {}", sock.display()),
|
||||
));
|
||||
}
|
||||
via_local()
|
||||
}
|
||||
|
||||
fn core_status(status: VaultItemStatus) -> VaultItemStatus {
|
||||
status
|
||||
}
|
||||
|
||||
/// List AI vault (Pi tool). UDS → core.
|
||||
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
|
||||
let status_q = status.as_str();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items?status={status_q}");
|
||||
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &body));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| {
|
||||
WebError::internal(format!("vaultd list JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::list_ai_vault_items(core_status(status)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id}");
|
||||
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &body));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| {
|
||||
WebError::internal(format!("vaultd get JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::get_ai_vault_item(id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_ai_vault_secret(
|
||||
id: &str,
|
||||
field: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
secret_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
let field_owned = field.to_string();
|
||||
let account = account_id.map(str::to_string);
|
||||
let secret = secret_id.map(str::to_string);
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/resolve");
|
||||
let body = json!({
|
||||
"field": field_owned,
|
||||
"accountId": account,
|
||||
"secretId": secret,
|
||||
});
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd resolve JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| {
|
||||
vault::resolve_ai_vault_secret(
|
||||
&id_owned,
|
||||
&field_owned,
|
||||
actor,
|
||||
request_id,
|
||||
account.as_deref(),
|
||||
secret.as_deref(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn login_ai_vault_credential(
|
||||
id: &str,
|
||||
force_refresh: bool,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/login");
|
||||
let body = json!({ "forceRefresh": force_refresh });
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd login JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::login_ai_vault_credential(&id_owned, force_refresh, actor, request_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn put_ai_vault_session(
|
||||
id: &str,
|
||||
cookie_header: &str,
|
||||
expires_at: Option<&str>,
|
||||
source: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
let cookie = cookie_header.to_string();
|
||||
let expires = expires_at.map(str::to_string);
|
||||
let source_owned = source.to_string();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/session");
|
||||
let body = json!({
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": expires,
|
||||
"source": source_owned,
|
||||
});
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd session JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| {
|
||||
vault::put_ai_vault_session(
|
||||
&id_owned,
|
||||
&cookie,
|
||||
expires.as_deref(),
|
||||
&source_owned,
|
||||
actor,
|
||||
request_id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transport_mode_defaults_to_auto() {
|
||||
// Do not assert env-free default in parallel tests; just ensure parser is stable.
|
||||
let _ = transport_mode();
|
||||
assert!(matches!(
|
||||
TransportMode::Auto,
|
||||
TransportMode::Auto
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_local_only_works_without_sock() {
|
||||
std::env::set_var("MNOTE_VAULT_PI_TRANSPORT", "local");
|
||||
// May fail if AI vault workspace missing in CI sandbox — only check no panic on mode.
|
||||
let _ = list_ai_vault_items(VaultItemStatus::Active);
|
||||
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,46 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||
}
|
||||
|
||||
/* Wolai-style sibling reorder lines (before / after). Nest-into uses full-row fill above. */
|
||||
.sidebar-tree .tree-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-target="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-target="true"] {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-target="true"] {
|
||||
background: rgba(0, 110, 40, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"]::before,
|
||||
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 110, 40, 0.85);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"]::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle,
|
||||
.sidebar-tree .tree-spacer {
|
||||
width: 20px;
|
||||
|
||||
@@ -90,6 +90,64 @@
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
/* 顶栏(密文簿旁):插入已有密文 [Key];编辑态显示,滚动详情时仍可见 */
|
||||
.mnote-vault-header-actions .mnote-vault-insert-cipher,
|
||||
.mnote-vault-insert-cipher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher select {
|
||||
height: 30px;
|
||||
min-width: 118px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #37352f;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher select:hover {
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
.mnote-vault-form-hint {
|
||||
margin: 0 0 8px;
|
||||
padding: 0 2px;
|
||||
color: #8b8782;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-vault-form-hint code {
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(27, 28, 28, 0.05);
|
||||
}
|
||||
|
||||
.mnote-vault-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-detail-actions button.is-danger {
|
||||
color: #c93a32;
|
||||
border-color: rgba(201, 58, 50, 0.28);
|
||||
@@ -732,3 +790,296 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Multi-account / multi-secret collapsible groups */
|
||||
.mnote-vault-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(247, 247, 246, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-section-head > button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-section-head > button:hover {
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
/* Multi-URL (equivalent site endpoints / fallbacks) */
|
||||
.mnote-vault-url-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(55, 53, 47, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-url-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-url-label {
|
||||
font-size: 12px;
|
||||
color: rgba(55, 53, 47, 0.65);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row input[type="url"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row input[type="url"]:focus {
|
||||
outline: 2px solid rgba(35, 131, 226, 0.35);
|
||||
border-color: rgba(35, 131, 226, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-url-row > button {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row > button:hover {
|
||||
background: #fdf2f2;
|
||||
}
|
||||
|
||||
.mnote-vault-url-spacer {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mnote-vault-urls-view .mnote-vault-field-value a {
|
||||
color: #2383e2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-group {
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions button {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions button.is-danger {
|
||||
color: #c93a32;
|
||||
border-color: rgba(201, 58, 50, 0.28);
|
||||
}
|
||||
|
||||
.mnote-vault-slot-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 10px 10px;
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
.mnote-vault-slot-empty {
|
||||
font-size: 12px;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
|
||||
|
||||
/* Nested appendix secrets under each account (default collapsed) */
|
||||
.mnote-vault-account-secrets {
|
||||
margin-top: 8px;
|
||||
padding: 8px 8px 6px;
|
||||
border: 1px dashed rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fafaf9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets.is-collapsed {
|
||||
gap: 0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-body[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d6a65;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d6a65;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-toggle:hover {
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-chevron {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: #9b9a97;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-count {
|
||||
font-weight: 500;
|
||||
color: #9b9a97;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-head button:not(.mnote-vault-secrets-toggle) {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-empty {
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret {
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-head button.is-danger {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(201, 58, 50, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #c93a32;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Cipher book plain input (visible while typing) */
|
||||
.mnote-vault-cipher-add input.mnote-vault-secret-input-plain,
|
||||
.mnote-vault-cipher-add input[type="text"] {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user