Files
mnote/rust/crates/mnote-web/src/routes/navigation_recent.rs
T

687 lines
24 KiB
Rust

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<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavigationRecentUpsertRequest {
#[serde(default)]
id: Option<String>,
#[serde(default, alias = "workspace_id")]
workspace_id: Option<String>,
#[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<String>,
#[serde(default, alias = "document_id")]
document_id: Option<String>,
#[serde(default)]
title: String,
#[serde(default, alias = "metadata_json")]
metadata_json: Option<String>,
}
pub async fn list_recent(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<NavigationRecentListQuery>,
) -> Result<Json<Value>, 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::<Vec<_>>(),
"pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::<Vec<_>>(),
"items": records.iter().map(recent_to_json).collect::<Vec<_>>(),
})))
}
pub async fn upsert_recent(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<NavigationRecentUpsertRequest>,
) -> Result<Json<Value>, 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<NavigationRecentRecord> {
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<PathBuf, WebError> {
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<Option<String>, 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::<Vec<_>>()
.join("/");
Ok(Some(cleaned).filter(|value| !value.is_empty()))
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
Some(
document_id
.trim()
.strip_prefix("local-md:")?
.replace("~2F", "/"),
)
}
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
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::<Value>(&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<String>) -> Result<String, WebError> {
let metadata = metadata_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| serde_json::from_str::<Value>(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));
}
}