清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。 新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。 扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。 验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
274 lines
9.3 KiB
Rust
274 lines
9.3 KiB
Rust
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
|
|
use serde::Serialize;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
|
|
const HEADER_REQUEST_ID: &str = "x-request-id";
|
|
const HEADER_TRACE_ID: &str = "x-trace-id";
|
|
const HEADER_TENANT_ID: &str = "x-mnote-tenant-id";
|
|
const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
|
|
const HEADER_DEPLOYMENT_ID: &str = "x-mnote-deployment-id";
|
|
const HEADER_PROJECT_ID: &str = "x-mnote-project-id";
|
|
const HEADER_ACTOR_ID: &str = "x-mnote-actor-id";
|
|
const HEADER_ACTOR_TYPE: &str = "x-mnote-actor-type";
|
|
const HEADER_SESSION_ID: &str = "x-mnote-session-id";
|
|
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
|
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
|
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
|
const COOKIE_ACTOR_ID: &str = "mnote_actor_id";
|
|
const COOKIE_ACTOR_TYPE: &str = "mnote_actor_type";
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TraceContext {
|
|
pub request_id: String,
|
|
pub trace_id: String,
|
|
pub method: String,
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthContext {
|
|
pub authorization: Option<String>,
|
|
pub cookie_header: Option<String>,
|
|
pub actor_id: String,
|
|
pub actor_type: String,
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct WorkspaceContext {
|
|
pub workspace_id: Option<String>,
|
|
pub tenant_id: Option<String>,
|
|
pub deployment_id: Option<String>,
|
|
pub project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SourceContext {
|
|
pub channel: String,
|
|
pub client: String,
|
|
pub idempotency_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RequestContext {
|
|
pub trace: TraceContext,
|
|
pub auth: AuthContext,
|
|
pub workspace: WorkspaceContext,
|
|
pub source: SourceContext,
|
|
}
|
|
|
|
impl RequestContext {
|
|
pub fn from_http_parts(method: &Method, uri: &Uri, headers: &HeaderMap) -> Self {
|
|
let request_id = header_or_generated(headers, HEADER_REQUEST_ID, "req");
|
|
let trace_id = header_or_generated(headers, HEADER_TRACE_ID, "trace");
|
|
|
|
Self {
|
|
trace: TraceContext {
|
|
request_id,
|
|
trace_id,
|
|
method: method.as_str().to_string(),
|
|
path: uri.path().to_string(),
|
|
},
|
|
auth: AuthContext {
|
|
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
|
|
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
|
|
actor_id: header_value(headers, HEADER_ACTOR_ID)
|
|
.or_else(|| cookie_value(headers, COOKIE_ACTOR_ID))
|
|
.and_then(|value| stable_actor_id(&value))
|
|
.unwrap_or_else(|| "anonymous".into()),
|
|
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
|
|
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
|
|
.unwrap_or_else(|| "anonymous".into()),
|
|
session_id: header_value(headers, HEADER_SESSION_ID),
|
|
},
|
|
workspace: WorkspaceContext {
|
|
workspace_id: header_value(headers, HEADER_WORKSPACE_ID),
|
|
tenant_id: header_value(headers, HEADER_TENANT_ID),
|
|
deployment_id: header_value(headers, HEADER_DEPLOYMENT_ID),
|
|
project_id: header_value(headers, HEADER_PROJECT_ID),
|
|
},
|
|
source: SourceContext {
|
|
channel: header_value(headers, HEADER_SOURCE_CHANNEL)
|
|
.unwrap_or_else(|| "http".into()),
|
|
client: header_value(headers, HEADER_SOURCE_CLIENT)
|
|
.unwrap_or_else(|| "mnote-web".into()),
|
|
idempotency_key: header_value(headers, HEADER_IDEMPOTENCY_KEY),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn apply_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());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn stable_actor_id(value: &str) -> Option<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let stable = trimmed
|
|
.split_once('|')
|
|
.map(|(actor_id, _)| actor_id.trim())
|
|
.unwrap_or(trimmed);
|
|
if stable.is_empty() {
|
|
None
|
|
} else {
|
|
Some(stable.to_string())
|
|
}
|
|
}
|
|
|
|
fn header_or_generated(headers: &HeaderMap, key: &str, prefix: &str) -> String {
|
|
header_value(headers, key).unwrap_or_else(|| generate_id(prefix))
|
|
}
|
|
|
|
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
|
headers
|
|
.get(key)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn generate_id(prefix: &str) -> String {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis();
|
|
let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
format!("{prefix}_{now}_{counter}")
|
|
}
|
|
|
|
fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) {
|
|
let Ok(name) = HeaderName::from_lowercase(key.as_bytes()) else {
|
|
return;
|
|
};
|
|
let Ok(value) = HeaderValue::from_str(value) else {
|
|
return;
|
|
};
|
|
headers.insert(name, value);
|
|
}
|
|
|
|
fn append_cookie(headers: &mut HeaderMap, name: &str, value: &str) {
|
|
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
|
let Ok(header_value) = HeaderValue::from_str(&cookie) else {
|
|
return;
|
|
};
|
|
headers.append(axum::http::header::SET_COOKIE, header_value);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn request_context_uses_headers_when_present() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(HEADER_REQUEST_ID, HeaderValue::from_static("req_demo"));
|
|
headers.insert(HEADER_TRACE_ID, HeaderValue::from_static("trace_demo"));
|
|
headers.insert(HEADER_WORKSPACE_ID, HeaderValue::from_static("ws_demo"));
|
|
headers.insert(HEADER_ACTOR_ID, HeaderValue::from_static("user_demo"));
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
&Method::POST,
|
|
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
|
|
&headers,
|
|
);
|
|
|
|
assert_eq!(context.trace.request_id, "req_demo");
|
|
assert_eq!(context.trace.trace_id, "trace_demo");
|
|
assert_eq!(context.workspace.workspace_id.as_deref(), Some("ws_demo"));
|
|
assert_eq!(context.auth.actor_id, "user_demo");
|
|
}
|
|
|
|
#[test]
|
|
fn request_context_normalizes_pipe_separated_actor_id() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
HEADER_ACTOR_ID,
|
|
HeaderValue::from_static("user_demo|session_123"),
|
|
);
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
&Method::GET,
|
|
&"/".parse::<Uri>().expect("uri"),
|
|
&headers,
|
|
);
|
|
|
|
assert_eq!(context.auth.actor_id, "user_demo");
|
|
}
|
|
|
|
#[test]
|
|
fn request_context_falls_back_to_actor_cookies() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
axum::http::header::COOKIE,
|
|
HeaderValue::from_static("mnote_actor_id=user_cookie; mnote_actor_type=user"),
|
|
);
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
&Method::GET,
|
|
&"/".parse::<Uri>().expect("uri"),
|
|
&headers,
|
|
);
|
|
|
|
assert_eq!(context.auth.actor_id, "user_cookie");
|
|
assert_eq!(context.auth.actor_type, "user");
|
|
}
|
|
|
|
#[test]
|
|
fn request_context_normalizes_pipe_separated_actor_cookie() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
axum::http::header::COOKIE,
|
|
HeaderValue::from_static(
|
|
"mnote_actor_id=user_cookie|session_abc; mnote_actor_type=user",
|
|
),
|
|
);
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
&Method::GET,
|
|
&"/".parse::<Uri>().expect("uri"),
|
|
&headers,
|
|
);
|
|
|
|
assert_eq!(context.auth.actor_id, "user_cookie");
|
|
assert_eq!(context.auth.actor_type, "user");
|
|
}
|
|
}
|
|
|
|
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
|
let cookie_header = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())?;
|
|
for part in cookie_header.split(';') {
|
|
let Some((cookie_name, cookie_value)) = part.trim().split_once('=') else {
|
|
continue;
|
|
};
|
|
if cookie_name.trim() == name {
|
|
let value = cookie_value.trim();
|
|
if !value.is_empty() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|