feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
+1 -35
View File
@@ -8450,16 +8450,6 @@ fn execute_command(
payload_json: request.payload_json,
args_json: json!({
"id": payload.document_id,
"streamDeltaHint": tree_stream_delta_hint("remove_document", json!({
"documentId": payload.document_id,
})),
"domainEventHint": tree_domain_event_hint("tree.node.purged"),
"domainEventPlan": tree_domain_event_plan(
"tree.node.purged",
tree_stream_delta_hint("remove_document", json!({
"documentId": payload.document_id,
})),
),
}),
}))
}
@@ -10991,31 +10981,7 @@ mod tests {
"documentId": "doc_1",
}),
json!({
"id": "doc_1",
"streamDeltaHint": {
"family": "tree",
"kind": "remove_document",
"args": {
"documentId": "doc_1"
}
},
"domainEventHint": {
"family": "tree",
"eventType": "tree.node.purged"
},
"domainEventPlan": {
"family": "tree",
"schema": "mnote.tree.domain_event",
"schemaVersion": 1,
"eventType": "tree.node.purged",
"streamDeltaHint": {
"family": "tree",
"kind": "remove_document",
"args": {
"documentId": "doc_1"
}
}
}
"id": "doc_1"
}),
),
];
+20 -1
View File
@@ -11,6 +11,9 @@ pub struct AppConfig {
pub service_name: String,
pub service_version: String,
pub bind_addr: String,
pub public_bind_addr: String,
pub legacy_next_base_url: Option<String>,
pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
@@ -30,7 +33,16 @@ impl AppConfig {
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:0".into()),
bind_addr: env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.unwrap_or_else(|_| "127.0.0.1:0".into()),
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", true),
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
@@ -79,6 +91,13 @@ impl AppConfig {
}
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::context::RequestContext;
use axum::Json;
use axum::http::StatusCode;
use axum::http::{HeaderName, HeaderValue};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
+3
View File
@@ -2,8 +2,11 @@ pub mod app;
pub mod context;
pub mod error;
pub mod middleware;
pub mod page_aggregate;
pub mod routes;
pub mod ssr;
pub mod transport;
pub mod tree_shell;
pub mod workspace_shell;
pub use app::{build_app, AppConfig, AppState};
+2 -1
View File
@@ -8,10 +8,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::from_env();
let bind_addr = config.bind_addr.clone();
let public_bind_addr = config.public_bind_addr.clone();
let app = build_app(AppState::new(config));
let listener = TcpListener::bind(&bind_addr).await?;
info!(bind_addr = %bind_addr, "mnote-web 最小骨架已启动");
info!(bind_addr = %bind_addr, public_bind_addr = %public_bind_addr, "mnote-web gateway 已启动");
axum::serve(listener, app).await?;
Ok(())
}
+136
View File
@@ -0,0 +1,136 @@
//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现。
//!
//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md
pub mod builder;
use serde::Serialize;
use serde_json::Value;
/// Page Aggregate 根结构
///
/// 对应 JSON 契约:
/// ```json
/// {
/// "schema": "mnote.page_aggregate.v1",
/// "identity": { ... },
/// "head": { ... },
/// "layout": { ... },
/// "body": { ... },
/// "tree": { ... },
/// "stats": { ... }
/// }
/// ```
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageAggregate {
pub schema: String,
pub identity: PageIdentity,
pub head: PageHead,
pub layout: PageLayout,
pub body: PageBody,
pub tree: PageTree,
pub stats: PageStats,
}
impl PageAggregate {
/// 创建一个新的 builder 用于构建 PageAggregate。
pub fn builder() -> builder::PageAggregateBuilder {
builder::PageAggregateBuilder::new()
}
/// 序列化为 `serde_json::Value`,输出与当前 `build_page_aggregate_snapshot` 完全一致的 JSON。
///
/// 使用 `#[serde(rename_all = "camelCase")]` 确保字段名与前端契约一致。
pub fn to_json_value(&self) -> Value {
serde_json::to_value(self).expect("PageAggregate 序列化不应失败")
}
/// 获取页面标题(`head.title`)的便捷方法。
pub fn head_title(&self) -> &str {
&self.head.title
}
}
/// 页面身份标识。
///
/// 标识这份页面聚合属于哪一个 page aggregate。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageIdentity {
pub document_id: String,
pub workspace_id: String,
}
/// 页面头部正式真相。
///
/// 承载页面头部的 title、updatedAt 及权限信息。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageHead {
pub title: String,
pub updated_at: Value,
pub permissions: PagePermissions,
}
/// 页面权限。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PagePermissions {
pub read_only: bool,
pub disable_download: bool,
pub disable_copy: bool,
}
/// 页面布局。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageLayout {
pub page_options: PageOptions,
}
/// 页面选项 / 编辑器运行时设置。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageOptions {
pub wide_layout: bool,
pub small_text: bool,
pub layout_density: String,
pub show_heading_numbers: bool,
pub show_toc: bool,
pub show_structure: bool,
pub protect_editing: bool,
pub show_word_count: bool,
pub collapse_backlinks: bool,
pub page_font: String,
pub hide_child_pages: bool,
pub show_block_ref_count: bool,
pub embed_default_block_id: Value,
}
/// 页面正文内容与保存元数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageBody {
pub content: Value,
pub revision: Value,
pub conflict_detection_key: Value,
}
/// 当前页面对应的子树投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTree {
pub page_subtree: Value,
}
/// 页面统计附属投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageStats {
pub word_count: u64,
pub character_count: u64,
pub block_count: u64,
pub todo_total: u64,
pub todo_done: u64,
}
@@ -0,0 +1,307 @@
//! PageAggregate 构建器(Builder 模式)。
//!
//! 使用方法:
//! ```rust,ignore
//! let aggregate = PageAggregate::builder()
//! .document_id("doc_1")
//! .workspace_id("ws_demo")
//! .title("我的页面")
//! .build();
//! ```
use crate::page_aggregate::{
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
PagePermissions, PageStats, PageTree,
};
use serde_json::Value;
/// PageAggregate 构建器。
///
/// 所有字段都有与当前 `build_page_aggregate_snapshot` 一致的默认值。
/// 使用 `build()` 方法消费 builder 并产出 `PageAggregate`。
#[derive(Debug)]
pub struct PageAggregateBuilder {
document_id: String,
workspace_id: String,
title: String,
updated_at: Value,
read_only: bool,
disable_download: bool,
disable_copy: bool,
wide_layout: bool,
small_text: bool,
layout_density: String,
show_heading_numbers: bool,
show_toc: bool,
show_structure: bool,
protect_editing: bool,
show_word_count: bool,
collapse_backlinks: bool,
page_font: String,
hide_child_pages: bool,
show_block_ref_count: bool,
embed_default_block_id: Value,
content: Value,
revision: Value,
conflict_detection_key: Value,
page_subtree: Value,
word_count: u64,
character_count: u64,
block_count: u64,
todo_total: u64,
todo_done: u64,
}
impl PageAggregateBuilder {
/// 创建新的构建器,所有字段使用与当前 `build_page_aggregate_snapshot` 一致的默认值。
pub fn new() -> Self {
Self {
document_id: String::new(),
workspace_id: "default".to_string(),
title: "无标题".to_string(),
updated_at: Value::Null,
read_only: false,
disable_download: false,
disable_copy: false,
wide_layout: false,
small_text: false,
layout_density: "normal".to_string(),
show_heading_numbers: true,
show_toc: false,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: false,
page_font: "default".to_string(),
hide_child_pages: false,
show_block_ref_count: false,
embed_default_block_id: Value::Null,
content: Value::Null,
revision: Value::Null,
conflict_detection_key: Value::Null,
page_subtree: Value::Null,
word_count: 0,
character_count: 0,
block_count: 0,
todo_total: 0,
todo_done: 0,
}
}
// ── Identity ──
pub fn document_id(mut self, value: impl Into<String>) -> Self {
self.document_id = value.into();
self
}
pub fn workspace_id(mut self, value: impl Into<String>) -> Self {
self.workspace_id = value.into();
self
}
// ── Head ──
pub fn title(mut self, value: impl Into<String>) -> Self {
self.title = value.into();
self
}
pub fn updated_at(mut self, value: Value) -> Self {
self.updated_at = value;
self
}
pub fn read_only(mut self, value: bool) -> Self {
self.read_only = value;
self
}
pub fn disable_download(mut self, value: bool) -> Self {
self.disable_download = value;
self
}
pub fn disable_copy(mut self, value: bool) -> Self {
self.disable_copy = value;
self
}
// ── Layout / PageOptions ──
pub fn wide_layout(mut self, value: bool) -> Self {
self.wide_layout = value;
self
}
pub fn small_text(mut self, value: bool) -> Self {
self.small_text = value;
self
}
pub fn layout_density(mut self, value: impl Into<String>) -> Self {
self.layout_density = value.into();
self
}
pub fn show_heading_numbers(mut self, value: bool) -> Self {
self.show_heading_numbers = value;
self
}
pub fn show_toc(mut self, value: bool) -> Self {
self.show_toc = value;
self
}
pub fn show_structure(mut self, value: bool) -> Self {
self.show_structure = value;
self
}
pub fn protect_editing(mut self, value: bool) -> Self {
self.protect_editing = value;
self
}
pub fn show_word_count(mut self, value: bool) -> Self {
self.show_word_count = value;
self
}
pub fn collapse_backlinks(mut self, value: bool) -> Self {
self.collapse_backlinks = value;
self
}
pub fn page_font(mut self, value: impl Into<String>) -> Self {
self.page_font = value.into();
self
}
pub fn hide_child_pages(mut self, value: bool) -> Self {
self.hide_child_pages = value;
self
}
pub fn show_block_ref_count(mut self, value: bool) -> Self {
self.show_block_ref_count = value;
self
}
pub fn embed_default_block_id(mut self, value: Value) -> Self {
self.embed_default_block_id = value;
self
}
// ── Body ──
pub fn content(mut self, value: Value) -> Self {
self.content = value;
self
}
pub fn revision(mut self, value: Value) -> Self {
self.revision = value;
self
}
pub fn conflict_detection_key(mut self, value: Value) -> Self {
self.conflict_detection_key = value;
self
}
// ── Tree ──
pub fn page_subtree(mut self, value: Value) -> Self {
self.page_subtree = value;
self
}
// ── Stats ──
pub fn word_count(mut self, value: u64) -> Self {
self.word_count = value;
self
}
pub fn character_count(mut self, value: u64) -> Self {
self.character_count = value;
self
}
pub fn block_count(mut self, value: u64) -> Self {
self.block_count = value;
self
}
pub fn todo_total(mut self, value: u64) -> Self {
self.todo_total = value;
self
}
pub fn todo_done(mut self, value: u64) -> Self {
self.todo_done = value;
self
}
// ── Build ──
/// 消费 builder 并产出 `PageAggregate`。
pub fn build(self) -> PageAggregate {
PageAggregate {
schema: "mnote.page_aggregate.v1".to_string(),
identity: PageIdentity {
document_id: self.document_id,
workspace_id: self.workspace_id,
},
head: PageHead {
title: self.title,
updated_at: self.updated_at,
permissions: PagePermissions {
read_only: self.read_only,
disable_download: self.disable_download,
disable_copy: self.disable_copy,
},
},
layout: PageLayout {
page_options: PageOptions {
wide_layout: self.wide_layout,
small_text: self.small_text,
layout_density: self.layout_density,
show_heading_numbers: self.show_heading_numbers,
show_toc: self.show_toc,
show_structure: self.show_structure,
protect_editing: self.protect_editing,
show_word_count: self.show_word_count,
collapse_backlinks: self.collapse_backlinks,
page_font: self.page_font,
hide_child_pages: self.hide_child_pages,
show_block_ref_count: self.show_block_ref_count,
embed_default_block_id: self.embed_default_block_id,
},
},
body: PageBody {
content: self.content,
revision: self.revision,
conflict_detection_key: self.conflict_detection_key,
},
tree: PageTree {
page_subtree: self.page_subtree,
},
stats: PageStats {
word_count: self.word_count,
character_count: self.character_count,
block_count: self.block_count,
todo_total: self.todo_total,
todo_done: self.todo_done,
},
}
}
}
impl Default for PageAggregateBuilder {
fn default() -> Self {
Self::new()
}
}
+6 -3
View File
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -162,6 +162,9 @@ mod tests {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -2,12 +2,12 @@ use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::{
ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts,
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
};
use bridge_runtime::{
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire, execute_runtime_input,
RuntimeTargetWire,
};
use serde_json::Value;
+7 -4
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use axum::Json;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::extract::Query;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::Json;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde::Serialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -94,7 +94,7 @@ pub async fn next_sidebar(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -104,6 +104,9 @@ mod tests {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
+132 -69
View File
@@ -6,15 +6,15 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
@@ -47,19 +47,34 @@ pub struct DocumentSaveRequest {
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
(
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"result": result,
})),
)
}
fn stamp_documents_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_DOCUMENTS_TRANSPORT.as_bytes()) {
headers.insert(name, HeaderValue::from_static("documents-api"));
}
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
@@ -99,13 +114,8 @@ fn next_documents_base_url() -> String {
.to_string()
}
fn should_proxy_via_next(context: &RequestContext) -> bool {
context
.auth
.cookie_header
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
fn should_proxy_via_next(_context: &RequestContext) -> bool {
false
}
fn build_next_proxy_headers(
@@ -368,33 +378,80 @@ async fn proxy_next_documents_save(
}))
}
pub async fn load_document_meta_result(
state: &AppState,
context: &RequestContext,
query: DocumentMetaQuery,
) -> Result<Value, WebError> {
let document_id_owned = query.document_id.trim().to_string();
if document_id_owned.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(context) {
return proxy_next_documents_meta(
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await;
}
fetch_documents_meta_via_convex(
state.config(),
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await
}
pub async fn load_document_content_result(
state: &AppState,
context: &RequestContext,
query: DocumentContentQuery,
) -> Result<Value, WebError> {
let document_id_owned = query.document_id.trim().to_string();
if document_id_owned.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(context) {
return proxy_next_documents_content(
context,
effective_workspace_id.as_deref(),
&document_id_owned,
)
.await;
}
execute_runtime_query_via_convex(
state.config(),
context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "documents.content.get".into(),
payload: json!({
"documentId": document_id_owned,
"workspaceId": effective_workspace_id,
}),
},
)
.await
}
pub async fn meta(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = fetch_documents_meta_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
document_id,
)
.await?;
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = load_document_meta_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -402,35 +459,8 @@ pub async fn content(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = execute_runtime_query_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "documents.content.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
}),
},
)
.await?;
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = load_document_content_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -438,7 +468,7 @@ pub async fn save(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -500,9 +530,9 @@ pub async fn save(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -511,6 +541,9 @@ mod tests {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -579,8 +612,24 @@ mod tests {
}))
}
#[test]
fn convex_auth_cookie_does_not_force_next_proxy() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"),
);
let context = crate::context::RequestContext::from_http_parts(
&Method::GET,
&"/api/documents/meta".parse::<Uri>().expect("uri"),
&headers,
);
assert!(!super::should_proxy_via_next(&context));
}
#[tokio::test]
async fn documents_meta_route_returns_document_metadata() {
async fn documents_api_meta_route_returns_document_metadata() {
let response = app()
.oneshot(
Request::builder()
@@ -592,6 +641,20 @@ mod tests {
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-documents-transport")
.and_then(|value| value.to_str().ok()),
Some("documents-api")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
@@ -603,7 +666,7 @@ mod tests {
}
#[tokio::test]
async fn documents_content_route_returns_page_subtree() {
async fn documents_api_content_route_returns_page_subtree() {
let response = app()
.oneshot(
Request::builder()
+11 -8
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::documents::{
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
};
use axum::extract::{Extension, Json, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1681,7 +1681,7 @@ pub async fn document_editor_shell(
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentEditorShellQuery>,
) -> Result<Response, WebError> {
let (_, meta_json) = document_meta(
let (_, _, meta_json) = document_meta(
State(state.clone()),
Extension(context.clone()),
Query(DocumentMetaQuery {
@@ -1691,7 +1691,7 @@ pub async fn document_editor_shell(
)
.await?;
let (_, content_json) = document_content(
let (_, _, content_json) = document_content(
State(state),
Extension(context),
Query(DocumentContentQuery {
@@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -1772,6 +1772,9 @@ mod tests {
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: true,
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
+652
View File
@@ -0,0 +1,652 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::web_shell::{load_sidebar_tree_html, load_workspace_shell_projection};
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use axum::body::Body;
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GatewayManifest {
ok: bool,
owner: &'static str,
public_entry: String,
legacy_next_base_url: Option<String>,
legacy_next_compat_enabled: bool,
notes: Vec<&'static str>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
let mut response = axum::Json(GatewayManifest {
ok: true,
owner: "mnote-web",
public_entry: state.config().public_bind_addr.clone(),
legacy_next_base_url: state.config().legacy_next_base_url.clone(),
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
notes: vec![
"3000 公开入口默认由 mnote-web gateway 拥有。",
"Next App Router 只作为 legacy compat upstream 使用。",
],
})
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
response
}
pub async fn auth_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() {
return legacy_next_proxy(State(state), Extension(context), request).await;
}
let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE Auth</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Response {
let workspace_id = context
.workspace
.workspace_id
.as_deref()
.unwrap_or("ws_demo");
let requested_page_id = query
.page_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
let active_page_id = requested_page_id.or(recent_page_id.as_deref());
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id,
active_page_id,
&default_workspace_name,
)
.await;
let sidebar_tree_html =
load_sidebar_tree_html(
state.config(),
&context,
workspace_id,
workspace_projection.active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
);
let workspace_name = workspace_projection.workspace_name.clone();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_sidebar_html={workspace_sidebar_html} />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
response
}
pub async fn legacy_next_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
if !state.config().enable_legacy_next_compat {
return Err(WebError::service_unavailable_code(
"legacy_next_compat_disabled",
"Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
return Err(WebError::service_unavailable_code(
"legacy_next_upstream_missing",
"未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
};
let path_and_query = request
.uri()
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}"))
.map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
let method = request.method().clone();
let headers = request.headers().clone();
let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024)
.await
.map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?;
let upstream_origin = upstream_origin(&upstream_url);
let mut upstream_request = client.request(method, upstream_url);
for (name, value) in headers.iter() {
if is_hop_by_hop_header(name.as_str()) || name == header::HOST {
continue;
}
if name == header::ORIGIN {
upstream_request = upstream_request.header(name, upstream_origin.as_str());
continue;
}
if name == header::REFERER {
let normalized_referer = normalize_legacy_referer(value, &upstream_origin);
upstream_request = upstream_request.header(name, normalized_referer);
continue;
}
upstream_request = upstream_request.header(name, value);
}
let upstream_response = upstream_request
.body(body.to_vec())
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"legacy_next_proxy_error",
format!("legacy Next 请求失败: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
})?;
let status = upstream_response.status();
let upstream_headers = upstream_response.headers().clone();
let body = upstream_response.bytes().await.map_err(|error| {
WebError::bad_gateway_code(
"legacy_next_proxy_body_error",
format!("legacy Next 响应读取失败: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
})?;
let mut response = Response::builder()
.status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?;
for (name, value) in upstream_headers.iter() {
if is_hop_by_hop_header(name.as_str()) {
continue;
}
response.headers_mut().append(name, value.clone());
}
stamp_gateway_headers(response.headers_mut(), true);
Ok(response)
}
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
context.auth.cookie_header.as_deref()?.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 stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if legacy {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) {
headers.insert(name, HeaderValue::from_static("next-app-router"));
}
}
}
fn is_hop_by_hop_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailers"
| "transfer-encoding"
| "upgrade"
)
}
fn upstream_origin(url: &reqwest::Url) -> String {
let host = url.host_str().unwrap_or("127.0.0.1");
match url.port() {
Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
None => format!("{}://{}", url.scheme(), host),
}
}
fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String {
let referer = value.to_str().unwrap_or_default();
let Ok(parsed) = reqwest::Url::parse(referer) else {
return upstream_origin.to_string();
};
let path = parsed.path();
let query = parsed
.query()
.map(|query| format!("?{query}"))
.unwrap_or_default();
format!("{upstream_origin}{path}{query}")
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use tokio::net::TcpListener;
use tower::util::ServiceExt;
fn app() -> axum::Router {
app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
}
fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router {
app_with_config(legacy_next_base_url, true)
}
fn app_with_config(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
) -> axum::Router {
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(legacy_next_base_url),
enable_legacy_next_compat,
enable_debug_shell_routes: false,
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(),
}))
}
async fn spawn_legacy_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("legacy listener");
let addr = listener.local_addr().expect("legacy addr");
let app = axum::Router::new().route(
"/auth",
get(|| async {
Html(r#"<html><body><button>测试账号快速登录</button></body></html>"#)
})
.post(|| async { "auth-post-ok" }),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
async fn spawn_legacy_origin_checked_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("legacy listener");
let addr = listener.local_addr().expect("legacy addr");
let expected_origin = format!("http://{addr}");
let app = axum::Router::new().route(
"/api/auth",
post(move |headers: HeaderMap| {
let expected_origin = expected_origin.clone();
async move {
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if origin != expected_origin {
return (StatusCode::FORBIDDEN, "Invalid origin");
}
(StatusCode::OK, "ok")
}
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
async fn spawn_legacy_cookie_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("legacy listener");
let addr = listener.local_addr().expect("legacy addr");
let app = axum::Router::new().route(
"/api/auth",
post(|| async {
let mut response = "ok".into_response();
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"),
);
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_static(
"__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly",
),
);
response
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("legacy server");
});
format!("http://{addr}")
}
#[tokio::test]
async fn gateway_health_declares_mnote_web_owner() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/gateway/health")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["publicEntry"], "127.0.0.1:3000");
assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100");
}
#[tokio::test]
async fn root_entry_returns_wolai_workspace_layout_contract() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
assert!(html.contains(r#"data-testid="wolai-topbar""#));
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
assert!(html.contains("星标置顶"));
assert!(html.contains("我的页面"));
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(!html.contains("欢迎使用 MNOTE 知识管理平台"));
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
}
#[tokio::test]
async fn root_entry_uses_recent_page_cookie_as_active_page() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.header("cookie", "mnote_recent_page_id=page_child")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-node-id="page_child""#));
assert!(html.contains(r#"class="wolai-page-row wolai-active-row" href="/documents/page_child?workspaceId=ws_demo" data-node-id="page_child""#));
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert!(response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html"));
}
#[tokio::test]
async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.uri("/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("测试账号快速登录"));
}
#[tokio::test]
async fn auth_entry_proxies_post_to_legacy_next_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.method("POST")
.uri("/auth")
.header("origin", "http://127.0.0.1:3000")
.header("referer", "http://127.0.0.1:3000/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert_eq!(text, "auth-post-ok");
}
#[tokio::test]
async fn legacy_proxy_normalizes_auth_post_origin_to_upstream_origin() {
let legacy_base_url = spawn_legacy_origin_checked_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("origin", "http://127.0.0.1:3000")
.header("referer", "http://127.0.0.1:3000/auth")
.body(Body::from(r#"{"action":"auth:signIn"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
);
}
#[tokio::test]
async fn legacy_proxy_preserves_multiple_set_cookie_headers() {
let legacy_base_url = spawn_legacy_cookie_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cookies = response.headers().get_all("set-cookie");
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("__convexAuthJWT=jwt-demo")));
assert!(values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, State};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Serialize)]
+142 -7
View File
@@ -1,15 +1,18 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
execute_runtime_query, runtime_input_requests_result,
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -37,7 +40,7 @@ pub async fn health(
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(runtime_input): Json<RuntimeInput>,
) -> Result<(StatusCode, Json<Value>), WebError> {
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
@@ -45,12 +48,14 @@ pub async fn bridge_runtime(
"bridge": "hermes_runtime_result",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"result": result,
}),
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
@@ -64,6 +69,7 @@ pub async fn bridge_runtime(
"bridge": "hermes_runtime_plan",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"plan": success.plan,
})
}
@@ -71,11 +77,140 @@ pub async fn bridge_runtime(
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
};
Ok((StatusCode::OK, Json(payload)))
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
}
fn ai_bridge_contract() -> Value {
json!({
"schema": "mnote.ai_bridge.v1",
"owner": "mnote-web",
"bridge": "hermes",
"sessionOwner": "rust-web-hermes",
"toolEventOwner": "rust-web-hermes",
"clientActionOwner": "rust-web-hermes"
})
}
fn stamp_ai_bridge_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
}
headers
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
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: true,
enable_debug_shell_routes: false,
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(),
}))
}
#[tokio::test]
async fn ai_bridge_route_returns_hermes_owner_contract() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"kind": "tool",
"context": {
"deploymentId": null,
"projectId": null,
"workspaceId": "ws_demo",
"requestId": "req_1",
"traceId": "trace_1",
"actor": {
"actorType": "user",
"actorId": "user_1",
"sessionId": null
},
"source": {
"channel": "rust-web",
"client": "mnote-web"
},
"tenantId": null,
"authToken": null,
"idempotencyKey": null,
"validateOnly": false,
"dryRun": false
},
"tool": {
"tool": "docs_search",
"kind": "query",
"mode": "plan",
"argsJson": {"query": "Rust Web"},
"target": null,
"reason": "owner gate",
"refs": []
},
"data": null
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-ai-bridge-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-web-hermes")
);
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["contract"]["schema"], "mnote.ai_bridge.v1");
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
}
}
+14 -13
View File
@@ -3,16 +3,16 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -188,8 +188,8 @@ pub async fn graph(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -199,6 +199,9 @@ mod tests {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -386,13 +389,11 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap"
);
assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand")
);
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand"));
}
#[tokio::test]
@@ -0,0 +1,150 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::ssr::pages::mindmap::MindmapPage;
use axum::extract::{Extension, Path};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
pub async fn mindmap_object_shell(
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
) -> Result<Response, WebError> {
let contract = json!({
"schema": "mnote.mindmap_shell.v1",
"owner": "mnote-web",
"shell": "mindmap",
"documentId": doc_id,
"mindmapId": mindmap_id,
"projection": {
"schema": "mnote.mindmap_projection.v1",
"source": "rust-web-object-shell"
},
"island": {
"kind": "react_mindmap_runtime",
"mountId": "mnote-mindmap-island",
"legacyCompat": "next-app-router"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id
});
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
let body_content = crate::ssr::render_view(leptos::view! {
<MindmapPage
document_id={doc_id.clone()}
mindmap_id={mindmap_id.clone()}
/>
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>思维导图</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
{}
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(&doc_id),
escape_html(&mindmap_id),
body_content,
escape_script_json(&contract_json),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "mindmap");
Ok(response)
}
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
headers.insert(name, HeaderValue::from_static(shell));
}
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
fn app() -> axum::Router {
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: true,
enable_debug_shell_routes: false,
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(),
}))
}
#[tokio::test]
async fn mindmap_shell_returns_rust_object_shell_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/mindmap/doc_1/mind_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("mindmap")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
assert!(html.contains("rust-web-object-shell"));
}
}
+29 -2
View File
@@ -3,19 +3,24 @@ mod command_support;
mod compat;
mod documents;
mod editor;
mod gateway;
mod health;
mod hermes;
mod kernel;
mod mindmap_shell;
mod query_support;
mod search;
mod session;
mod snapshot_support;
mod sse;
mod stream_support;
mod tree;
mod web_shell;
mod ws;
use crate::app::AppState;
use axum::Router;
use axum::routing::{get, post};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
@@ -24,6 +29,26 @@ pub fn build_router(state: AppState) -> Router {
let mut router = Router::new()
.route("/health", get(health::health))
.route("/", get(gateway::root_entry))
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route(
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
)
.route(
"/documents/{document_id}",
get(web_shell::document_page_shell),
)
.route(
"/api/page-aggregate/{document_id}",
get(web_shell::page_aggregate),
)
.route("/api/search/documents", post(search::documents))
.route("/api/gateway/health", get(gateway::gateway_health))
.route("/api/runtime/config", get(session::runtime_config))
.route("/api/auth/session", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/save", post(documents::save))
@@ -52,6 +77,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/bridge/workspace", get(bridge::workspace))
.route("/api/bridge/request", get(bridge::request))
.route("/api/bridge/trace", get(bridge::trace))
.route("/api/tree/events", get(sse::tree_events))
.route("/api/stream/events", get(sse::events))
.route("/api/realtime/ws", get(ws::socket))
.nest(
@@ -65,7 +91,8 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/ai-agent/run", post(compat::next_ai_agent_run))
.route("/sidebar", get(compat::next_sidebar)),
);
)
.fallback(gateway::legacy_next_proxy);
if enable_debug_shell_routes {
router = router
@@ -3,13 +3,13 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_convex_query_plan;
use bridge_runtime::{
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input,
execute_runtime_query,
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
RuntimeSourceWire,
};
use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value;
use storage_convex_bridge::{BridgeContext, build_query_request};
use storage_convex_bridge::{build_query_request, BridgeContext};
pub fn resolve_effective_workspace_id(
context: &RequestContext,
+333
View File
@@ -0,0 +1,333 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_against_data, resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsRequest {
pub workspace_id: Option<String>,
pub query: Option<String>,
pub document_id: Option<String>,
pub limit: Option<u32>,
pub filters: Option<SearchDocumentsFilters>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsFilters {
pub title_only: Option<bool>,
pub exact: Option<bool>,
pub include_ocr: Option<bool>,
pub only_current_page: Option<bool>,
pub time_range: Option<String>,
pub time_field: Option<String>,
pub custom_range: Option<SearchCustomRange>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchCustomRange {
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchShellQuery {
pub workspace_id: Option<String>,
pub q: Option<String>,
}
pub async fn shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<SearchShellQuery>,
) -> Result<Response, WebError> {
let workspace_id = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
let contract = json!({
"schema": "mnote.search_shell.v1",
"owner": "mnote-web",
"shell": "search",
"workspaceId": workspace_id,
"query": search_query,
"initialResults": {
"queryName": "search.documents",
"results": []
},
"island": {
"kind": "react_search_palette",
"mountId": "mnote-search-island",
"runtime": "SearchPaletteHost"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id
});
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
let body_content = crate::ssr::render_view(leptos::view! {
<SearchPage
workspace_id={workspace_id.to_string()}
search_query={search_query.to_string()}
sidebar_tree_html={sidebar_tree_html}
/>
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>搜索</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
{}
<script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
body_content,
escape_script_json(&contract_json),
);
let mut response = Html(html).into_response();
stamp_search_headers(response.headers_mut());
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") {
response
.headers_mut()
.insert(name, HeaderValue::from_static("search"));
}
Ok(response)
}
pub async fn documents(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<SearchDocumentsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let filters = body.filters.unwrap_or_default();
let normalized_query = body.query.unwrap_or_default().trim().to_string();
let page_id = if filters.only_current_page.unwrap_or(false) {
body.document_id.filter(|value| !value.trim().is_empty())
} else {
None
};
let result = if normalized_query.is_empty() {
json!({
"enqueueAssetIds": [],
"results": [],
})
} else {
execute_runtime_query_against_data(
&context,
Some(&effective_workspace_id),
RuntimeQueryEnvelopeWire {
name: "search.documents".into(),
payload: json!({
"query": normalized_query,
"workspaceId": effective_workspace_id,
"pageId": page_id,
"limit": body.limit.unwrap_or(30),
"titleOnly": filters.title_only.unwrap_or(false),
"exact": filters.exact.unwrap_or(false),
"includeOcr": filters.include_ocr.unwrap_or(false),
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
}),
},
json!({
"documents": [
{
"id": "doc_1",
"workspaceId": effective_workspace_id,
"title": "Rust Web 搜索结果",
"rawText": "mnote-web search documents transport",
"createdAt": "2026-04-28T00:00:00Z",
"updatedAt": "2026-04-28T00:00:00Z"
}
],
"mindmaps": [],
"tables": [],
"tableRows": [],
"assets": []
}),
)?
};
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
"recent": [],
"meta": {
"owner": "mnote-web",
"queryName": "search.documents",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
},
})),
))
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn stamp_search_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_QUERY_NAME.as_bytes()) {
headers.insert(name, HeaderValue::from_static("search.documents"));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
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: true,
enable_debug_shell_routes: false,
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(),
}))
}
#[tokio::test]
async fn search_shell_returns_server_first_island_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/search?workspaceId=ws_demo&q=Rust")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("search")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-mnote-shell=\"search\""));
assert!(html.contains("mnote.search_shell.v1"));
assert!(html.contains("react_search_palette"));
assert!(html.contains("search.documents"));
}
#[tokio::test]
async fn search_documents_route_is_owned_by_mnote_web() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Rust Web",
"filters": {
"titleOnly": false,
"exact": false,
"includeOcr": false,
"onlyCurrentPage": false,
"timeRange": "any",
"timeField": "updated"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-query-name")
.and_then(|value| value.to_str().ok()),
Some("search.documents")
);
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["meta"]["owner"], "mnote-web");
assert_eq!(payload["meta"]["queryName"], "search.documents");
}
}
+175
View File
@@ -0,0 +1,175 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeConfigResponse {
pub ok: bool,
pub owner: &'static str,
pub public_entry: String,
pub tree_renderer_family: &'static str,
pub document_editor_host: &'static str,
pub legacy_next_compat_enabled: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionResponse {
pub ok: bool,
pub owner: &'static str,
pub user_id: String,
pub email: String,
pub name: String,
pub actor_type: String,
pub request_id: String,
pub trace_id: String,
}
pub async fn runtime_config(State(state): State<AppState>) -> Response {
owner_json(Json(RuntimeConfigResponse {
ok: true,
owner: "mnote-web",
public_entry: state.config().public_bind_addr.clone(),
tree_renderer_family: "rust_family",
document_editor_host: "leptos_tiptap_island",
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
}))
}
pub async fn session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Response {
owner_json(Json(SessionResponse {
ok: true,
owner: "mnote-web",
user_id: state.config().dev_user_id.clone(),
email: state.config().dev_user_email.clone(),
name: state.config().dev_user_name.clone(),
actor_type: context.auth.actor_type,
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
}))
}
pub async fn refresh_session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Response {
let mut response = owner_json(Json(SessionResponse {
ok: true,
owner: "mnote-web",
user_id: state.config().dev_user_id.clone(),
email: state.config().dev_user_email.clone(),
name: state.config().dev_user_name.clone(),
actor_type: context.auth.actor_type,
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
}));
*response.status_mut() = StatusCode::OK;
response
}
fn owner_json<T>(payload: Json<T>) -> Response
where
T: Serialize,
{
let mut response = payload.into_response();
stamp_owner_header(response.headers_mut());
response
}
fn stamp_owner_header(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
fn app() -> axum::Router {
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: true,
enable_debug_shell_routes: false,
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(),
}))
}
#[tokio::test]
async fn runtime_config_is_owned_by_mnote_web_and_hides_internal_urls() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/runtime/config")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["treeRendererFamily"], "rust_family");
assert!(payload.get("legacyNextBaseUrl").is_none());
assert!(payload.get("convexAdminKey").is_none());
}
#[tokio::test]
async fn session_handoff_returns_dev_identity_without_internal_secret() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/auth/session")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["userId"], "dev-user");
assert!(payload.get("convexAdminKey").is_none());
}
}
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> {
+66 -4
View File
@@ -2,10 +2,12 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream;
use serde_json::Value;
@@ -117,6 +119,28 @@ pub async fn events(
))
}
pub async fn tree_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<StreamSnapshotQuery>,
) -> Result<
(
HeaderMap,
Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
),
WebError,
> {
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-tree-stream-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
let sse = events(State(state), Extension(context), Query(query)).await?;
Ok((headers, sse))
}
#[derive(Clone)]
struct StreamPollState {
app_state: AppState,
@@ -137,8 +161,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -147,6 +171,9 @@ mod tests {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -183,4 +210,39 @@ mod tests {
assert!(text.contains("\"projection\":\"sidebar_tree\""));
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
}
#[tokio::test]
async fn tree_realtime_route_returns_rust_web_owned_snapshot_event() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-tree-stream-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-web")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("\"kind\":\"snapshot\""));
}
}
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save",
@@ -588,8 +588,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)]
mod tests {
use super::{
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change,
resolve_stream_cursor, resolve_stream_scope,
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
StreamSnapshotQuery, StreamSnapshotScope,
};
use serde_json::json;
+201 -23
View File
@@ -5,34 +5,37 @@ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
};
use crate::tree_shell::picker_renderer::{
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -90,6 +93,10 @@ pub enum TreeCommandRequest {
parent_id: Option<String>,
sort_order: i64,
},
Purge {
workspace_id: Option<String>,
document_id: String,
},
}
fn escape_html(input: &str) -> String {
@@ -201,7 +208,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default()
}
fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
projection
.get("items")
.and_then(Value::as_array)
@@ -4467,9 +4474,98 @@ fn create_command_wire(
validate_only: false,
})
}
TreeCommandRequest::Purge {
workspace_id: _,
document_id,
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire {
name: "tree.node.purge".into(),
command_id: format!("tree_purge_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("tree-shell purge".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
}
}
async fn resolve_tree_create_workspace_id(
state: &AppState,
context: &RequestContext,
requested_workspace_id: Option<&str>,
parent_id: Option<&str>,
) -> Result<String, WebError> {
if let Some(workspace_id) =
resolve_effective_workspace_id(context, requested_workspace_id, false)?
{
return Ok(workspace_id);
}
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
let parent_meta =
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
if let Some(workspace_id) = parent_meta
.get("workspace_id")
.or_else(|| parent_meta.get("workspaceId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(workspace_id.to_string());
}
}
let bootstrap = execute_convex_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
json!({
"fallbackName": context.auth.actor_id,
"workspaceIdIfCreate": generate_tree_document_id(),
}),
None,
None,
"tree_command_workspace_bootstrap",
)
.await?;
bootstrap
.get("activeWorkspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
WebError::bad_gateway_code(
"workspace_bootstrap_bad_response",
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
)
.with_context(context)
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
})
}
pub async fn tree_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -4504,6 +4600,10 @@ pub async fn tree_command(
parent_id: raw_request.parent_id,
sort_order: raw_request.sort_order.unwrap_or(-1),
},
"purge" => TreeCommandRequest::Purge {
workspace_id: raw_request.workspace_id,
document_id: raw_request.document_id.unwrap_or_default(),
},
other => {
return Err(WebError::bad_request_code(
"tree_command_validation",
@@ -4517,6 +4617,7 @@ pub async fn tree_command(
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(),
};
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
match &request {
@@ -4553,10 +4654,27 @@ pub async fn tree_command(
None,
Some(*sort_order),
),
TreeCommandRequest::Purge { document_id, .. } => (
"purge",
document_id.clone(),
None,
None,
None,
),
};
let effective_workspace_id =
resolve_effective_workspace_id(&context, requested_workspace_id, true)?
.expect("workspace_required 已确保存在");
let effective_workspace_id = match &request {
TreeCommandRequest::Create { parent_id, .. } => {
resolve_tree_create_workspace_id(
&state,
&context,
requested_workspace_id,
parent_id.as_deref(),
)
.await?
}
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
.expect("workspace_required 已确保存在"),
};
let command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
@@ -4607,8 +4725,8 @@ pub async fn reduce_tree_shell_runtime(
#[cfg(test)]
mod tests {
use super::{TreeCommandRequest, create_command_wire};
use crate::app::{AppConfig, AppState, build_app};
use super::{create_command_wire, TreeCommandRequest};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
@@ -4621,6 +4739,9 @@ mod tests {
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: true,
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -4628,7 +4749,7 @@ mod tests {
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
@@ -4795,9 +4916,8 @@ mod tests {
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(filetree_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
@@ -4825,9 +4945,8 @@ mod tests {
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(picker_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
}
#[tokio::test]
@@ -4991,6 +5110,65 @@ mod tests {
);
}
#[tokio::test]
async fn tree_command_create_uses_default_workspace_when_workspace_missing() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(r#"{"action":"create","title":"新页面"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(
payload["result"]["workspaceId"],
Value::String("ws_demo".into())
);
assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
}
#[tokio::test]
async fn tree_command_purge_uses_tree_command_protocol() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("purge".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1));
}
#[tokio::test]
async fn tree_command_rejects_negative_sort_order() {
let response = app()
@@ -0,0 +1,469 @@
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::error::WebError;
use crate::page_aggregate::PageAggregate;
use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
use crate::routes::tree::collect_page_tree_render_rows;
use crate::tree_shell::page_renderer::{
render_initial_page_tree_html, PageTreeInitialRenderInput,
};
use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use crate::ssr::pages::document::DocumentPage;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
}
pub async fn document_page_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
)
.await?;
let title = aggregate.head_title();
let workspace_id = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
</body>
</html>"#,
escape_html(title),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
body_content,
escape_script_json(&snapshot_json),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document");
stamp_recent_page_cookie(response.headers_mut(), &document_id);
Ok(response)
}
pub async fn page_aggregate(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
)
.await?;
let mut response = (
StatusCode::OK,
Json(json!({
"ok": true,
"owner": "mnote-web",
"schema": "mnote.page_aggregate.v1",
"result": aggregate,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.into_response();
stamp_shell_headers(response.headers_mut(), "page-aggregate");
Ok(response)
}
async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
) -> Result<PageAggregate, WebError> {
let meta = load_document_meta_result(
state,
context,
DocumentMetaQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let content = load_document_content_result(
state,
context,
DocumentContentQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let conflict_detection_key = content
.get("conflictDetectionKey")
.or_else(|| content.get("conflict_detection_key"))
.cloned()
.unwrap_or(Value::Null);
let page_subtree = content
.get("pageSubtree")
.or_else(|| content.get("page_subtree"))
.cloned()
.unwrap_or(Value::Null);
let todo_total = meta
.get("todo_total")
.or_else(|| meta.get("todo_total_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let todo_done = meta
.get("todo_done")
.or_else(|| meta.get("todo_done_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
Ok(PageAggregate::builder()
// identity
.document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id))
.workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default"))
// head
.title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题"))
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
.read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false))
.disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false))
.disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false))
// layout
.wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false))
.small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false))
.show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true))
.show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false))
.show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false))
.protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false))
.show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true))
.collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false))
.page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default"))
.layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal"))
.hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false))
.show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false))
.embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null))
// body
.content(content.get("content").cloned().unwrap_or(Value::Null))
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
.conflict_detection_key(conflict_detection_key)
// tree
.page_subtree(page_subtree)
// stats
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
.character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0))
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
.todo_total(todo_total)
.todo_done(todo_done)
.build())
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
let value = document_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect::<String>();
if value.is_empty() {
return;
}
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
headers.insert(name, HeaderValue::from_static(shell));
}
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
pub(crate) async fn load_workspace_shell_projection(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
default_workspace_name: &str,
) -> WorkspaceShellProjection {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let dataset = load_projection_snapshot(config, context, &spec)
.await
.map(|snapshot| snapshot.dataset)
.unwrap_or_else(|_| {
let documents = active_document_id
.map(|document_id| {
json!([{
"id": document_id,
"workspace_id": workspace_id,
"title": "个人",
"parent_id": null,
"sort_order": 0,
"is_starred": false
}])
})
.unwrap_or_else(|| json!([]));
json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": documents
})
});
build_workspace_shell_projection(
&dataset,
workspace_id,
active_document_id,
default_workspace_name,
)
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
pub(crate) async fn load_sidebar_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some(snapshot.projection),
Err(_) if config.allow_dev_fixtures => {
// Dev 模式降级:使用内建示例页面树数据集
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": [
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 },
],
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
}
Err(_) => None,
};
result.map(|projection| {
let rows = collect_page_tree_render_rows(&projection);
render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
})
})
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
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: true,
enable_debug_shell_routes: false,
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: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"word_count": 42,
"character_count": 128,
"block_count": 3
},
"documents:getContent": {
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
}
}"#
.into(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn document_shell_returns_page_aggregate_snapshot() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("document")
);
assert!(response
.headers()
.get_all("set-cookie")
.iter()
.any(|value| value
.to_str()
.unwrap_or_default()
.contains("mnote_recent_page_id=doc_1")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-testid=\"wolai-sidebar\""));
assert!(html.contains("data-testid=\"wolai-topbar\""));
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
assert!(html.contains("星标置顶"));
assert!(html.contains("我的页面"));
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(!html.contains("mnote-web-document-shell"));
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_snapshot_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
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["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{StreamSnapshotQuery, load_stream_snapshot};
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::response::Response;
use futures_util::StreamExt;
use serde_json::{Value, json};
use serde_json::{json, Value};
pub async fn socket(
ws: WebSocketUpgrade,
+66
View File
@@ -0,0 +1,66 @@
//! SSR (Server-Side Rendering) 模块
//!
//! 提供 Leptos 0.8 的 SSR 渲染基础设施。
//! 使用 `RenderHtml::to_html_with_buf` 将 Leptos view 渲染为 HTML 字符串。
pub mod pages;
pub mod styles;
pub use styles::MNOTE_CSS;
use leptos::prelude::*;
use leptos::tachys::view::Position;
/// 将任何实现 `RenderHtml` 的视图渲染为 HTML 字符串 (SSR)
///
/// 使用 Leptos 0.8 的 `RenderHtml::to_html_with_buf` 方法,
/// 通过 `Position::default()` (即 FirstChild) 初始化位置状态。
pub fn render_view(view: impl RenderHtml) -> String {
let mut buf = String::new();
let mut position = Position::default();
RenderHtml::to_html_with_buf(
view,
&mut buf,
&mut position,
true, // escape — 对 HTML 特殊字符进行转义
false, // mark_branches — 不标记分支注释
vec![], // extra_attrs — 无需额外属性
);
buf
}
#[cfg(test)]
mod tests {
use super::*;
use leptos::view;
#[test]
fn render_view_produces_non_empty_html() {
let html = render_view(view! { <h1>"Hello SSR"</h1> });
assert!(!html.is_empty());
assert!(html.contains("Hello SSR"));
assert!(html.contains("<h1>"));
}
#[test]
fn render_view_escapes_html_special_chars() {
let html = render_view(view! { <p>"<script>alert('xss')</script>"</p> });
assert!(html.contains("&lt;"));
assert!(!html.contains("<script>"));
}
#[test]
fn render_view_renders_nested_elements() {
let html = render_view(view! {
<main>
<header><h1>"Title"</h1></header>
<section><p>"Content"</p></section>
</main>
});
assert!(html.contains("<main>"));
assert!(html.contains("<header>"));
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<section>"));
assert!(html.contains("<p>Content</p>"));
}
}
@@ -0,0 +1,21 @@
//! MNOTE 登录页面组件
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 登录页面
///
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
#[component]
pub fn AuthPage() -> impl IntoView {
view! {
<PageLayout current_nav="home">
<section class="mnote-auth">
<h1>MNOTE </h1>
<p>
"此页面由 mnote-web gateway 提供。"
</p>
</section>
</PageLayout>
}
}
@@ -0,0 +1,38 @@
//! MNOTE 文档页面组件 (SSR)
//!
//! 提供文档编辑器的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 文档页面
///
/// 渲染文档编辑器的 SSR 壳结构:
/// - `<PageLayout current_nav="documents">`
/// - 标题区域
/// - 页面聚合快照标记
/// - 编辑器占位区
#[component]
pub fn DocumentPage(
/// 文档标题
title: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island">
<header class="document-shell-header">
<h1>{title}</h1>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,36 @@
//! MNOTE 首页组件(Wolai 风格)
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 首页。
#[component]
pub fn HomePage(
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
<div class="mnote-home">
<div class="mnote-home-icon" aria-hidden="true">""</div>
<h1>"个人"</h1>
<div class="mnote-home-links">
<a href="/documents/backup"><span>""</span>"正版软件备份"</a>
<a href="/documents/growth"><span>""</span>"个人发展"</a>
<a href="/documents/notes"><span>""</span>"杂记"</a>
<a href="/documents/passwords"><span>""</span>"密码"</a>
<a href="/documents/wolai-guide"><span>""</span>"Wolai 教程"</a>
<a href="/documents/health"><span>""</span>"个人健康"</a>
<a href="/documents/software"><span>""</span>"软件开发"</a>
</div>
</div>
</PageLayout>
}
}
@@ -0,0 +1,148 @@
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
<script>
(function(){
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
// 高亮当前页
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
}
}
}
// 展开/折叠
tree.addEventListener('click', function(e) {
var btn = e.target.closest('[data-rust-action]');
if (!btn) return;
var nodeId = btn.getAttribute('data-node-id');
var action = btn.getAttribute('data-rust-action');
if (action === 'toggle') {
var row = btn.closest('.tree-row');
if (!row) return;
var li = row.parentElement;
var children = li.querySelector(':scope > .tree-children');
if (children) {
children.classList.toggle('tree-children--collapsed');
var isCollapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
btn.textContent = isCollapsed ? '▸' : '';
}
e.preventDefault();
} else if (action === 'open') {
window.location.href = '/documents/' + encodeURIComponent(nodeId);
e.preventDefault();
}
});
})();
</script>
"##;
/// MNOTE Wolai 风格页面布局
///
/// 包含左侧栏 + 内容区的双栏布局。
/// 侧栏显示品牌、导航链接和可选的页面树。
///
/// # 用法
///
/// ```ignore
/// view! {
/// <PageLayout current_nav="home" sidebar_tree_html={None}>
/// <section>...</section>
/// </PageLayout>
/// }
/// ```
#[component]
pub fn PageLayout(
children: Children,
current_nav: &'static str,
/// 侧栏页面树 HTML(可选),由路由 handler 渲染
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选),显示在侧栏顶部
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> impl IntoView {
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
let ws_name = workspace_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "开发用户 的空间".to_string());
let sidebar_sections_html = workspace_sidebar_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
let dataset = serde_json::json!({
"workspaces": [{ "id": "default", "name": ws_name.clone() }],
"documents": []
});
let projection = crate::workspace_shell::build_workspace_shell_projection(
&dataset,
"default",
None,
&ws_name,
);
crate::workspace_shell::render_workspace_shell_sidebar_html(
&projection,
Some(sidebar_tree_html.as_str()),
)
});
view! {
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
<span class="sidebar-workspace-name">{ws_name}</span>
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">""</span></a>
<a href="/graph" title="关系图"><span class="nav-icon">""</span></a>
<a href="/actions" title="快捷动作"><span class="nav-icon">""</span></a>
<a href="/inbox" title="收纳"><span class="nav-icon">""</span></a>
<a href="/files" title="文件"><span class="nav-icon">""</span></a>
<a href="/more" title="更多"><span class="nav-icon">""</span></a>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
</aside>
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left"><span class="wolai-menu-icon">""</span><span class="wolai-home-icon">""</span><span>{"个人"}</span></div>
<div class="wolai-topbar-actions" aria-label="页面操作">
<span title="收藏">""</span>
<span title="演示">""</span>
<span title="评论">""</span>
<span title="关系">""</span>
<span title="成员">"♙+"</span>
<span title="历史">""</span>
<span title="更多">""</span>
</div>
</header>
<article class="mnote-content">
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button">"AI"</button>
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button">"?"</button>
</div>
</div>
</div>
}
}
@@ -0,0 +1,37 @@
//! MNOTE 思维导图页面组件 (SSR)
//!
//! 提供思维导图页面的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 思维导图页面
///
/// 渲染思维导图的 SSR 壳结构:
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
/// - 思维导图标题区域
/// - React 运行时挂载占位区
#[component]
pub fn MindmapPage(
/// 文档 ID
document_id: String,
/// 思维导图 ID
mindmap_id: String,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents">
<main
id="mnote-mindmap-shell"
data-object-shell="mindmap"
data-document-id={document_id}
data-mindmap-id={mindmap_id}
>
<header>
<h1>{"思维导图"}</h1>
</header>
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,8 @@
//! SSR 页面组件
pub mod auth;
pub mod document;
pub mod home;
pub mod layout;
pub mod mindmap;
pub mod search;
@@ -0,0 +1,80 @@
//! MNOTE 搜索页面组件 (SSR)
//!
//! 提供搜索页面的服务器端渲染壳。
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 搜索页面
///
/// 渲染搜索页面的 SSR 壳结构:
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
/// - 搜索标题区域
/// - 搜索岛占位区
#[component]
pub fn SearchPage(
/// 工作区 ID
#[prop(into)]
workspace_id: String,
/// 搜索查询
#[prop(into)]
search_query: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main id="mnote-search-shell" data-island-host="react_search_palette">
<header class="search-header">
<h1>{"搜索"}</h1>
<p class="search-meta">
{format!("工作区: {}", workspace_id)}
</p>
<p class="search-query">
{if search_query.is_empty() {
"请输入关键词".to_string()
} else {
format!("查询: {}", search_query)
}}
</p>
</header>
<section id="mnote-search-island" data-react-island="search_palette"></section>
</main>
</PageLayout>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssr::render_view;
#[test]
fn search_page_renders_correct_structure() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="Rust" />
});
assert!(html.contains("mnote-search-shell"));
assert!(html.contains("data-island-host=\"react_search_palette\""));
assert!(html.contains("mnote-search-island"));
assert!(html.contains("data-react-island=\"search_palette\""));
assert!(html.contains("搜索"));
}
#[test]
fn search_page_contains_layout() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="" />
});
assert!(html.contains("mnote-shell"));
assert!(html.contains("mnote-sidebar"));
assert!(html.contains("mnote-sidebar-brand"));
assert!(html.contains("mnote-sidebar-nav"));
assert!(html.contains("mnote-content"));
}
}
+878
View File
@@ -0,0 +1,878 @@
//! MNOTE CSS 样式常量
//!
//! 为所有 Leptos SSR 页面提供 wolai / Notion 风格样式,
//! 匹配 3100 (Next.js) 前端的 wolai 设计系统。
/// MNOTE 完整 CSS 样式字符串
///
/// Wolai / Notion 风格:白色背景、近黑正文、浅色侧栏、极简边框。
/// 覆盖所有页面组件使用的 class 和 id。
pub const MNOTE_CSS: &str = r##"
/* ===== 基础重置 ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ===== Wolai 设计变量(来自设计稿) ===== */
:root {
/* 背景色 */
--color-basic-50: #F7F7F5;
--color-basic-75: #F0F0EE;
--color-basic-100: #EBEBEB;
--color-basic-200: #E3E3E0;
--color-basic-300: #D4D4D1;
--color-basic-400: #C4C4C1;
--color-basic-500: #A9A9A6;
--color-basic-600: #9B9A97;
--color-basic-700: #6D6D69;
--color-basic-800: #504F4B;
--color-basic-900: #37352F;
/* wolai 语义变量 */
--wolai-bg: #FFFFFF;
--wolai-bg-sidebar: var(--color-basic-50);
--wolai-bg-hover: rgba(55, 53, 47, 0.08);
--wolai-bg-active: rgba(55, 53, 47, 0.12);
--wolai-text-primary: var(--color-basic-900);
--wolai-text-secondary: var(--color-basic-600);
--wolai-border: #E9E9E8;
--wolai-brand: #22A559;
--wolai-accent: #2563eb;
--wolai-accent-hover: #1d4ed8;
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
--wolai-radius: 4px;
}
/* ===== 基础排版 ===== */
html, body {
height: 100%;
}
body {
font-family: var(--wolai-font-sans);
color: var(--wolai-text-primary);
background: var(--wolai-bg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--wolai-accent);
text-decoration: none;
cursor: pointer;
}
a:hover {
color: var(--wolai-accent-hover);
}
/* ===== 滚动条(悬停时显示) ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
}
/* ===== 页面布局:侧栏 + 内容 ===== */
.mnote-shell {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
background: var(--wolai-bg);
}
.wolai-workspace-shell {
--wolai-bg-sidebar: #F7F7F6;
--wolai-bg-selected: #F8E6E7;
--wolai-accent-red: #E0525B;
}
.wolai-sidebar {
width: 288px;
background: var(--wolai-bg-sidebar);
}
.wolai-sidebar-header {
height: 52px;
gap: 10px;
padding: 12px 16px 8px;
justify-content: flex-start;
}
.wolai-avatar {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: #D6545D;
color: #fff;
font-size: 15px;
font-weight: 700;
flex: 0 0 auto;
}
.wolai-sidebar-header .sidebar-workspace-name {
max-width: 190px;
color: #202124;
font-size: 16px;
font-weight: 650;
}
.wolai-sidebar-chevron {
margin-left: auto;
color: var(--wolai-text-secondary);
font-size: 16px;
}
.wolai-quick-actions {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 12px 18px;
}
.wolai-quick-actions a {
height: 28px;
justify-content: center;
padding: 0;
margin: 0;
color: #4B4B4B;
}
.wolai-quick-actions a .nav-icon {
margin: 0;
}
.wolai-sidebar-section {
padding: 4px 8px 14px;
}
.wolai-section-title {
height: 28px;
display: flex;
align-items: center;
gap: 4px;
color: #B5B5B2;
font-size: 14px;
padding: 0 8px;
}
.wolai-section-icon {
color: #FFB33F;
}
.wolai-section-caret {
color: #B5B5B2;
}
.wolai-section-add {
margin-left: auto;
font-size: 22px;
color: #B5B5B2;
}
.wolai-page-row {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
padding: 0 12px;
color: #535353;
font-size: 17px;
line-height: 1.2;
}
.wolai-page-row:hover {
background: var(--wolai-bg-hover);
color: #202124;
}
.wolai-muted-row {
color: #747471;
}
.wolai-active-row {
background: var(--wolai-bg-selected);
color: var(--wolai-accent-red);
}
.wolai-row-icon {
width: 22px;
color: #111;
text-align: center;
flex: 0 0 auto;
}
.wolai-sidebar-footer {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--wolai-border);
background: #FBFBFA;
}
.wolai-footer-entry {
height: 46px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #6D6D69;
font-size: 15px;
}
.wolai-footer-entry + .wolai-footer-entry {
border-left: 1px solid var(--wolai-border);
}
.wolai-topbar {
height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 auto;
padding: 0 18px;
color: #222;
font-size: 17px;
}
.wolai-topbar-left,
.wolai-topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.wolai-topbar-actions {
color: #222;
font-size: 20px;
}
.wolai-menu-icon {
color: #4A4A4A;
font-size: 22px;
}
.wolai-home-icon {
color: #000;
font-size: 24px;
font-weight: 700;
}
.wolai-floating-actions {
position: fixed;
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 20;
}
.wolai-floating-button {
width: 50px;
height: 50px;
border: 1px solid #ECECF1;
border-radius: 50%;
background: #fff;
color: #222;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
font-size: 18px;
cursor: pointer;
}
/* 侧栏导航 */
.mnote-sidebar {
width: 288px;
flex-shrink: 0;
background: var(--wolai-bg-sidebar);
border-right: 1px solid var(--wolai-border);
display: flex;
flex-direction: column;
overflow-y: auto;
user-select: none;
}
.mnote-sidebar-header {
padding: 14px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.mnote-sidebar-brand {
font-size: 15px;
font-weight: 600;
color: var(--wolai-text-primary);
text-decoration: none;
}
.mnote-sidebar-brand:hover {
color: var(--wolai-text-primary);
}
.mnote-sidebar-nav {
flex: 1;
padding: 4px 8px;
}
.mnote-sidebar-nav a {
display: flex;
align-items: center;
height: 30px;
padding: 4px 8px;
margin-bottom: 2px;
border-radius: var(--wolai-radius);
font-size: 14px;
line-height: 1.4;
color: var(--wolai-text-primary);
text-decoration: none;
transition: none;
}
.mnote-sidebar-nav a:hover {
background: var(--wolai-bg-hover);
}
.mnote-sidebar-nav a.active {
background: var(--wolai-bg-active);
}
.mnote-sidebar-nav a .nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 5px;
font-size: 18px;
flex-shrink: 0;
}
/* 工作区名称 */
.sidebar-workspace-name {
font-size: 12px;
color: var(--wolai-text-secondary);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 侧栏页面树区 */
.sidebar-tree-section {
border-top: 1px solid var(--wolai-border);
margin-top: 4px;
padding-top: 4px;
}
.sidebar-tree-divider {
display: none;
}
.sidebar-tree {
padding: 2px 0;
}
.sidebar-tree .tree-root {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-node {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-row {
display: flex;
align-items: center;
height: 30px;
padding: 0 8px;
border-radius: var(--wolai-radius);
cursor: pointer;
font-size: 14px;
color: var(--wolai-text-primary);
gap: 2px;
}
.sidebar-tree .tree-row:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-row[data-active="true"] {
background: var(--wolai-bg-active);
}
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
font-weight: 500;
}
.sidebar-tree .tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
color: var(--wolai-text-secondary);
padding: 0;
border-radius: 3px;
}
.sidebar-tree .tree-toggle:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-spacer {
display: inline-flex;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.sidebar-tree .tree-kind-badge {
display: none;
}
.sidebar-tree .tree-link {
flex: 1;
display: flex;
align-items: center;
border: none;
background: none;
cursor: pointer;
padding: 0 4px;
font-size: 14px;
color: var(--wolai-text-primary);
overflow: hidden;
min-width: 0;
}
.sidebar-tree .tree-link-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tree .tree-actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.sidebar-tree .tree-row:hover .tree-actions {
display: flex;
}
.sidebar-tree .tree-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
color: var(--wolai-text-secondary);
border-radius: 3px;
padding: 0;
}
.sidebar-tree .tree-action:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-children {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-children--collapsed {
display: none;
}
/* 树行缩进 — 通过嵌套深度自动偏移 */
.sidebar-tree .tree-node .tree-children .tree-row {
padding-left: 28px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-row {
padding-left: 48px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-row {
padding-left: 68px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-children .tree-row {
padding-left: 88px;
}
/* 内容区域 */
.mnote-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wolai-bg);
}
.mnote-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* ===== 首页 ===== */
.mnote-home {
max-width: 720px;
margin: 0 auto;
padding: 72px 64px;
}
.mnote-home-icon {
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 22px;
color: #000;
font-size: 92px;
line-height: 1;
}
.mnote-home h1 {
text-align: center;
font-size: 44px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 34px;
color: var(--wolai-text-primary);
}
.mnote-home p {
font-size: 16px;
color: var(--wolai-text-secondary);
line-height: 1.6;
margin-bottom: 32px;
}
.mnote-home-links {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;
margin: 0 auto;
}
.mnote-home-links a {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
border-radius: var(--wolai-radius);
font-size: 21px;
font-weight: 650;
color: var(--wolai-text-primary);
text-decoration: none;
transition: background-color 80ms ease;
}
.mnote-home-links a:hover {
background: var(--wolai-bg-hover);
}
.mnote-home-links a::before {
content: none;
}
.mnote-home-links a span {
width: 26px;
text-align: center;
color: #111;
}
/* ===== 文档壳 ===== */
.document-shell {
width: 100%;
min-height: 100vh;
margin: 0 auto;
}
.document-shell-header {
padding: 48px 0 0;
margin: 0 auto 8px;
max-width: 760px;
}
.document-shell-header h1 {
font-size: 40px;
font-weight: 700;
line-height: 1.2;
color: var(--wolai-text-primary);
word-break: break-word;
padding: 3px 0;
}
#mnote-editor-island {
margin: 0 15px;
max-width: 760px;
width: calc(100% - 30px);
min-height: 300px;
padding-bottom: 48px;
}
#mnote-editor-island .bn-editor {
padding: 0;
max-width: none;
margin: 0;
}
/* ===== 搜索壳 ===== */
#mnote-search-shell {
max-width: 800px;
min-height: 100vh;
margin: 0 auto;
padding: 48px 64px;
}
#mnote-search-shell header {
margin-bottom: 24px;
}
#mnote-search-shell h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
margin-bottom: 4px;
}
.search-meta,
.search-query {
color: var(--wolai-text-secondary);
font-size: 14px;
}
#mnote-search-island {
min-height: 200px;
}
/* ===== 思维导图壳 ===== */
#mnote-mindmap-shell {
max-width: 1000px;
min-height: 100vh;
margin: 0 auto;
padding: 48px 64px;
}
#mnote-mindmap-shell header {
margin-bottom: 24px;
}
#mnote-mindmap-shell h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
}
#mnote-mindmap-island {
min-height: 400px;
}
/* ===== 认证页面 ===== */
.mnote-auth {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
background: var(--wolai-bg);
}
.mnote-auth-card {
text-align: center;
padding: 48px 32px;
max-width: 340px;
}
.mnote-auth-card h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
margin-bottom: 8px;
}
.mnote-auth-card p {
color: var(--wolai-text-secondary);
font-size: 15px;
line-height: 1.5;
}
/* ===== ProseMirror 兼容 ===== */
.ProseMirror {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.ProseMirror pre {
white-space: pre-wrap;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.mnote-sidebar {
width: 200px;
}
.mnote-home {
padding: 48px 24px;
}
.mnote-home h1 {
font-size: 28px;
}
.document-shell-header {
padding: 24px 0 0;
max-width: 100%;
}
.document-shell-header h1 {
font-size: 28px;
}
#mnote-editor-island {
margin: 0 24px;
width: auto;
padding-bottom: 24px;
}
#mnote-search-shell,
#mnote-mindmap-shell {
padding: 24px;
}
#mnote-search-shell h1,
#mnote-mindmap-shell h1 {
font-size: 24px;
}
}
@media (max-width: 480px) {
.mnote-sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--wolai-border);
}
.mnote-shell {
flex-direction: column;
}
.mnote-home {
padding: 32px 16px;
}
.mnote-home h1 {
font-size: 24px;
}
.document-shell-header {
padding: 16px 0 0;
}
.document-shell-header h1 {
font-size: 24px;
}
#mnote-editor-island {
margin: 0 16px;
width: auto;
padding-bottom: 16px;
}
}
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mnote_css_contains_core_selectors() {
// Wolai 设计系统 CSS 变量
assert!(MNOTE_CSS.contains("--wolai-text-primary"));
assert!(MNOTE_CSS.contains("--wolai-text-secondary"));
assert!(MNOTE_CSS.contains("--wolai-border"));
assert!(MNOTE_CSS.contains("--wolai-bg-sidebar"));
assert!(MNOTE_CSS.contains("--wolai-accent"));
// 布局结构
assert!(MNOTE_CSS.contains(".mnote-shell"));
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
assert!(MNOTE_CSS.contains(".mnote-main"));
assert!(MNOTE_CSS.contains(".mnote-content"));
}
#[test]
fn mnote_css_contains_shell_ids() {
assert!(MNOTE_CSS.contains(".document-shell"));
assert!(MNOTE_CSS.contains("#mnote-search-shell"));
assert!(MNOTE_CSS.contains("#mnote-mindmap-shell"));
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
assert!(MNOTE_CSS.contains("#mnote-search-island"));
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
}
#[test]
fn mnote_css_contains_responsive_breakpoints() {
assert!(MNOTE_CSS.contains("@media (max-width: 768px)"));
assert!(MNOTE_CSS.contains("@media (max-width: 480px)"));
}
#[test]
fn mnote_css_contains_scrollbar_styles() {
assert!(MNOTE_CSS.contains("::-webkit-scrollbar"));
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
}
#[test]
fn mnote_css_contains_prosemirror_styles() {
assert!(MNOTE_CSS.contains(".ProseMirror"));
}
#[test]
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 最多 20000 字符避免过于臃肿
assert!(MNOTE_CSS.len() < 20000);
}
}
+76 -13
View File
@@ -2,13 +2,14 @@ use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use bridge_runtime::{
RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan,
build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use base64::Engine;
const HEADER_REQUEST_ID: &str = "x-request-id";
const HEADER_TRACE_ID: &str = "x-trace-id";
@@ -17,6 +18,7 @@ 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_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
@@ -60,6 +62,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
return Ok(authorization.to_string());
}
if let Some(convex_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
return Ok(format!("Bearer {convex_token}"));
}
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
return Ok(format!("Bearer {convex_token}"));
}
@@ -78,12 +84,23 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex")
})?;
// 说明:
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
Ok(format!("Convex {admin_key}"))
let dev_user_id = config.dev_user_id.trim();
if dev_user_id.is_empty() {
return Ok(format!("Convex {admin_key}"));
}
// 说明:Rust Web 直接调用 Convex HTTP API 时没有 Next/Convex Auth cookie。
// 自托管开发态用 admin auth 携带 acting identity,让 @convex-dev/auth 的
// getAuthUserId(ctx) 能得到 DEV_USER_ID,从而和 Next 开发态免登录语义一致。
let identity = json!({
"subject": format!("{}|mnote-web-dev-session", dev_user_id),
"issuer": "mnote-web-dev",
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let identity_encoded = base64::engine::general_purpose::STANDARD
.encode(identity.to_string().as_bytes());
Ok(format!("Convex {admin_key}:{identity_encoded}"))
}
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
@@ -459,7 +476,7 @@ fn now_iso_like() -> String {
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
async fn execute_convex_mutation_by_name(
pub async fn execute_convex_mutation_by_name(
config: &AppConfig,
context: &RequestContext,
function_name: &str,
@@ -699,12 +716,16 @@ mod tests {
use crate::app::AppConfig;
use crate::context::RequestContext;
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
use base64::Engine;
fn config() -> AppConfig {
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: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -744,11 +765,21 @@ mod tests {
}
#[test]
fn build_authorization_falls_back_to_plain_admin_auth() {
fn build_authorization_falls_back_to_dev_admin_identity() {
let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
.expect("authorization");
assert_eq!(authorization, "Convex admin-demo");
assert!(authorization.starts_with("Convex admin-demo:"));
let encoded = authorization
.trim_start_matches("Convex admin-demo:")
.trim();
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("identity base64");
let identity: serde_json::Value = serde_json::from_slice(&decoded).expect("identity json");
assert_eq!(identity["subject"], "dev-user|mnote-web-dev-session");
assert_eq!(identity["issuer"], "mnote-web-dev");
assert_eq!(identity["email"], "dev@mnote.local");
}
#[test]
@@ -766,4 +797,36 @@ mod tests {
assert_eq!(authorization, "Bearer token-from-cookie");
}
#[test]
fn build_authorization_prefers_convex_auth_jwt_over_legacy_handoff_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"mnote_web_convex_token=legacy-token; __convexAuthJWT=jwt-from-convex-auth",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
}
#[test]
fn build_authorization_reads_convex_auth_jwt_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"foo=bar; __convexAuthJWT=jwt-from-convex-auth; __convexAuthRefreshToken=refresh",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
}
}
@@ -23,17 +23,4 @@ impl TreeShellCommandAction {
Self::Copy => "tree.subtree.copy",
}
}
pub fn compat_command_name(self) -> &'static str {
match self {
Self::Create => "documents.create",
Self::Rename => "documents.title.update",
Self::Move => "documents.move",
Self::Archive => "documents.delete",
Self::Restore => "documents.restore",
Self::Purge => "documents.purge",
Self::Embed => "documents.embed",
Self::Copy => "documents.copy_tree",
}
}
}
@@ -545,9 +545,7 @@ mod tests {
copy: false,
},
);
assert!(rejected
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(rejected.outputs.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!(
output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
@@ -399,8 +399,8 @@ fn transition<const N: usize>(
mod tests {
use super::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
PageTreeRuntimeOutput, PageTreeRuntimeState,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeRow, PageTreeRuntimeState,
};
fn ids(values: &[&str]) -> Vec<String> {
@@ -247,12 +247,10 @@ mod tests {
});
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(
filetree
.filetree_selection
.selected_row_ids
.contains("asset:a")
);
assert!(filetree
.filetree_selection
.selected_row_ids
.contains("asset:a"));
assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!(
filetree
@@ -334,74 +332,52 @@ mod tests {
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
assert!(
artifact
.runtime_api
.dom_patch_kinds
.contains("fileTreeState")
);
assert!(artifact
.runtime_api
.dom_patch_kinds
.contains("fileTreeState"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeOpen")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeInternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeExternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("pickerPickDocument")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("createNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("renameNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveSubtree")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("copyResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("uploadResource")
);
assert!(artifact
.runtime_api
.host_event_kinds
.contains("fileTreeOpen"));
assert!(artifact
.runtime_api
.host_event_kinds
.contains("fileTreeInternalDrop"));
assert!(artifact
.runtime_api
.host_event_kinds
.contains("fileTreeExternalDrop"));
assert!(artifact
.runtime_api
.host_event_kinds
.contains("pickerPickDocument"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("createNode"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("renameNode"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("moveSubtree"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("copyResource"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("moveResource"));
assert!(artifact
.runtime_api
.command_event_kinds
.contains("uploadResource"));
assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse"));
@@ -4,9 +4,8 @@ use super::filetree_runtime::{
FileTreeRuntimeState,
};
use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeState,
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeOutput, PageTreeRuntimeState,
};
use super::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
@@ -393,6 +392,7 @@ mod tests {
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_runtime::{
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
FileTreeRuntimeState,
@@ -407,7 +407,6 @@ mod tests {
use crate::tree_shell::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use serde_json::json;
use std::collections::BTreeSet;
@@ -0,0 +1,333 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellProjection {
pub schema: String,
pub workspace_id: String,
pub workspace_name: String,
pub active_page_id: Option<String>,
pub active_page_title: Option<String>,
pub starred_items: Vec<WorkspaceShellItem>,
pub my_page_items: Vec<WorkspaceShellItem>,
pub bottom_entries: Vec<WorkspaceShellEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
pub href: String,
pub depth: u32,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellEntry {
pub id: String,
pub label: String,
pub href: String,
pub icon: String,
}
pub fn build_workspace_shell_projection(
dataset: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
default_workspace_name: &str,
) -> WorkspaceShellProjection {
let active_page_id = active_page_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let documents = dataset
.get("documents")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let workspace_name = resolve_workspace_name(dataset, workspace_id)
.unwrap_or_else(|| default_workspace_name.trim().to_string())
.if_empty_else(|| "个人空间".to_string());
let mut my_page_items = documents
.iter()
.filter_map(|document| document_to_item(document, workspace_id, None))
.collect::<Vec<_>>();
my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_id = active_page_id
.or_else(|| {
dataset
.get("active_page_id")
.or_else(|| dataset.get("activePageId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
for item in &mut my_page_items {
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
}
let mut starred_items = documents
.iter()
.filter(|document| {
document
.get("is_starred")
.or_else(|| document.get("isStarred"))
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
.collect::<Vec<_>>();
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
my_page_items
.iter()
.find(|item| item.id == active_id)
.map(|item| item.title.clone())
});
WorkspaceShellProjection {
schema: "mnote.workspace_shell.v1".into(),
workspace_id: workspace_id.to_string(),
workspace_name,
active_page_id,
active_page_title,
starred_items,
my_page_items,
bottom_entries: vec![
WorkspaceShellEntry {
id: "trash".into(),
label: "垃圾箱".into(),
href: "/trash".into(),
icon: "🗑".into(),
},
WorkspaceShellEntry {
id: "templates".into(),
label: "模板中心".into(),
href: "/templates".into(),
icon: "📦".into(),
},
],
}
}
fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
dataset
.get("workspaces")
.and_then(Value::as_array)
.and_then(|workspaces| {
workspaces.iter().find_map(|workspace| {
let id = workspace
.get("id")
.or_else(|| workspace.get("workspace_id"))
.or_else(|| workspace.get("workspaceId"))
.and_then(Value::as_str)?;
if id != workspace_id {
return None;
}
workspace
.get("name")
.or_else(|| workspace.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
}
fn document_to_item(
document: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
) -> Option<WorkspaceShellItem> {
let document_workspace_id = document
.get("workspace_id")
.or_else(|| document.get("workspaceId"))
.and_then(Value::as_str)
.unwrap_or(workspace_id);
if document_workspace_id != workspace_id {
return None;
}
if document
.get("deleted_at")
.or_else(|| document.get("deletedAt"))
.is_some_and(|value| !value.is_null())
{
return None;
}
let id = document
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let title = document
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
let depth = document
.get("depth")
.and_then(Value::as_u64)
.or_else(|| {
document
.get("parent_id")
.or_else(|| document.get("parentId"))
.and_then(Value::as_str)
.map(|_| 1)
})
.unwrap_or(0) as u32;
Some(WorkspaceShellItem {
id: id.to_string(),
title: title.to_string(),
icon: document
.get("icon")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
active: active_page_id.is_some_and(|active_id| active_id == id),
})
}
trait StringEmptyExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
impl StringEmptyExt 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 serde_json::json;
#[test]
fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() {
let dataset = json!({
"active_workspace_id": "ws_demo",
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [
{ "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true },
{ "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false }
]
});
let projection = build_workspace_shell_projection(
&dataset,
"ws_demo",
Some("page_home"),
"开发用户 的工作区",
);
assert_eq!(projection.schema, "mnote.workspace_shell.v1");
assert_eq!(projection.workspace_id, "ws_demo");
assert_eq!(projection.workspace_name, "开发用户 的工作区");
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
assert_eq!(projection.starred_items.len(), 1);
assert_eq!(projection.starred_items[0].title, "个人");
assert_eq!(projection.my_page_items.len(), 2);
assert!(projection.my_page_items[0].active);
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
}
}
pub fn render_workspace_shell_sidebar_html(
projection: &WorkspaceShellProjection,
sidebar_tree_html: Option<&str>,
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
} else {
projection
.starred_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("")
};
let projected_my_pages = projection
.my_page_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("");
let tree_html = sidebar_tree_html
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
)
})
.unwrap_or_default();
let my_pages = if projected_my_pages.is_empty() {
tree_html
} else {
format!("{projected_my_pages}{tree_html}")
};
let bottom_entries = projection
.bottom_entries
.iter()
.map(|entry| {
format!(
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
escape_html(&entry.href),
escape_html(&entry.icon),
escape_html(&entry.label),
)
})
.collect::<Vec<_>>()
.join("");
format!(
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
)
}
fn render_item_row(item: &WorkspaceShellItem) -> String {
let active_class = if item.active { " wolai-active-row" } else { "" };
let depth_style = if item.depth == 0 {
String::new()
} else {
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(item.icon.as_deref().unwrap_or("")),
escape_html(&item.title),
)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}