use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput}; use serde::Deserialize; use serde_json::{json, Value}; use std::path::{Component, Path, PathBuf}; #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NavigationRecentListQuery { #[serde(default)] kind: Option, #[serde(default)] limit: Option, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NavigationRecentUpsertRequest { #[serde(default)] id: Option, #[serde(default, alias = "workspace_id")] workspace_id: Option, #[serde(default)] kind: String, #[serde(default, alias = "source_kind")] source_kind: String, #[serde(default, alias = "root_uri")] root_uri: String, #[serde(default, alias = "relative_path")] relative_path: Option, #[serde(default, alias = "document_id")] document_id: Option, #[serde(default)] title: String, #[serde(default, alias = "metadata_json")] metadata_json: Option, } pub async fn list_recent( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result, WebError> { let actor_id = require_actor_id(&state, &context)?; let records = load_navigation_recent_records( &state, &context, &actor_id, query.kind.as_deref(), query.limit.unwrap_or(40), ); Ok(Json(json!({ "ok": true, "owner": "mnote-web", "folders": records.iter().filter(|record| record.kind == "folder").map(recent_to_json).collect::>(), "pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::>(), "items": records.iter().map(recent_to_json).collect::>(), }))) } pub async fn upsert_recent( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { let actor_id = require_actor_id(&state, &context)?; let metadata_json = normalize_metadata_json(request.metadata_json)?; let source_kind = request .source_kind .trim() .to_string() .if_empty_else(|| "local_folder".to_string()); let root_uri = request.root_uri.trim().to_string(); let kind = request.kind.trim().to_string(); let document_id = request .document_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let relative_path = normalize_navigation_relative_path(request.relative_path.as_deref())? .or_else(|| { document_id .as_deref() .and_then(local_markdown_relative_path_from_document_id) }); if source_kind == "local_folder" { let root_path = ensure_local_workspace_read_access_with_state(&state, &context, &root_uri)?; ensure_navigation_recent_target_exists(&root_path, &kind, relative_path.as_deref())?; } let record = state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: request.id, user_id: actor_id, workspace_id: request .workspace_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), kind, source_kind, root_uri, relative_path, document_id, title: request.title.trim().to_string(), metadata_json, }) .map_err(|error| { WebError::bad_request_code( "navigation_recent_upsert_failed", format!("写入最近访问失败: {error}"), ) .with_context(&context) })?; Ok(Json(json!({ "ok": true, "owner": "mnote-web", "recent": recent_to_json(&record), }))) } pub(crate) fn load_navigation_recent_records( state: &AppState, context: &RequestContext, actor_id: &str, kind: Option<&str>, limit: usize, ) -> Vec { state .control_plane() .list_navigation_recent(actor_id, kind, limit) .unwrap_or_default() .into_iter() .filter(|record| navigation_recent_is_accessible(state, context, record)) .collect() } fn navigation_recent_is_accessible( state: &AppState, context: &RequestContext, record: &NavigationRecentRecord, ) -> bool { if record.source_kind != "local_folder" { return true; } let Ok(root_path) = ensure_local_workspace_read_access_with_state(state, context, &record.root_uri) else { return false; }; let relative_path = if record .relative_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() { let Ok(relative_path) = normalize_navigation_relative_path(record.relative_path.as_deref()) else { return false; }; relative_path } else { record .document_id .as_deref() .and_then(local_markdown_relative_path_from_document_id) }; let Ok(target_path) = relative_path .as_deref() .map(|path| canonical_navigation_target(&root_path, path)) .transpose() else { return false; }; match record.kind.as_str() { "folder" => target_path .as_deref() .map(Path::is_dir) .unwrap_or_else(|| root_path.is_dir()), "page" => target_path.as_deref().map(Path::is_file).unwrap_or(false), _ => true, } } fn ensure_navigation_recent_target_exists( root_path: &Path, kind: &str, relative_path: Option<&str>, ) -> Result<(), WebError> { match kind { "folder" => { if let Some(path) = relative_path { let target = canonical_navigation_target(root_path, path)?; if !target.is_dir() { return Err(WebError::bad_request_code( "navigation_recent_folder_not_found", "最近访问文件夹不存在", )); } } Ok(()) } "page" => { let Some(path) = relative_path else { return Err(WebError::bad_request_code( "navigation_recent_page_path_required", "最近访问页面缺少相对路径", )); }; let target = canonical_navigation_target(root_path, path)?; if !target.is_file() { return Err(WebError::bad_request_code( "navigation_recent_page_not_found", "最近访问页面不存在", )); } Ok(()) } _ => Ok(()), } } fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result { let relative_path = normalize_navigation_relative_path(Some(relative_path))? .as_deref() .map(Path::new) .map(|path| root_path.join(path)) .unwrap_or_else(|| root_path.to_path_buf()); let canonical_target = relative_path.canonicalize().map_err(|error| { WebError::bad_request_code( "navigation_recent_target_unavailable", format!("最近访问目标不可用: {error}"), ) })?; if !canonical_target.starts_with(root_path) { return Err(WebError::bad_request_code( "navigation_recent_root_escape", "最近访问路径不能越过授权目录", )); } Ok(canonical_target) } fn normalize_navigation_relative_path(value: Option<&str>) -> Result, WebError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; let normalized = value.replace('\\', "/"); let raw_path = Path::new(&normalized); if raw_path.is_absolute() || raw_path .components() .any(|component| matches!(component, Component::ParentDir)) { return Err(WebError::bad_request_code( "navigation_recent_root_escape", "最近访问路径不能越过授权目录", )); } let cleaned = normalized .split('/') .filter(|segment| !segment.trim().is_empty() && *segment != ".") .collect::>() .join("/"); Ok(Some(cleaned).filter(|value| !value.is_empty())) } fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { Some( document_id .trim() .strip_prefix("local-md:")? .replace("~2F", "/"), ) } fn require_actor_id(state: &AppState, context: &RequestContext) -> Result { current_actor_id(state, context) .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "navigation_recent_auth_required", "最近访问需要登录用户", ) .with_context(context) }) } fn recent_to_json(record: &NavigationRecentRecord) -> Value { json!({ "id": record.id, "userId": record.user_id, "workspaceId": record.workspace_id, "kind": record.kind, "sourceKind": record.source_kind, "rootUri": record.root_uri, "relativePath": record.relative_path, "documentId": record.document_id, "title": record.title, "status": record.status, "metadata": serde_json::from_str::(&record.metadata_json).unwrap_or(Value::Null), "visitedAt": record.visited_at, "createdAt": record.created_at, "updatedAt": record.updated_at, "revision": record.revision, }) } fn normalize_metadata_json(metadata_json: Option) -> Result { let metadata = metadata_json .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| serde_json::from_str::(value)) .transpose() .map_err(|error| { WebError::bad_request_code( "navigation_recent_metadata_invalid", format!("最近访问 metadataJson 必须是 JSON 对象: {error}"), ) })? .unwrap_or_else(|| json!({})); let normalized = if metadata.is_object() { metadata } else { json!({}) }; serde_json::to_string(&normalized) .map_err(|error| WebError::internal(format!("最近访问 metadataJson 序列化失败: {error}"))) } trait EmptyStringExt { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; } impl EmptyStringExt for String { fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String { if self.trim().is_empty() { fallback() } else { self } } } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{header, Request, StatusCode}; use control_plane::{UpsertNavigationRecentInput, UpsertUserInput}; use tower::ServiceExt; fn test_state() -> AppState { 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: true, 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(), }) } fn temp_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create temp root"); root } #[tokio::test] async fn navigation_recent_requires_logged_in_actor() { let response = build_app(test_state()) .oneshot( Request::builder() .uri("/api/navigation/recent") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn navigation_recent_post_and_get_returns_folder_and_page_groups() { let root = temp_root("mnote-navigation-recent-api"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_real".to_string()), email: Some("user_real@example.com".to_string()), username: "user_real".to_string(), display_name: "user_real".to_string(), role: None, password_hash: None, }) .expect("upsert user"); let app = build_app(state); let folder_body = json!({ "kind": "folder", "sourceKind": "local_folder", "rootUri": root_uri, "relativePath": "docs", "title": "docs" }) .to_string(); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/navigation/recent") .header(header::CONTENT_TYPE, "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from(folder_body)) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let page_body = json!({ "kind": "page", "sourceKind": "local_folder", "rootUri": root_uri, "relativePath": "docs/Plan.md", "documentId": "local-md:docs~2FPlan.md", "title": "Plan" }) .to_string(); let response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/navigation/recent") .header(header::CONTENT_TYPE, "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from(page_body)) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .uri("/api/navigation/recent") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["folders"][0]["relativePath"], "docs"); assert_eq!(payload["pages"][0]["documentId"], "local-md:docs~2FPlan.md"); assert_eq!(payload["items"].as_array().map(Vec::len), Some(2)); } #[tokio::test] async fn navigation_recent_list_filters_deleted_local_paths() { let root = temp_root("mnote-navigation-recent-filter-deleted"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_real".to_string()), email: Some("user_real@example.com".to_string()), username: "user_real".to_string(), display_name: "user_real".to_string(), role: None, password_hash: None, }) .expect("upsert user"); state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: "user_real".to_string(), workspace_id: None, kind: "page".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.clone(), relative_path: Some("docs/Plan.md".to_string()), document_id: Some("local-md:docs~2FPlan.md".to_string()), title: "Plan".to_string(), metadata_json: "{}".to_string(), }) .expect("insert recent"); std::fs::remove_file(root.join("docs").join("Plan.md")).expect("delete plan"); let response = build_app(state) .oneshot( Request::builder() .uri("/api/navigation/recent") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["pages"].as_array().map(Vec::len), Some(0)); assert_eq!(payload["items"].as_array().map(Vec::len), Some(0)); } #[tokio::test] async fn navigation_recent_post_rejects_root_escape_relative_path() { let root = temp_root("mnote-navigation-recent-reject-escape"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_real".to_string()), email: Some("user_real@example.com".to_string()), username: "user_real".to_string(), display_name: "user_real".to_string(), role: None, password_hash: None, }) .expect("upsert user"); let app = build_app(state); let body = json!({ "kind": "folder", "sourceKind": "local_folder", "rootUri": root_uri, "relativePath": "../", "title": "escape" }) .to_string(); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/navigation/recent") .header(header::CONTENT_TYPE, "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from(body)) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), Some("navigation_recent_root_escape") ); } #[tokio::test] async fn navigation_recent_list_filters_root_escape_records() { let root = temp_root("mnote-navigation-recent-filter-escape"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init local workspace"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_real".to_string()), email: Some("user_real@example.com".to_string()), username: "user_real".to_string(), display_name: "user_real".to_string(), role: None, password_hash: None, }) .expect("upsert user"); state .control_plane() .upsert_navigation_recent(UpsertNavigationRecentInput { id: None, user_id: "user_real".to_string(), workspace_id: None, kind: "folder".to_string(), source_kind: "local_folder".to_string(), root_uri: root_uri.clone(), relative_path: Some("../".to_string()), document_id: None, title: "escape".to_string(), metadata_json: "{}".to_string(), }) .expect("insert escaped recent through store"); let response = build_app(state) .oneshot( Request::builder() .uri("/api/navigation/recent") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["folders"].as_array().map(Vec::len), Some(0)); assert_eq!(payload["items"].as_array().map(Vec::len), Some(0)); } }