feat: cut over rust web main shell
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/// 加载侧栏页面树 HTML(SSR)
|
||||
///
|
||||
/// 从 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user