advance 1-8 post-mvp execution batches
This commit is contained in:
@@ -106,15 +106,19 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub fn apply_response_headers(&self, headers: &mut HeaderMap) {
|
||||
self.apply_trace_response_headers(headers);
|
||||
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
|
||||
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
|
||||
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_trace_response_headers(&self, headers: &mut HeaderMap) {
|
||||
insert_header(headers, HEADER_REQUEST_ID, &self.trace.request_id);
|
||||
insert_header(headers, HEADER_TRACE_ID, &self.trace.trace_id);
|
||||
if let Some(workspace_id) = &self.workspace.workspace_id {
|
||||
insert_header(headers, HEADER_WORKSPACE_ID, workspace_id);
|
||||
}
|
||||
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
|
||||
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
|
||||
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,19 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
@@ -693,29 +693,7 @@ pub(crate) fn ensure_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn ensure_leaf_block(
|
||||
|
||||
@@ -375,6 +375,8 @@ fn page_get_tool() -> Value {
|
||||
"description": "读取当前页面 Page Aggregate 摘要",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod manifest;
|
||||
pub mod page;
|
||||
pub mod resource;
|
||||
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -145,4 +147,163 @@ impl ToolCallInput {
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn command_context_bridge(&self) -> Option<CommandContextBridge> {
|
||||
let ctx = self.args.as_ref().and_then(|args| {
|
||||
args.get("commandContext")
|
||||
.or_else(|| args.get("command_context"))
|
||||
})?;
|
||||
let workspace_readonly = ctx
|
||||
.get("workspace.readonly")
|
||||
.or_else(|| ctx.get("workspaceReadonly"))
|
||||
.or_else(|| ctx.get("workspace_readonly"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let ai_can_write = ctx
|
||||
.get("ai.canWrite")
|
||||
.or_else(|| ctx.get("aiCanWrite"))
|
||||
.or_else(|| ctx.get("ai_can_write"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
Some(CommandContextBridge {
|
||||
workspace_readonly,
|
||||
ai_can_write,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
|
||||
///
|
||||
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
|
||||
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
|
||||
///
|
||||
/// 设计意图:`CommandContext.ai.canWrite` / `workspace.readonly` 是从 tree/workspace/editor
|
||||
/// 状态推导的写权限口径,与 `args.aiAccessScope.permissionLevel`(AI 客户端声明的权限)是
|
||||
/// 两个独立的信息源。桥接合并两者,任一拒绝则阻止写入。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CommandContextBridge {
|
||||
pub workspace_readonly: bool,
|
||||
pub ai_can_write: bool,
|
||||
}
|
||||
|
||||
/// 统一的 hermes_tools 写入守卫。检查:
|
||||
///
|
||||
/// - `idempotencyKey` 必须存在
|
||||
/// - `dryRun` 必须显式携带
|
||||
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput)
|
||||
/// - 如果提供了 `bridge` 且 `ai_can_write == false`,拒绝
|
||||
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
|
||||
///
|
||||
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
|
||||
pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if let Some(bridge) = input.command_context_bridge() {
|
||||
if bridge.workspace_readonly {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_workspace_readonly",
|
||||
"当前 workspace 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if !bridge.ai_can_write {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_write_forbidden",
|
||||
"CommandContext 标记 AI 写入未授权,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, Method};
|
||||
use serde_json::json;
|
||||
|
||||
fn context() -> RequestContext {
|
||||
RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_input(args: Value) -> ToolCallInput {
|
||||
ToolCallInput {
|
||||
tool_name: "mnote.page.save".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
document_id: Some("doc_1".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/mnote".into()),
|
||||
actor_id: Some("user_1".into()),
|
||||
profile: None,
|
||||
session_id: Some("sess_1".into()),
|
||||
run_id: Some("run_1".into()),
|
||||
tool_call_id: Some("tool_1".into()),
|
||||
trace_id: Some("trace_1".into()),
|
||||
idempotency_key: Some("idem_1".into()),
|
||||
dry_run: Some(false),
|
||||
capability_scope: None,
|
||||
args: Some(args),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_write_authorized_rejects_command_context_ai_cannot_write() {
|
||||
let error = ensure_write_authorized(
|
||||
&context(),
|
||||
&write_input(json!({
|
||||
"commandContext": {
|
||||
"ai.canWrite": false,
|
||||
"workspace.readonly": false
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect_err("ai.canWrite=false should reject write tools");
|
||||
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
|
||||
let error = ensure_write_authorized(
|
||||
&context(),
|
||||
&write_input(json!({
|
||||
"commandContext": {
|
||||
"ai.canWrite": true,
|
||||
"workspace.readonly": true
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect_err("workspace.readonly=true should reject write tools");
|
||||
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,29 +72,7 @@ pub async fn page_get(
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
pub async fn page_save(
|
||||
|
||||
@@ -285,29 +285,7 @@ fn ensure_resource_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote resource tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote resource tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
|
||||
@@ -9,6 +9,10 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
if context.trace.path == "/api/auth" {
|
||||
context.apply_trace_response_headers(response.headers_mut());
|
||||
} else {
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
|
||||
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
|
||||
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
|
||||
const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
||||
const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -121,9 +123,12 @@ pub async fn auth_api(
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
||||
}
|
||||
if action == "auth:signOut" {
|
||||
return Ok(build_sign_out_response(&context));
|
||||
}
|
||||
|
||||
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
|
||||
Ok(build_auth_proxy_response(&convex_response, &context))
|
||||
Ok(build_auth_proxy_response(&convex_response, &context, &payload))
|
||||
}
|
||||
|
||||
pub async fn auth_entry(
|
||||
@@ -1625,6 +1630,7 @@ async fn run_convex_auth_action(
|
||||
fn build_auth_proxy_response(
|
||||
convex_response: &serde_json::Value,
|
||||
context: &RequestContext,
|
||||
request_payload: &serde_json::Value,
|
||||
) -> Response {
|
||||
if convex_response
|
||||
.get("status")
|
||||
@@ -1639,7 +1645,7 @@ fn build_auth_proxy_response(
|
||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||
clear_auth_cookies(response.headers_mut());
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
apply_trace_response_headers(context, response.headers_mut());
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1679,14 +1685,46 @@ fn build_auth_proxy_response(
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
|
||||
}
|
||||
if let Some(email) = resolve_mnote_actor_email(&value, tokens, request_payload) {
|
||||
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, &email);
|
||||
}
|
||||
if let Some(name) = resolve_mnote_actor_name(&value, tokens, request_payload) {
|
||||
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, &name);
|
||||
}
|
||||
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
}
|
||||
}
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
apply_trace_response_headers(context, response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
fn build_sign_out_response(context: &RequestContext) -> Response {
|
||||
let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response();
|
||||
clear_auth_cookies(response.headers_mut());
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
apply_trace_response_headers(context, response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) {
|
||||
insert_response_header(headers, "x-request-id", &context.trace.request_id);
|
||||
insert_response_header(headers, "x-trace-id", &context.trace.trace_id);
|
||||
if let Some(workspace_id) = &context.workspace.workspace_id {
|
||||
insert_response_header(headers, "x-mnote-workspace-id", workspace_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) {
|
||||
let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_str(value) else {
|
||||
return;
|
||||
};
|
||||
headers.insert(name, value);
|
||||
}
|
||||
|
||||
fn resolve_mnote_actor_id(
|
||||
value: &serde_json::Value,
|
||||
tokens: &serde_json::Value,
|
||||
@@ -1735,6 +1773,69 @@ fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_mnote_actor_email(
|
||||
value: &serde_json::Value,
|
||||
tokens: &serde_json::Value,
|
||||
request_payload: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
[
|
||||
"/email",
|
||||
"/user/email",
|
||||
"/profile/email",
|
||||
"/args/params/email",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
|
||||
.or_else(|| {
|
||||
tokens
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(extract_email_from_jwt)
|
||||
})
|
||||
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/email")))
|
||||
}
|
||||
|
||||
fn resolve_mnote_actor_name(
|
||||
value: &serde_json::Value,
|
||||
tokens: &serde_json::Value,
|
||||
request_payload: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
["/name", "/user/name", "/profile/name", "/args/params/name"]
|
||||
.iter()
|
||||
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
|
||||
.or_else(|| {
|
||||
tokens
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(extract_name_from_jwt)
|
||||
})
|
||||
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/name")))
|
||||
}
|
||||
|
||||
fn extract_email_from_jwt(token: &str) -> Option<String> {
|
||||
jwt_string_claim(token, &["email", "preferred_username"])
|
||||
}
|
||||
|
||||
fn extract_name_from_jwt(token: &str) -> Option<String> {
|
||||
jwt_string_claim(token, &["name", "username"])
|
||||
}
|
||||
|
||||
fn jwt_string_claim(token: &str, keys: &[&str]) -> Option<String> {
|
||||
let payload_segment = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
|
||||
let payload: Value = serde_json::from_slice(&decoded).ok()?;
|
||||
keys.iter()
|
||||
.find_map(|key| non_empty_json_string(payload.get(key)))
|
||||
}
|
||||
|
||||
fn non_empty_json_string(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
||||
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
||||
@@ -1742,6 +1843,11 @@ fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, v
|
||||
}
|
||||
}
|
||||
|
||||
fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
||||
let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes());
|
||||
set_literal_cookie(headers, name, &encoded);
|
||||
}
|
||||
|
||||
fn set_auth_cookie_from_value(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
name: &'static str,
|
||||
@@ -1762,6 +1868,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
|
||||
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME);
|
||||
}
|
||||
|
||||
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
|
||||
@@ -2623,9 +2731,9 @@ mod tests {
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(!html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(html.contains(r#""transport":"disabled""#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
@@ -2797,7 +2905,9 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-mnote-shell="auth""#));
|
||||
assert!(html.contains("邮箱登录"));
|
||||
assert!(html.contains("账号登录"));
|
||||
assert!(html.contains("邮箱或用户名"));
|
||||
assert!(!html.contains(r#"<span>"用户名"</span>"#));
|
||||
assert!(html.contains("测试账号快速登录"));
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
|
||||
const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun";
|
||||
const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent";
|
||||
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
|
||||
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
|
||||
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
|
||||
|
||||
@@ -90,6 +91,7 @@ struct AcpActiveRun {
|
||||
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
|
||||
mnote_session_id: String,
|
||||
acp_session_id: String,
|
||||
event_tx: broadcast::Sender<crate::acp_bridge::SseEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1484,6 +1486,7 @@ async fn acp_stream_events(
|
||||
manager: Arc::clone(&mgr),
|
||||
mnote_session_id: mnote_session_id.clone(),
|
||||
acp_session_id,
|
||||
event_tx: event_tx.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1567,6 +1570,12 @@ async fn acp_stream_events(
|
||||
{
|
||||
Ok(result) => {
|
||||
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
|
||||
let prompt_cancelled =
|
||||
matches!(result.stop_reason, crate::acp_types::StopReason::Cancelled);
|
||||
let runtime_aborted = matches!(
|
||||
runtime_status_for_run(&run_id_owned).as_deref(),
|
||||
Some("aborting" | "aborted")
|
||||
);
|
||||
let agent_audit = if audit_payload
|
||||
.get("sourceKind")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1590,7 +1599,11 @@ async fn acp_stream_events(
|
||||
Value::Null
|
||||
};
|
||||
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
|
||||
event: "run.completed".into(),
|
||||
event: if prompt_cancelled || runtime_aborted {
|
||||
"run.aborted".into()
|
||||
} else {
|
||||
"run.completed".into()
|
||||
},
|
||||
data: json!({
|
||||
"stopReason": format!("{:?}", result.stop_reason),
|
||||
"agentAudit": agent_audit,
|
||||
@@ -1627,15 +1640,16 @@ async fn acp_stream_events(
|
||||
});
|
||||
}
|
||||
}
|
||||
let was_aborted = matches!(
|
||||
runtime_status_for_run(&run_id_owned).as_deref(),
|
||||
Some("aborting" | "aborted")
|
||||
);
|
||||
mgr_clone.close().await;
|
||||
ACP_ACTIVE_RUNS
|
||||
.lock()
|
||||
.expect("acp active runs")
|
||||
.remove(&run_id_owned);
|
||||
if !matches!(
|
||||
runtime_status_for_run(&run_id_owned).as_deref(),
|
||||
Some("aborting" | "aborted")
|
||||
) {
|
||||
if !was_aborted {
|
||||
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
|
||||
}
|
||||
});
|
||||
@@ -1763,11 +1777,37 @@ pub async fn abort_run(
|
||||
})),
|
||||
));
|
||||
};
|
||||
active.manager.cancel().await.map_err(|error| {
|
||||
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let cancel_result = tokio::time::timeout(
|
||||
Duration::from_millis(ACP_ABORT_NOTIFICATION_TIMEOUT_MS),
|
||||
active.manager.cancel(),
|
||||
)
|
||||
.await;
|
||||
let cancel_status = match cancel_result {
|
||||
Ok(Ok(())) => json!({"ok": true}),
|
||||
Ok(Err(error)) => {
|
||||
warn!(error = ?error, run_id = %run_id, "ACP abort notification failed; marking run aborted");
|
||||
json!({"ok": false, "error": error.to_string()})
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
timeout_ms = ACP_ABORT_NOTIFICATION_TIMEOUT_MS,
|
||||
"ACP abort notification timed out; marking run aborted"
|
||||
);
|
||||
json!({"ok": false, "error": "abort notification timed out"})
|
||||
}
|
||||
};
|
||||
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
|
||||
let _ = active.event_tx.send(crate::acp_bridge::SseEvent {
|
||||
event: "run.aborted".into(),
|
||||
data: json!({
|
||||
"reason": payload
|
||||
.get("reason")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("client_abort"),
|
||||
"cancel": cancel_status.clone(),
|
||||
}),
|
||||
});
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
@@ -1778,6 +1818,7 @@ pub async fn abort_run(
|
||||
"acpSessionId": active.acp_session_id,
|
||||
"status": "aborted",
|
||||
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
|
||||
"cancel": cancel_status,
|
||||
"events": [
|
||||
{"event": "abort.started", "runId": run_id},
|
||||
{"event": "abort.completed", "runId": run_id}
|
||||
|
||||
@@ -306,6 +306,23 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = ensure_tool_capability_scope(&context, &input) {
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message(),
|
||||
"capabilityScope": input.capability_scope
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -449,6 +466,74 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
fn ensure_tool_capability_scope(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
let required = required_capability_scope(&input.tool_name);
|
||||
if required.is_empty()
|
||||
|| declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_capability_scope_forbidden",
|
||||
"调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
||||
}
|
||||
|
||||
fn required_capability_scope(tool_name: &str) -> Vec<String> {
|
||||
manifest::manifest()
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
|
||||
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(normalize_capability_scope)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
|
||||
let Some(declared) = declared else {
|
||||
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
|
||||
return true;
|
||||
};
|
||||
let declared = declared
|
||||
.iter()
|
||||
.map(|value| normalize_capability_scope(value))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
required.iter().all(|scope| {
|
||||
declared
|
||||
.iter()
|
||||
.any(|candidate| capability_scope_satisfies(candidate, scope))
|
||||
})
|
||||
}
|
||||
|
||||
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
|
||||
if candidate == required {
|
||||
return true;
|
||||
}
|
||||
required
|
||||
.strip_suffix(".read")
|
||||
.map(|prefix| format!("{prefix}.write"))
|
||||
.as_deref()
|
||||
== Some(candidate)
|
||||
}
|
||||
|
||||
fn normalize_capability_scope(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_read_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
@@ -964,6 +1049,46 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.save",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_scope",
|
||||
"runId": "run_scope",
|
||||
"toolCallId": "call_scope",
|
||||
"traceId": "trace_scope",
|
||||
"actorId": "user_1",
|
||||
"capabilityScope": ["page.read"],
|
||||
"dryRun": true,
|
||||
"idempotencyKey": "scope-mismatch",
|
||||
"args": {"content": "不会写入"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_capability_scope_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_returns_first_batch_tools() {
|
||||
let response = app()
|
||||
|
||||
@@ -3,37 +3,65 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use axum::response::sse::{Event as SseEvent, Sse};
|
||||
use futures_util::stream;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
type BoxedEventStream = Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalFolderEventsQuery {
|
||||
pub root_uri: String,
|
||||
pub document_id: Option<String>,
|
||||
pub tree_live: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn local_folder_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderEventsQuery>,
|
||||
) -> Result<
|
||||
(
|
||||
HeaderMap,
|
||||
Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>,
|
||||
),
|
||||
WebError,
|
||||
> {
|
||||
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
|
||||
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
|
||||
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
|
||||
build_tree_live_stream(state, context, canonical_root, query.root_uri).await?
|
||||
} else {
|
||||
build_document_events_stream(state, context, canonical_root, &query).await?
|
||||
};
|
||||
|
||||
// Apply keepalive via the same type-erased stream path
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web"));
|
||||
}
|
||||
|
||||
Ok((headers, Sse::new(stream)))
|
||||
}
|
||||
|
||||
/// Build the original document-level external-edit event stream (`ready` / `change`).
|
||||
async fn build_document_events_stream(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
canonical_root: PathBuf,
|
||||
query: &LocalFolderEventsQuery,
|
||||
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
@@ -80,26 +108,129 @@ pub async fn local_folder_events(
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.boxed();
|
||||
|
||||
Ok((HeaderMap::new(), stream))
|
||||
}
|
||||
|
||||
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
|
||||
/// with full sidebar + file tree projections.
|
||||
///
|
||||
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
|
||||
/// No data is written to Convex command log.
|
||||
async fn build_tree_live_stream(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
canonical_root: PathBuf,
|
||||
root_uri: String,
|
||||
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
|
||||
let subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
.map_err(|error| WebError::internal(error).with_context(&context))?;
|
||||
|
||||
let workspace_id = local_workspace_id_from_root_uri(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
|
||||
// Build initial snapshot
|
||||
let sidebar_snapshot = load_local_folder_page_tree_snapshot(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let file_tree_snapshot = load_local_folder_file_tree_snapshot(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
|
||||
let initial_payload = build_tree_snapshot_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
&revision.revision,
|
||||
"snapshot",
|
||||
&sidebar_snapshot,
|
||||
&file_tree_snapshot,
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web"));
|
||||
}
|
||||
let stream = stream::unfold(
|
||||
(Some(initial_payload), subscription, root_uri, workspace_id),
|
||||
|(payload, mut subscription, root_uri, workspace_id)| async move {
|
||||
if let Some(payload) = payload {
|
||||
return Some((
|
||||
Ok(stream_event("snapshot", &payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
),
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(_watcher_payload) => {
|
||||
// Rebuild full snapshot on any filesystem change
|
||||
if let Some(resync_payload) =
|
||||
rebuild_tree_resync_payload(&root_uri, &workspace_id)
|
||||
{
|
||||
return Some((
|
||||
Ok(stream_event("resync", &resync_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
// Snapshot load failed — continue waiting for next change
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.boxed();
|
||||
|
||||
Ok((HeaderMap::new(), stream))
|
||||
}
|
||||
|
||||
fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Value> {
|
||||
let revision = local_folder_watch_revision(root_uri).ok()?;
|
||||
let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?;
|
||||
let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?;
|
||||
Some(build_tree_snapshot_payload(
|
||||
root_uri,
|
||||
workspace_id,
|
||||
&revision.revision,
|
||||
"resync",
|
||||
&sidebar_snapshot,
|
||||
&file_tree_snapshot,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_tree_snapshot_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
revision: &str,
|
||||
kind: &str,
|
||||
sidebar_snapshot: &ProjectionSnapshot,
|
||||
file_tree_snapshot: &ProjectionSnapshot,
|
||||
) -> Value {
|
||||
let dataset = json!({
|
||||
"kernel_sidebar_projection": sidebar_snapshot.projection,
|
||||
"kernelSidebarProjection": sidebar_snapshot.projection,
|
||||
"kernel_file_tree_projection": file_tree_snapshot.projection,
|
||||
"kernelFileTreeProjection": file_tree_snapshot.projection,
|
||||
});
|
||||
|
||||
json!({
|
||||
"kind": kind,
|
||||
"revision": revision,
|
||||
"stream": "workspace",
|
||||
"projection": "sidebar_tree",
|
||||
"scope": "workspace",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"data": {
|
||||
"dataset": dataset,
|
||||
"tree": sidebar_snapshot.projection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
let trimmed = document_id.trim();
|
||||
let encoded = trimmed.strip_prefix("local-md:")?;
|
||||
@@ -132,6 +263,101 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn test_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-folder-events-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
fn app_with_local_workspace(root: &std::path::Path) -> axum::Router {
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri)
|
||||
.expect("init local workspace");
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a root_uri query-parameter-safe by percent-encoding (no external crate).
|
||||
fn encoded_root_uri(raw: &str) -> String {
|
||||
raw.replace('%', "%25")
|
||||
.replace(':', "%3A")
|
||||
.replace('/', "%2F")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_live_headers_include_mnote_web_owner() {
|
||||
let root = test_root("tree-live-headers");
|
||||
std::fs::write(root.join("test.md"), "# Test\n").expect("write test");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let encoded = encoded_root_uri(&root_uri);
|
||||
|
||||
let app = app_with_local_workspace(&root);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/local-folder/events?rootUri={encoded}&treeLive=true"
|
||||
))
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("mnote-web"),
|
||||
"response should have x-mnote-web-owner header"
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-local-folder-events-owner")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("rust-web"),
|
||||
"response should have x-mnote-local-folder-events-owner header"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_document_id_maps_to_relative_path() {
|
||||
@@ -140,4 +366,52 @@ mod tests {
|
||||
Some("docs/README.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tree_snapshot_payload_has_required_fields() {
|
||||
let sidebar_projection = json!({
|
||||
"projection": "page_tree",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"watchRevision": "abc123",
|
||||
"items": [],
|
||||
});
|
||||
let file_tree_projection = json!({
|
||||
"projection": "file_tree",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"watchRevision": "abc123",
|
||||
"items": [],
|
||||
});
|
||||
let sidebar = ProjectionSnapshot {
|
||||
dataset: json!({}),
|
||||
projection: sidebar_projection,
|
||||
};
|
||||
let file_tree = ProjectionSnapshot {
|
||||
dataset: json!({}),
|
||||
projection: file_tree_projection,
|
||||
};
|
||||
|
||||
let payload = build_tree_snapshot_payload(
|
||||
"file:///test",
|
||||
"local:test_workspace",
|
||||
"rev_1",
|
||||
"snapshot",
|
||||
&sidebar,
|
||||
&file_tree,
|
||||
);
|
||||
|
||||
assert_eq!(payload["kind"], "snapshot");
|
||||
assert_eq!(payload["stream"], "workspace");
|
||||
assert_eq!(payload["projection"], "sidebar_tree");
|
||||
assert_eq!(payload["sourceKind"], "local_folder");
|
||||
assert_eq!(payload["rootUri"], "file:///test");
|
||||
assert_eq!(payload["workspaceId"], "local:test_workspace");
|
||||
assert_eq!(payload["revision"], "rev_1");
|
||||
assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object());
|
||||
assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object());
|
||||
assert!(payload["data"]["dataset"]["kernelSidebarProjection"].is_object());
|
||||
assert!(payload["data"]["dataset"]["kernelFileTreeProjection"].is_object());
|
||||
assert!(payload["data"]["tree"].is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde::Serialize;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
||||
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -63,6 +67,18 @@ pub async fn refresh_session(
|
||||
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
|
||||
let actor_email = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_EMAIL))
|
||||
.or_else(|| jwt_cookie_claim(&context, &["email", "preferred_username"]));
|
||||
let actor_name = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME))
|
||||
.or_else(|| jwt_cookie_claim(&context, &["name", "username"]));
|
||||
let user_id = if has_forwarded_actor {
|
||||
actor_id.to_string()
|
||||
} else {
|
||||
@@ -79,12 +95,12 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
owner: "mnote-web",
|
||||
user_id,
|
||||
email: if has_forwarded_actor {
|
||||
String::new()
|
||||
actor_email.unwrap_or_default()
|
||||
} else {
|
||||
state.config().dev_user_email.clone()
|
||||
},
|
||||
name: if has_forwarded_actor {
|
||||
actor_id.to_string()
|
||||
actor_name.unwrap_or_else(|| actor_id.to_string())
|
||||
} else {
|
||||
state.config().dev_user_name.clone()
|
||||
},
|
||||
@@ -99,6 +115,50 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
}
|
||||
}
|
||||
|
||||
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
|
||||
let token = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| raw_cookie_value(cookies, COOKIE_CONVEX_AUTH_JWT))?;
|
||||
let payload_segment = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
|
||||
keys.iter().find_map(|key| {
|
||||
payload
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn encoded_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
|
||||
let encoded = raw_cookie_value(cookie_header, name)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
|
||||
String::from_utf8(decoded)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn raw_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
|
||||
cookie_header.split(';').find_map(|part| {
|
||||
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
||||
if cookie_name.trim() == name {
|
||||
let value = cookie_value.trim();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn owner_json<T>(payload: Json<T>) -> Response
|
||||
where
|
||||
T: Serialize,
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn AuthPage() -> impl IntoView {
|
||||
</a>
|
||||
<section class="mnote-auth-panel" aria-labelledby="mnote-auth-title">
|
||||
<div class="mnote-auth-heading">
|
||||
<h1 id="mnote-auth-title">"邮箱登录"</h1>
|
||||
<h1 id="mnote-auth-title">"账号登录"</h1>
|
||||
<p>"登录后进入你的工作区"</p>
|
||||
</div>
|
||||
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
|
||||
@@ -25,24 +25,14 @@ pub fn AuthPage() -> impl IntoView {
|
||||
<input type="hidden" name="provider" value="password" />
|
||||
<input type="hidden" name="flow" value="signIn" data-auth-flow />
|
||||
<label class="mnote-auth-field">
|
||||
<span>"邮箱"</span>
|
||||
<span data-auth-account-label>"邮箱或用户名"</span>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="mnote-auth-field mnote-auth-username" hidden>
|
||||
<span>"用户名"</span>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
id="account"
|
||||
name="account"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="用于显示的用户名"
|
||||
placeholder="请输入邮箱或用户名"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="mnote-auth-field">
|
||||
@@ -85,8 +75,8 @@ const AUTH_SCRIPT: &str = r#"
|
||||
if (!root) return;
|
||||
var form = root.querySelector('form[data-auth-mode="convex-password"]');
|
||||
var flowInput = root.querySelector('[data-auth-flow]');
|
||||
var usernameField = root.querySelector('.mnote-auth-username');
|
||||
var usernameInput = root.querySelector('#username');
|
||||
var accountInput = root.querySelector('#account');
|
||||
var accountLabel = root.querySelector('[data-auth-account-label]');
|
||||
var title = root.querySelector('#mnote-auth-title');
|
||||
var subtitle = root.querySelector('.mnote-auth-heading p');
|
||||
var submit = root.querySelector('[data-auth-submit]');
|
||||
@@ -110,19 +100,54 @@ const AUTH_SCRIPT: &str = r#"
|
||||
var isSignUp = nextFlow === 'signUp';
|
||||
flowInput.value = nextFlow;
|
||||
switcher.dataset.flow = nextFlow;
|
||||
if (title) title.textContent = isSignUp ? '创建账号' : '邮箱登录';
|
||||
if (title) title.textContent = isSignUp ? '创建账号' : '账号登录';
|
||||
if (subtitle) subtitle.textContent = isSignUp ? '创建账号后进入你的工作区' : '登录后进入你的工作区';
|
||||
submit.textContent = isSignUp ? '注册并登录' : '登录';
|
||||
switcher.textContent = isSignUp ? '已有账号?登录' : '没有账号?注册';
|
||||
if (usernameField) usernameField.hidden = !isSignUp;
|
||||
if (usernameInput) usernameInput.required = isSignUp;
|
||||
if (accountLabel) accountLabel.textContent = isSignUp ? '邮箱' : '邮箱或用户名';
|
||||
if (accountInput) {
|
||||
accountInput.placeholder = isSignUp ? '请输入邮箱' : '请输入邮箱或用户名';
|
||||
accountInput.autocomplete = isSignUp ? 'email' : 'username';
|
||||
}
|
||||
quickLogin.hidden = isSignUp;
|
||||
var passwordInput = root.querySelector('#password');
|
||||
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
|
||||
setMessage('', '');
|
||||
}
|
||||
|
||||
function buildPayload(flow, email, password, username) {
|
||||
function accountMap() {
|
||||
try {
|
||||
return JSON.parse(window.localStorage.getItem('mnote.auth.accountEmailByName') || '{}') || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function rememberAccount(email, name) {
|
||||
var normalizedEmail = String(email || '').trim();
|
||||
var normalizedName = String(name || '').trim();
|
||||
if (!normalizedEmail || !normalizedName || normalizedName.indexOf('@') !== -1) return;
|
||||
try {
|
||||
var map = accountMap();
|
||||
map[normalizedName.toLowerCase()] = normalizedEmail;
|
||||
window.localStorage.setItem('mnote.auth.accountEmailByName', JSON.stringify(map));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function resolveAccountEmail(account) {
|
||||
var normalized = String(account || '').trim();
|
||||
if (normalized.indexOf('@') !== -1) return normalized;
|
||||
var mapped = accountMap()[normalized.toLowerCase()];
|
||||
return String(mapped || normalized).trim();
|
||||
}
|
||||
|
||||
function defaultNameFromEmail(email) {
|
||||
return String(email || '').split('@')[0].trim();
|
||||
}
|
||||
|
||||
function buildPayload(flow, account, password) {
|
||||
var email = flow === 'signIn' ? resolveAccountEmail(account) : String(account || '').trim();
|
||||
var name = defaultNameFromEmail(email);
|
||||
var payload = {
|
||||
action: 'auth:signIn',
|
||||
args: {
|
||||
@@ -134,23 +159,25 @@ const AUTH_SCRIPT: &str = r#"
|
||||
}
|
||||
}
|
||||
};
|
||||
if (flow === 'signUp' && username) {
|
||||
payload.args.params.name = username;
|
||||
if (name) {
|
||||
payload.args.params.name = name;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuth(flow, email, password, username) {
|
||||
async function requestAuth(flow, account, password) {
|
||||
var response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(buildPayload(flow, email, password, username))
|
||||
body: JSON.stringify(buildPayload(flow, account, password))
|
||||
});
|
||||
var data = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || '登录失败,请重试');
|
||||
}
|
||||
var params = buildPayload(flow, account, password).args.params;
|
||||
rememberAccount(params.email, params.name);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -160,18 +187,21 @@ const AUTH_SCRIPT: &str = r#"
|
||||
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
var email = String((root.querySelector('#email') || {}).value || '').trim();
|
||||
var account = String((accountInput || {}).value || '').trim();
|
||||
var password = String((root.querySelector('#password') || {}).value || '');
|
||||
var username = String((usernameInput || {}).value || '').trim();
|
||||
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
|
||||
if (!email || !password || (flow === 'signUp' && !username)) {
|
||||
if (!account || !password) {
|
||||
setMessage('请完整填写信息', 'error');
|
||||
return;
|
||||
}
|
||||
if (flow === 'signUp' && account.indexOf('@') === -1) {
|
||||
setMessage('注册时请填写邮箱;登录时可输入邮箱或已记住的用户名', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(flow === 'signUp' ? '正在创建账号...' : '正在登录...', 'info');
|
||||
try {
|
||||
await requestAuth(flow, email, password, username);
|
||||
await requestAuth(flow, account, password);
|
||||
setMessage('登录成功,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
} catch (error) {
|
||||
@@ -189,15 +219,15 @@ const AUTH_SCRIPT: &str = r#"
|
||||
return;
|
||||
}
|
||||
|
||||
var emailInput = root.querySelector('#email');
|
||||
var passwordInput = root.querySelector('#password');
|
||||
if (emailInput) emailInput.value = email;
|
||||
if (accountInput) accountInput.value = email;
|
||||
if (passwordInput) passwordInput.value = password;
|
||||
|
||||
setBusy(true);
|
||||
setMessage('正在登录测试账号...', 'info');
|
||||
try {
|
||||
await requestAuth('signIn', email, password, username);
|
||||
await requestAuth('signIn', email, password);
|
||||
rememberAccount(email, username);
|
||||
setMessage('登录成功,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
return;
|
||||
@@ -206,7 +236,8 @@ const AUTH_SCRIPT: &str = r#"
|
||||
}
|
||||
|
||||
try {
|
||||
await requestAuth('signUp', email, password, username);
|
||||
await requestAuth('signUp', email, password);
|
||||
rememberAccount(email, username);
|
||||
setMessage('测试账号已创建,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
} catch (error) {
|
||||
|
||||
@@ -61,30 +61,7 @@ pub fn HomePage(
|
||||
</main>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<section class="mnote-workspace-empty-state" data-testid="mnote-workspace-empty-state">
|
||||
<h1>"暂无页面"</h1>
|
||||
<p>"当前还没有可显示的本地工作区。"</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-create-default-local-workspace"
|
||||
data-mnote-action="create-local-workspace"
|
||||
>"创建我的空间"</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-open-local-folder-empty"
|
||||
data-mnote-action="open-local-folder"
|
||||
>"打开本地文件夹"</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-empty-create-page"
|
||||
data-mnote-action="create-local-workspace"
|
||||
>"新建页面"</button>
|
||||
</section>
|
||||
}.into_any()
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</PageLayout>
|
||||
}
|
||||
|
||||
@@ -1214,6 +1214,110 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu);
|
||||
}
|
||||
|
||||
function closeAccountMenu() {
|
||||
var existing = document.querySelector('[data-testid="mnote-account-menu"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
document.querySelectorAll('[data-testid="mnote-account-menu-trigger"]').forEach(function(trigger) {
|
||||
if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function accountMenuFallbackSession() {
|
||||
var body = document.body instanceof HTMLElement ? document.body : null;
|
||||
var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
||||
return {
|
||||
ok: true,
|
||||
name: actorId || '当前用户',
|
||||
email: '',
|
||||
userId: actorId || 'anonymous',
|
||||
actorType: actorId && actorId !== 'anonymous' ? 'user' : 'anonymous',
|
||||
authMode: 'browser'
|
||||
};
|
||||
}
|
||||
|
||||
function renderAccountInfo(menu, session) {
|
||||
var nameNode = menu.querySelector('[data-account-info="name"]');
|
||||
var emailNode = menu.querySelector('[data-account-info="email"]');
|
||||
var idNode = menu.querySelector('[data-account-info="user-id"]');
|
||||
var typeNode = menu.querySelector('[data-account-info="actor-type"]');
|
||||
if (nameNode) nameNode.textContent = session.name || session.userId || '当前用户';
|
||||
if (emailNode) emailNode.textContent = session.email || '未提供邮箱';
|
||||
if (idNode) idNode.textContent = session.userId || 'anonymous';
|
||||
if (typeNode) typeNode.textContent = session.actorType || session.authMode || 'unknown';
|
||||
}
|
||||
|
||||
async function loadAccountInfo(menu) {
|
||||
try {
|
||||
var response = await fetch('/api/auth/session', {
|
||||
method: 'GET',
|
||||
headers: { 'accept': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload) throw new Error('session_failed_' + response.status);
|
||||
renderAccountInfo(menu, payload);
|
||||
} catch (_) {
|
||||
renderAccountInfo(menu, accountMenuFallbackSession());
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAccount(trigger) {
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
var response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ action: 'auth:signOut', args: {} })
|
||||
});
|
||||
var payload = await response.json().catch(function() { return {}; });
|
||||
payload = payload || {};
|
||||
if (!response.ok || payload.error) {
|
||||
throw new Error(payload.error || '退出登录失败');
|
||||
}
|
||||
window.location.assign('/auth');
|
||||
} catch (error) {
|
||||
var menu = document.querySelector('[data-testid="mnote-account-menu"]');
|
||||
var errorNode = menu ? menu.querySelector('[data-account-error]') : null;
|
||||
if (errorNode instanceof HTMLElement) {
|
||||
errorNode.textContent = error && error.message ? error.message : '退出登录失败';
|
||||
errorNode.hidden = false;
|
||||
}
|
||||
setCommandPending(trigger, false);
|
||||
}
|
||||
}
|
||||
|
||||
function openAccountMenu(trigger) {
|
||||
closeAccountMenu();
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'mnote-workspace-source-menu mnote-account-menu';
|
||||
menu.setAttribute('data-testid', 'mnote-account-menu');
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.innerHTML =
|
||||
'<div class="mnote-account-menu__profile" role="group" aria-label="个人信息">' +
|
||||
'<div class="mnote-account-menu__title">个人设置</div>' +
|
||||
'<div class="mnote-account-menu__name" data-account-info="name">正在读取...</div>' +
|
||||
'<div class="mnote-account-menu__row"><span>邮箱</span><strong data-account-info="email">正在读取...</strong></div>' +
|
||||
'<div class="mnote-account-menu__row"><span>用户 ID</span><strong data-account-info="user-id">正在读取...</strong></div>' +
|
||||
'<div class="mnote-account-menu__row"><span>身份</span><strong data-account-info="actor-type">正在读取...</strong></div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-account-menu__separator" aria-hidden="true"></div>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
|
||||
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
|
||||
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
|
||||
if (signOutButton) {
|
||||
signOutButton.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
void signOutAccount(signOutButton);
|
||||
});
|
||||
}
|
||||
var host = trigger.closest('[data-testid="wolai-sidebar-quick-actions"]') || trigger.parentElement;
|
||||
if (host) host.appendChild(menu);
|
||||
void loadAccountInfo(menu);
|
||||
}
|
||||
|
||||
autoOpenRecentLocalRootOnHome();
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
@@ -1825,7 +1929,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
var activeId = currentDocumentId();
|
||||
var activeRowId = currentFileTreeActiveRowId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1934,7 +2038,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var rows = projectionItems(projection);
|
||||
var activeId = currentDocumentId();
|
||||
var activeRowId = currentFileTreeActiveRowId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>') + '</ul>';
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2006,6 +2110,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
};
|
||||
var poll = async function() {
|
||||
if (document.hidden) return;
|
||||
// If tree live SSE transport is active for local_folder, skip polling (fallback)
|
||||
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
||||
if (treeTransport === 'local-folder-events') return;
|
||||
var url = new URL('/api/tree/local-folder-watch', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
||||
@@ -8864,6 +8971,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var sourceMenu = closestAction(e.target, '[data-testid="mnote-workspace-source-menu"]');
|
||||
if (!sourceMenu) closeWorkspaceSourceMenu();
|
||||
|
||||
var accountMenuTrigger = closestAction(e.target, '[data-mnote-action="open-account-menu"]');
|
||||
if (accountMenuTrigger) {
|
||||
e.preventDefault();
|
||||
var existingAccountMenu = document.querySelector('[data-testid="mnote-account-menu"]');
|
||||
if (existingAccountMenu) closeAccountMenu();
|
||||
else openAccountMenu(accountMenuTrigger);
|
||||
return;
|
||||
}
|
||||
var accountMenu = closestAction(e.target, '[data-testid="mnote-account-menu"]');
|
||||
if (!accountMenu) closeAccountMenu();
|
||||
|
||||
var trashTrigger = closestAction(e.target, '[data-mnote-action="open-trash-modal"]');
|
||||
if (trashTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -9706,6 +9824,18 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sourceKind = (params.get('sourceKind') || '').trim();
|
||||
if (sourceKind === 'local_folder') {
|
||||
applyTransport('local-folder-events');
|
||||
applyStatus('connecting');
|
||||
var localRootUri = (params.get('rootUri') || '').trim()
|
||||
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
|
||||
if (localRootUri && 'EventSource' in window) {
|
||||
var url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', localRootUri);
|
||||
url.searchParams.set('treeLive', 'true');
|
||||
startWithSse(bootstrap, '', url);
|
||||
return;
|
||||
}
|
||||
// No rootUri or EventSource unavailable — mark static and let polling fallback handle it
|
||||
applyTransport('local-folder-static');
|
||||
applyStatus('static');
|
||||
return;
|
||||
@@ -9841,7 +9971,7 @@ pub fn PageLayout(
|
||||
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作" data-testid="wolai-sidebar-quick-actions">
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
|
||||
@@ -9863,7 +9993,17 @@ pub fn PageLayout(
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
<a href="/more" title="更多" aria-label="更多"><span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span></a>
|
||||
<button
|
||||
type="button"
|
||||
title="更多"
|
||||
aria-label="更多"
|
||||
data-testid="mnote-account-menu-trigger"
|
||||
data-mnote-action="open-account-menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
|
||||
|
||||
@@ -338,12 +338,81 @@ a:hover {
|
||||
color: #1D4ED8;
|
||||
}
|
||||
|
||||
.mnote-account-menu {
|
||||
inset-inline-start: auto;
|
||||
right: 8px;
|
||||
top: calc(100% - 10px);
|
||||
width: 232px;
|
||||
}
|
||||
|
||||
.mnote-account-menu__profile {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mnote-account-menu__title {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mnote-account-menu__name {
|
||||
margin-top: 5px;
|
||||
color: var(--wolai-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-account-menu__row {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mnote-account-menu__row strong {
|
||||
min-width: 0;
|
||||
color: var(--wolai-text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-account-menu__separator {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--wolai-border);
|
||||
}
|
||||
|
||||
.mnote-account-menu__logout {
|
||||
color: #C2410C;
|
||||
}
|
||||
|
||||
.mnote-account-menu__logout:hover {
|
||||
background: #FFF7ED;
|
||||
}
|
||||
|
||||
.mnote-account-menu__error {
|
||||
padding: 6px 8px 2px;
|
||||
color: #B91C1C;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wolai-quick-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 12px 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a,
|
||||
@@ -403,22 +472,6 @@ a:hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.mnote-empty-create-page {
|
||||
margin-top: 14px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--wolai-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-empty-create-page:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.wolai-page-row {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
@@ -1519,8 +1572,7 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-empty,
|
||||
.wolai-sidebar-empty {
|
||||
.sidebar-tree .tree-empty {
|
||||
padding: 6px 12px;
|
||||
color: var(--atelier-text-soft);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -129,9 +129,6 @@ pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> Strin
|
||||
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
|
||||
);
|
||||
if input.rows.is_empty() {
|
||||
html.push_str(
|
||||
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>"#,
|
||||
);
|
||||
html.push_str("</ul>");
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -161,9 +161,6 @@ pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> Stri
|
||||
let mut html =
|
||||
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
|
||||
if input.rows.is_empty() {
|
||||
html.push_str(
|
||||
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>"#,
|
||||
);
|
||||
html.push_str("</ul>");
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -391,7 +391,8 @@ mod tests {
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None);
|
||||
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
assert!(!html.contains("暂无页面"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,15 +472,14 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-filetree-empty-state">暂无文件或页面</div>"#.to_string()
|
||||
String::new()
|
||||
});
|
||||
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
|
||||
let my_pages = if !tree_html.is_empty() {
|
||||
tree_html
|
||||
} else if !projected_my_pages.is_empty() {
|
||||
projected_my_pages
|
||||
} else {
|
||||
empty_state.to_string()
|
||||
String::new()
|
||||
};
|
||||
let bottom_entries = projection
|
||||
.bottom_entries
|
||||
|
||||
Reference in New Issue
Block a user