2026-04-29 12:24:44 +08:00
|
|
|
use crate::app::AppState;
|
2026-05-22 17:45:22 +08:00
|
|
|
use crate::context::RequestContext;
|
2026-04-29 12:24:44 +08:00
|
|
|
use crate::error::WebError;
|
2026-05-19 08:07:17 +08:00
|
|
|
use crate::routes::local_folder_source::{
|
2026-05-22 17:45:22 +08:00
|
|
|
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
2026-05-22 17:45:22 +08:00
|
|
|
load_local_folder_page_tree_snapshot, load_local_trash_entries,
|
2026-05-19 08:07:17 +08:00
|
|
|
};
|
2026-05-16 07:38:45 +08:00
|
|
|
use crate::routes::snapshot_support::load_sidebar_dataset;
|
2026-04-29 14:36:24 +08:00
|
|
|
use crate::routes::web_shell::{
|
2026-05-13 22:43:16 +08:00
|
|
|
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
|
|
|
|
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
|
|
|
|
load_sidebar_tree_html, load_workspace_shell_projection,
|
2026-04-30 16:18:54 +08:00
|
|
|
render_document_title_controller_script, render_editor_island_adapter_script,
|
2026-05-08 00:41:03 +08:00
|
|
|
render_local_file_tree_html, render_local_sidebar_tree_html,
|
2026-04-29 14:36:24 +08:00
|
|
|
};
|
|
|
|
|
use crate::transport::convex::execute_convex_mutation_by_name;
|
2026-05-08 00:41:03 +08:00
|
|
|
use crate::workspace_shell::{
|
|
|
|
|
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::body::Body;
|
2026-05-11 13:16:34 +08:00
|
|
|
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::extract::{Extension, Query, State};
|
2026-05-11 13:16:34 +08:00
|
|
|
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
2026-05-20 10:43:38 +08:00
|
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
2026-05-22 17:45:22 +08:00
|
|
|
use control_plane::{
|
|
|
|
|
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
|
|
|
|
UpsertUserInput,
|
|
|
|
|
};
|
2026-05-11 13:16:34 +08:00
|
|
|
use futures_util::{SinkExt, StreamExt};
|
2026-05-16 07:38:45 +08:00
|
|
|
use leptos::prelude::InnerHtmlAttribute;
|
2026-04-29 12:24:44 +08:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-16 07:38:45 +08:00
|
|
|
use serde_json::{json, Value};
|
2026-05-20 10:43:38 +08:00
|
|
|
use std::collections::BTreeMap;
|
2026-04-29 12:24:44 +08:00
|
|
|
use std::time::Duration;
|
2026-05-11 13:16:34 +08:00
|
|
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
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";
|
2026-05-06 21:44:20 +08:00
|
|
|
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
|
|
|
|
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
|
|
|
|
|
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
|
|
|
|
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
|
2026-05-22 17:45:22 +08:00
|
|
|
const COOKIE_MNOTE_SESSION: &str = "mnote_session";
|
2026-05-20 10:43:38 +08:00
|
|
|
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
|
|
|
|
|
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
|
2026-05-21 23:53:39 +08:00
|
|
|
const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
|
|
|
|
const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name";
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
#[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>,
|
2026-04-29 14:36:24 +08:00
|
|
|
workspace_id: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
source_kind: Option<String>,
|
|
|
|
|
root_uri: Option<String>,
|
2026-05-21 14:39:38 +08:00
|
|
|
restore_focus_row_id: Option<String>,
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
pub async fn favicon() -> Response {
|
|
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::NO_CONTENT)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response());
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn auth_api(
|
2026-04-29 12:24:44 +08:00
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
request: Request<Body>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-06 21:44:20 +08:00
|
|
|
let body = axum::body::to_bytes(request.into_body(), 256 * 1024)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?;
|
|
|
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"auth_bad_request",
|
|
|
|
|
format!("登录请求不是合法 JSON: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
})?;
|
|
|
|
|
let action = payload
|
|
|
|
|
.get("action")
|
|
|
|
|
.and_then(|value| value.as_str())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if action != "auth:signIn" && action != "auth:signOut" {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"auth_action_unsupported",
|
2026-05-22 17:45:22 +08:00
|
|
|
"Rust gateway 当前仅支持账号登录、注册与登出动作。",
|
2026-05-06 21:44:20 +08:00
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
|
|
|
|
}
|
2026-05-21 23:53:39 +08:00
|
|
|
if action == "auth:signOut" {
|
2026-05-22 17:45:22 +08:00
|
|
|
return Ok(build_sqlite_sign_out_response(&state, &context));
|
2026-05-21 23:53:39 +08:00
|
|
|
}
|
2026-05-06 21:44:20 +08:00
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
handle_sqlite_auth_action(&state, &context, &payload)
|
2026-05-06 21:44:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn auth_entry(
|
2026-05-22 17:45:22 +08:00
|
|
|
State(state): State<AppState>,
|
2026-05-06 21:44:20 +08:00
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
_request: Request<Body>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-22 17:45:22 +08:00
|
|
|
if has_real_auth_context(&state, &context) {
|
2026-05-06 21:44:20 +08:00
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::SEE_OTHER)
|
|
|
|
|
.header(header::LOCATION, "/")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?;
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
pub async fn admin_access_policy_entry(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-22 17:45:22 +08:00
|
|
|
if !has_real_auth_context(&state, &context) {
|
2026-05-19 08:07:17 +08:00
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::SEE_OTHER)
|
|
|
|
|
.header(header::LOCATION, "/auth")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
if !is_local_access_policy_admin_context(&context) {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_access_policy_admin_required",
|
|
|
|
|
"只有管理员可以访问目录授权页面",
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context));
|
|
|
|
|
}
|
2026-05-22 17:45:22 +08:00
|
|
|
let workspace_name = default_workspace_name_for_context(&state, &context);
|
|
|
|
|
let share_grants_path = control_plane_db_path_display();
|
2026-05-19 08:07:17 +08:00
|
|
|
let content = crate::ssr::render_view(leptos::view! {
|
2026-05-22 17:45:22 +08:00
|
|
|
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
|
2026-05-19 08:07:17 +08:00
|
|
|
});
|
|
|
|
|
let mut response = Html(format!(
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
2026-05-22 01:47:40 +08:00
|
|
|
<title>授权管理</title>
|
2026-05-19 08:07:17 +08:00
|
|
|
<style>{}</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
|
|
|
|
|
{}
|
|
|
|
|
</body>
|
|
|
|
|
</html>"#,
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
escape_html(context.auth.actor_id.as_str()),
|
|
|
|
|
content
|
|
|
|
|
))
|
|
|
|
|
.into_response();
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 01:47:40 +08:00
|
|
|
pub async fn user_access_policy_entry(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-22 17:45:22 +08:00
|
|
|
if !has_real_auth_context(&state, &context) {
|
2026-05-22 01:47:40 +08:00
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::SEE_OTHER)
|
|
|
|
|
.header(header::LOCATION, "/auth")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
2026-05-22 17:45:22 +08:00
|
|
|
let workspace_name = default_workspace_name_for_context(&state, &context);
|
2026-05-22 01:47:40 +08:00
|
|
|
let content = crate::ssr::render_view(leptos::view! {
|
2026-05-22 17:45:22 +08:00
|
|
|
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} is_admin=false />
|
2026-05-22 01:47:40 +08:00
|
|
|
});
|
|
|
|
|
let mut response = 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="user-access-policy" data-mnote-actor-id="{}">
|
|
|
|
|
{}
|
|
|
|
|
</body>
|
|
|
|
|
</html>"#,
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
escape_html(context.auth.actor_id.as_str()),
|
|
|
|
|
content
|
|
|
|
|
))
|
|
|
|
|
.into_response();
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
pub async fn root_entry(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<RootEntryQuery>,
|
2026-04-29 14:36:24 +08:00
|
|
|
) -> Result<Response, WebError> {
|
2026-05-22 17:45:22 +08:00
|
|
|
if !has_real_auth_context(&state, &context) {
|
2026-05-06 21:44:20 +08:00
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::SEE_OTHER)
|
|
|
|
|
.header(header::LOCATION, "/auth")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
let is_local_folder = query
|
|
|
|
|
.source_kind
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
== Some("local_folder");
|
2026-04-29 14:36:24 +08:00
|
|
|
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
|
2026-04-29 12:24:44 +08:00
|
|
|
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
2026-04-29 14:36:24 +08:00
|
|
|
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
|
2026-05-22 17:45:22 +08:00
|
|
|
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
|
|
|
|
let actor_id =
|
|
|
|
|
current_actor_id(&state, &context).unwrap_or_else(|| context.auth.actor_id.clone());
|
|
|
|
|
let actor_type = current_actor_type(&state, &context);
|
2026-05-19 08:07:17 +08:00
|
|
|
let should_render_local_first_landing = !is_local_folder
|
|
|
|
|
&& query
|
|
|
|
|
.source_kind
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.is_none()
|
|
|
|
|
&& query
|
|
|
|
|
.workspace_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.is_none()
|
|
|
|
|
&& requested_page_id.is_none();
|
2026-05-08 00:41:03 +08:00
|
|
|
let (
|
|
|
|
|
workspace_id,
|
|
|
|
|
workspace_projection,
|
|
|
|
|
sidebar_tree_html,
|
|
|
|
|
file_tree_html,
|
|
|
|
|
selected_active_page_id,
|
|
|
|
|
active_source_kind,
|
|
|
|
|
active_root_uri,
|
|
|
|
|
) = if is_local_folder {
|
|
|
|
|
let root_uri = query
|
|
|
|
|
.root_uri
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
|
|
|
|
})?;
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
|
|
|
|
let workspace_id = snapshot
|
|
|
|
|
.dataset
|
|
|
|
|
.get("workspace")
|
|
|
|
|
.and_then(|workspace| workspace.get("id"))
|
|
|
|
|
.and_then(|value| value.as_str())
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("local-folder")
|
|
|
|
|
.to_string();
|
2026-05-24 01:49:51 +08:00
|
|
|
let requested_or_recent_page_id =
|
|
|
|
|
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
|
2026-05-08 00:41:03 +08:00
|
|
|
let workspace_projection = build_workspace_shell_projection(
|
|
|
|
|
&snapshot.dataset,
|
|
|
|
|
&workspace_id,
|
|
|
|
|
requested_or_recent_page_id.as_deref(),
|
|
|
|
|
"本地文件夹",
|
|
|
|
|
);
|
|
|
|
|
let selected_active_page_id = choose_root_entry_active_page_id(
|
|
|
|
|
requested_page_id.clone(),
|
2026-05-24 01:49:51 +08:00
|
|
|
None,
|
2026-05-08 00:41:03 +08:00
|
|
|
workspace_projection.active_page_id.as_deref(),
|
|
|
|
|
workspace_projection
|
|
|
|
|
.my_page_items
|
|
|
|
|
.first()
|
|
|
|
|
.map(|item| item.id.as_str()),
|
|
|
|
|
);
|
|
|
|
|
let sidebar_tree_html =
|
|
|
|
|
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
2026-05-21 14:39:38 +08:00
|
|
|
let restore_focus_row_id = query
|
|
|
|
|
.restore_focus_row_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
let file_tree_html = render_local_file_tree_html(
|
|
|
|
|
root_uri,
|
|
|
|
|
selected_active_page_id.as_deref(),
|
|
|
|
|
restore_focus_row_id,
|
|
|
|
|
)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
(
|
|
|
|
|
workspace_id,
|
|
|
|
|
workspace_projection,
|
|
|
|
|
sidebar_tree_html,
|
|
|
|
|
file_tree_html,
|
|
|
|
|
selected_active_page_id,
|
|
|
|
|
Some("local_folder".to_string()),
|
|
|
|
|
Some(root_uri.to_string()),
|
|
|
|
|
)
|
2026-05-19 08:07:17 +08:00
|
|
|
} else if should_render_local_first_landing {
|
2026-05-22 17:45:22 +08:00
|
|
|
let payload = create_default_local_workspace_for_actor(&actor_id, &actor_type)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let root_uri = payload
|
|
|
|
|
.get("workspace")
|
|
|
|
|
.and_then(|workspace| workspace.get("rootUri"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
|
|
|
|
|
})?
|
|
|
|
|
.to_string();
|
|
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
|
|
|
|
|
let workspace_id = snapshot
|
|
|
|
|
.dataset
|
|
|
|
|
.get("workspace")
|
|
|
|
|
.and_then(|workspace| workspace.get("id"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("local-folder")
|
|
|
|
|
.to_string();
|
2026-05-19 08:07:17 +08:00
|
|
|
let workspace_projection = build_workspace_shell_projection(
|
2026-05-20 10:43:38 +08:00
|
|
|
&snapshot.dataset,
|
2026-05-19 08:07:17 +08:00
|
|
|
&workspace_id,
|
2026-05-20 10:43:38 +08:00
|
|
|
requested_page_id.as_deref(),
|
2026-05-22 17:45:22 +08:00
|
|
|
&default_workspace_name,
|
2026-05-19 08:07:17 +08:00
|
|
|
);
|
2026-05-20 10:43:38 +08:00
|
|
|
let selected_active_page_id = choose_root_entry_active_page_id(
|
|
|
|
|
requested_page_id.clone(),
|
|
|
|
|
recent_page_id.clone(),
|
|
|
|
|
workspace_projection.active_page_id.as_deref(),
|
|
|
|
|
workspace_projection
|
|
|
|
|
.my_page_items
|
|
|
|
|
.first()
|
|
|
|
|
.map(|item| item.id.as_str()),
|
|
|
|
|
);
|
|
|
|
|
let sidebar_tree_html =
|
|
|
|
|
render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?;
|
|
|
|
|
let file_tree_html =
|
2026-05-21 14:39:38 +08:00
|
|
|
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
|
2026-05-19 08:07:17 +08:00
|
|
|
(
|
|
|
|
|
workspace_id,
|
|
|
|
|
workspace_projection,
|
2026-05-20 10:43:38 +08:00
|
|
|
sidebar_tree_html,
|
|
|
|
|
file_tree_html,
|
|
|
|
|
selected_active_page_id,
|
|
|
|
|
Some("local_folder".to_string()),
|
|
|
|
|
Some(root_uri),
|
2026-05-19 08:07:17 +08:00
|
|
|
)
|
2026-05-08 00:41:03 +08:00
|
|
|
} else {
|
|
|
|
|
let workspace_id =
|
|
|
|
|
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
|
|
|
|
let requested_or_recent_page_id = choose_root_entry_active_page_id(
|
|
|
|
|
requested_page_id.clone(),
|
|
|
|
|
recent_page_id.clone(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let workspace_projection = load_workspace_shell_projection(
|
|
|
|
|
state.config(),
|
|
|
|
|
&context,
|
|
|
|
|
&workspace_id,
|
|
|
|
|
requested_or_recent_page_id.as_deref(),
|
|
|
|
|
&default_workspace_name,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let selected_active_page_id = choose_root_entry_active_page_id(
|
|
|
|
|
requested_page_id.clone(),
|
|
|
|
|
recent_page_id.clone(),
|
|
|
|
|
workspace_projection.active_page_id.as_deref(),
|
|
|
|
|
workspace_projection
|
|
|
|
|
.my_page_items
|
|
|
|
|
.first()
|
|
|
|
|
.map(|item| item.id.as_str()),
|
|
|
|
|
);
|
|
|
|
|
let sidebar_tree_html = load_sidebar_tree_html(
|
|
|
|
|
state.config(),
|
|
|
|
|
&context,
|
|
|
|
|
&workspace_id,
|
|
|
|
|
selected_active_page_id.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let file_tree_html = load_file_tree_html(
|
|
|
|
|
state.config(),
|
|
|
|
|
&context,
|
|
|
|
|
&workspace_id,
|
|
|
|
|
selected_active_page_id.as_deref(),
|
2026-05-16 07:38:45 +08:00
|
|
|
None,
|
2026-05-08 00:41:03 +08:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
(
|
|
|
|
|
workspace_id,
|
|
|
|
|
workspace_projection,
|
|
|
|
|
sidebar_tree_html,
|
|
|
|
|
file_tree_html,
|
|
|
|
|
selected_active_page_id,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
|
|
|
|
&workspace_projection,
|
|
|
|
|
Some(sidebar_tree_html.as_str()),
|
2026-04-29 14:36:24 +08:00
|
|
|
Some(file_tree_html.as_str()),
|
2026-04-29 12:24:44 +08:00
|
|
|
);
|
|
|
|
|
let workspace_name = workspace_projection.workspace_name.clone();
|
2026-04-29 14:36:24 +08:00
|
|
|
let active_page_id = selected_active_page_id.unwrap_or_default();
|
|
|
|
|
let active_page_title = workspace_projection
|
|
|
|
|
.active_page_title
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_default();
|
2026-05-19 08:07:17 +08:00
|
|
|
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
|
2026-04-30 16:18:54 +08:00
|
|
|
let render_workspace_entry = || {
|
|
|
|
|
crate::ssr::render_view(leptos::view! {
|
|
|
|
|
<crate::ssr::pages::home::HomePage
|
|
|
|
|
sidebar_tree_html={sidebar_tree_html.clone()}
|
|
|
|
|
workspace_name={workspace_name.clone()}
|
|
|
|
|
workspace_id={workspace_id.clone()}
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
|
|
|
|
active_page_id={active_page_id.clone()}
|
|
|
|
|
active_page_title={active_page_title.clone()}
|
2026-05-19 08:07:17 +08:00
|
|
|
show_admin_access_policy={show_admin_access_policy}
|
2026-04-30 16:18:54 +08:00
|
|
|
/>
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
|
2026-05-24 01:49:51 +08:00
|
|
|
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
|
|
|
|
|
let panes_bootstrap_json = serde_json::to_string(&json!({
|
|
|
|
|
"schema": "mnote.document_panes_bootstrap.v1",
|
|
|
|
|
"secondaryRequested": false,
|
|
|
|
|
"secondaryInvalid": false,
|
|
|
|
|
"panes": [],
|
|
|
|
|
}))
|
|
|
|
|
.unwrap_or_else(|_| "{}".to_string());
|
|
|
|
|
format!(
|
|
|
|
|
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
|
|
|
|
{}"#,
|
|
|
|
|
escape_script_json(&panes_bootstrap_json),
|
|
|
|
|
render_editor_island_adapter_script(),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
("MNOTE".to_string(), render_workspace_entry(), body_extra)
|
2026-04-30 16:18:54 +08:00
|
|
|
} else {
|
|
|
|
|
match build_page_aggregate_snapshot(
|
|
|
|
|
&state,
|
|
|
|
|
&context,
|
|
|
|
|
&active_page_id,
|
|
|
|
|
Some(workspace_id.as_str()),
|
2026-05-08 00:41:03 +08:00
|
|
|
active_source_kind.as_deref(),
|
|
|
|
|
active_root_uri.as_deref(),
|
2026-04-30 16:18:54 +08:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(aggregate) => {
|
|
|
|
|
let title = aggregate.head.title.as_str();
|
|
|
|
|
let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree)
|
|
|
|
|
.unwrap_or_else(|_| "null".to_string());
|
|
|
|
|
let snapshot_json =
|
|
|
|
|
serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
2026-05-08 00:41:03 +08:00
|
|
|
let bootstrap_json = build_editor_bootstrap_json(
|
|
|
|
|
&aggregate,
|
|
|
|
|
&context,
|
|
|
|
|
active_source_kind.as_deref(),
|
|
|
|
|
active_root_uri.as_deref(),
|
|
|
|
|
);
|
2026-05-13 22:43:16 +08:00
|
|
|
let panes_bootstrap_json = build_document_panes_bootstrap_json(
|
|
|
|
|
&aggregate,
|
|
|
|
|
&context,
|
|
|
|
|
active_source_kind.as_deref(),
|
|
|
|
|
active_root_uri.as_deref(),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
false,
|
|
|
|
|
false,
|
|
|
|
|
);
|
2026-04-30 16:18:54 +08:00
|
|
|
let content = crate::ssr::render_view(leptos::view! {
|
|
|
|
|
<crate::ssr::pages::document::DocumentPage
|
|
|
|
|
title={title.to_string()}
|
|
|
|
|
document_id={active_page_id.clone()}
|
|
|
|
|
workspace_id={workspace_id.clone()}
|
|
|
|
|
sidebar_tree_html={sidebar_tree_html.clone()}
|
|
|
|
|
workspace_name={workspace_name.clone()}
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
|
|
|
|
page_subtree_json={page_subtree_json}
|
2026-05-19 08:07:17 +08:00
|
|
|
show_admin_access_policy={show_admin_access_policy}
|
2026-05-20 10:43:38 +08:00
|
|
|
enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
|
2026-04-30 16:18:54 +08:00
|
|
|
/>
|
|
|
|
|
});
|
|
|
|
|
let body_extra = format!(
|
|
|
|
|
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
|
|
|
|
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
2026-05-13 22:43:16 +08:00
|
|
|
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
2026-04-30 16:18:54 +08:00
|
|
|
{}
|
|
|
|
|
{}"#,
|
|
|
|
|
escape_script_json(&snapshot_json),
|
|
|
|
|
escape_script_json(&bootstrap_json),
|
2026-05-13 22:43:16 +08:00
|
|
|
escape_script_json(&panes_bootstrap_json),
|
2026-04-30 16:18:54 +08:00
|
|
|
render_document_title_controller_script(),
|
|
|
|
|
render_editor_island_adapter_script(),
|
|
|
|
|
);
|
|
|
|
|
(title.to_string(), content, body_extra)
|
|
|
|
|
}
|
|
|
|
|
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
let mut response = Html(format!(
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
2026-04-30 16:18:54 +08:00
|
|
|
<title>{}</title>
|
2026-04-29 12:24:44 +08:00
|
|
|
<style>{}</style>
|
|
|
|
|
</head>
|
2026-05-20 10:43:38 +08:00
|
|
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
2026-04-29 12:24:44 +08:00
|
|
|
{}
|
2026-04-30 16:18:54 +08:00
|
|
|
{}
|
2026-04-29 12:24:44 +08:00
|
|
|
</body>
|
|
|
|
|
</html>"#,
|
2026-04-30 16:18:54 +08:00
|
|
|
escape_html(&html_title),
|
2026-04-29 12:24:44 +08:00
|
|
|
crate::ssr::MNOTE_CSS,
|
2026-05-19 08:07:17 +08:00
|
|
|
escape_html(context.auth.actor_id.as_str()),
|
2026-05-20 10:43:38 +08:00
|
|
|
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
|
|
|
|
|
escape_html(active_root_uri.as_deref().unwrap_or("")),
|
2026-04-30 16:18:54 +08:00
|
|
|
content,
|
|
|
|
|
body_extra
|
2026-04-29 12:24:44 +08:00
|
|
|
))
|
|
|
|
|
.into_response();
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
2026-04-29 14:36:24 +08:00
|
|
|
Ok(response)
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
pub async fn trash_entry(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<RootEntryQuery>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-22 17:45:22 +08:00
|
|
|
if !has_real_auth_context(&state, &context) {
|
2026-05-16 07:38:45 +08:00
|
|
|
let mut response = Response::builder()
|
|
|
|
|
.status(StatusCode::SEE_OTHER)
|
|
|
|
|
.header(header::LOCATION, "/auth")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
let source_kind = query
|
|
|
|
|
.source_kind
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
if source_kind == Some("local_folder") {
|
|
|
|
|
let root_uri = query
|
|
|
|
|
.root_uri
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
|
|
|
|
})?;
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
2026-05-20 10:43:38 +08:00
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
let workspace_id =
|
|
|
|
|
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
|
|
|
|
|
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
|
2026-05-21 14:39:38 +08:00
|
|
|
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
|
2026-05-20 10:43:38 +08:00
|
|
|
let workspace_projection = build_workspace_shell_projection(
|
|
|
|
|
&json!({
|
|
|
|
|
"active_workspace_id": workspace_id,
|
|
|
|
|
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
|
|
|
|
"documents": [],
|
|
|
|
|
}),
|
|
|
|
|
&workspace_id,
|
|
|
|
|
None,
|
|
|
|
|
"我的空间",
|
|
|
|
|
);
|
|
|
|
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
|
|
|
|
&workspace_projection,
|
|
|
|
|
Some(sidebar_tree_html.as_str()),
|
|
|
|
|
Some(file_tree_html.as_str()),
|
|
|
|
|
);
|
|
|
|
|
let trash_workbench_html = render_local_trash_workbench_html(
|
|
|
|
|
&workspace_id,
|
|
|
|
|
root_uri,
|
|
|
|
|
&load_local_trash_entries(root_uri)?,
|
|
|
|
|
);
|
|
|
|
|
let content = crate::ssr::render_view(leptos::view! {
|
|
|
|
|
<crate::ssr::pages::layout::PageLayout
|
|
|
|
|
current_nav="trash"
|
|
|
|
|
sidebar_tree_html={sidebar_tree_html.clone()}
|
|
|
|
|
workspace_name={"我的空间".to_string()}
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
|
|
|
|
topbar_title={"垃圾箱".to_string()}
|
|
|
|
|
enable_tree_live={false}
|
|
|
|
|
>
|
|
|
|
|
<div inner_html={trash_workbench_html}></div>
|
|
|
|
|
</crate::ssr::pages::layout::PageLayout>
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let mut response = 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="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
|
|
|
|
|
{}
|
|
|
|
|
</body>
|
|
|
|
|
</html>"#,
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
escape_html(root_uri),
|
|
|
|
|
content,
|
|
|
|
|
))
|
|
|
|
|
.into_response();
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
context.apply_response_headers(response.headers_mut());
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
2026-05-16 07:38:45 +08:00
|
|
|
let workspace_id =
|
|
|
|
|
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
|
|
|
|
let workspace_projection = load_workspace_shell_projection(
|
|
|
|
|
state.config(),
|
|
|
|
|
&context,
|
|
|
|
|
&workspace_id,
|
|
|
|
|
None,
|
|
|
|
|
&default_workspace_name,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, &workspace_id, None)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let file_tree_html = load_file_tree_html(state.config(), &context, &workspace_id, None, None)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
|
|
|
|
&workspace_projection,
|
|
|
|
|
Some(sidebar_tree_html.as_str()),
|
|
|
|
|
Some(file_tree_html.as_str()),
|
|
|
|
|
);
|
|
|
|
|
let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_else(|_| {
|
|
|
|
|
json!({
|
|
|
|
|
"active_workspace_id": workspace_id,
|
|
|
|
|
"workspaces": [{ "id": workspace_id, "name": workspace_projection.workspace_name }],
|
|
|
|
|
"documents": [],
|
|
|
|
|
"trashed_documents": [],
|
|
|
|
|
"trashed_media_assets": [],
|
|
|
|
|
"trashed_mindmap_assets": [],
|
|
|
|
|
"trashed_table_assets": [],
|
|
|
|
|
"degraded": true
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
let trash_workbench_html = render_trash_workbench_html(&workspace_id, &dataset);
|
|
|
|
|
let workspace_name = workspace_projection.workspace_name.clone();
|
|
|
|
|
let content = crate::ssr::render_view(leptos::view! {
|
|
|
|
|
<crate::ssr::pages::layout::PageLayout
|
|
|
|
|
current_nav="trash"
|
|
|
|
|
sidebar_tree_html={sidebar_tree_html.clone()}
|
|
|
|
|
workspace_name={workspace_name.clone()}
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
|
|
|
|
topbar_title={"垃圾箱".to_string()}
|
|
|
|
|
>
|
|
|
|
|
<div inner_html={trash_workbench_html}></div>
|
|
|
|
|
</crate::ssr::pages::layout::PageLayout>
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let mut response = 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="workspace">
|
|
|
|
|
{}
|
|
|
|
|
</body>
|
|
|
|
|
</html>"#,
|
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
content,
|
|
|
|
|
))
|
|
|
|
|
.into_response();
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
context.apply_response_headers(response.headers_mut());
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
|
|
|
|
|
let documents = json_array(dataset, "trashed_documents");
|
|
|
|
|
let media_assets = json_array(dataset, "trashed_media_assets");
|
|
|
|
|
let mindmap_assets = json_array(dataset, "trashed_mindmap_assets");
|
|
|
|
|
let table_assets = json_array(dataset, "trashed_table_assets");
|
|
|
|
|
let resource_count = media_assets.len() + mindmap_assets.len() + table_assets.len();
|
|
|
|
|
let document_rows = render_trashed_document_rows(workspace_id, documents);
|
|
|
|
|
let mut resource_rows = String::new();
|
|
|
|
|
resource_rows.push_str(&render_trashed_resource_rows("media", "附件", media_assets));
|
|
|
|
|
resource_rows.push_str(&render_trashed_resource_rows(
|
|
|
|
|
"mindmap",
|
|
|
|
|
"思维导图",
|
|
|
|
|
mindmap_assets,
|
|
|
|
|
));
|
|
|
|
|
resource_rows.push_str(&render_trashed_resource_rows("table", "表格", table_assets));
|
|
|
|
|
let resource_body = if resource_count == 0 {
|
|
|
|
|
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
resource_rows
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
format!(
|
|
|
|
|
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-workspace-id="{workspace_id}" data-trash-fetch-path="/trash">
|
|
|
|
|
<header class="mnote-trash-header">
|
|
|
|
|
<h1>垃圾箱</h1>
|
|
|
|
|
<p>默认删除的页面会先进入这里;彻底删除会永久移除。</p>
|
|
|
|
|
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
|
|
|
|
</header>
|
|
|
|
|
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
|
|
|
|
<div class="mnote-trash-section-title">
|
|
|
|
|
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
|
|
|
|
<button type="button" data-trash-action="empty-documents"{empty_disabled}>清空页面垃圾箱</button>
|
|
|
|
|
</div>
|
|
|
|
|
{document_rows}
|
|
|
|
|
</section>
|
|
|
|
|
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
|
|
|
|
<div class="mnote-trash-section-title">
|
|
|
|
|
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
|
|
|
|
<button type="button" data-trash-action="empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
|
|
|
|
</div>
|
|
|
|
|
{resource_body}
|
|
|
|
|
<p class="mnote-trash-note">资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。</p>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
<script>
|
|
|
|
|
(function() {{
|
|
|
|
|
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
|
|
|
|
if (!root) return;
|
|
|
|
|
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
|
|
|
|
function setStatus(message, failed) {{
|
|
|
|
|
var status = root.querySelector('[data-trash-status]');
|
|
|
|
|
if (!status) return;
|
|
|
|
|
status.textContent = message || '';
|
|
|
|
|
status.setAttribute('data-type', failed ? 'error' : 'success');
|
|
|
|
|
}}
|
|
|
|
|
function refreshTrashWorkbenchFromServer(reason) {{
|
|
|
|
|
if (!workspaceId) return Promise.resolve(false);
|
|
|
|
|
var url = new URL(root.getAttribute('data-trash-fetch-path') || '/trash', window.location.origin);
|
|
|
|
|
url.searchParams.set('workspaceId', workspaceId);
|
|
|
|
|
url.searchParams.set('liveRefreshReason', reason || 'tree-event');
|
|
|
|
|
return fetch(url.toString(), {{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: {{ 'x-mnote-trash-live-refresh': '1' }}
|
|
|
|
|
}}).then(function(response) {{
|
|
|
|
|
return response.text().then(function(html) {{
|
|
|
|
|
if (!response.ok) throw new Error('trash_live_refresh_failed_' + response.status);
|
|
|
|
|
var parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
|
|
|
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
|
|
|
|
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
|
|
|
|
root.innerHTML = nextRoot.innerHTML;
|
|
|
|
|
root.setAttribute('data-live-refresh-reason', reason || 'tree-event');
|
|
|
|
|
root.setAttribute('data-live-refresh-at', String(Date.now()));
|
|
|
|
|
return true;
|
|
|
|
|
}});
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
return false;
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
function startTrashWorkbenchLiveRefresh() {{
|
|
|
|
|
if (!workspaceId || !('EventSource' in window)) return;
|
|
|
|
|
var url = new URL('/api/tree/events', window.location.origin);
|
|
|
|
|
url.searchParams.set('workspaceId', workspaceId);
|
|
|
|
|
var source = new EventSource(url.toString());
|
|
|
|
|
root.__mnoteTrashEventSource = source;
|
|
|
|
|
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
|
|
|
|
|
source.addEventListener(kind, function() {{
|
|
|
|
|
refreshTrashWorkbenchFromServer(kind);
|
|
|
|
|
}});
|
|
|
|
|
}});
|
|
|
|
|
source.onerror = function() {{
|
|
|
|
|
root.setAttribute('data-live-refresh-error', 'eventsource_error');
|
|
|
|
|
}};
|
|
|
|
|
}}
|
|
|
|
|
function readJson(response) {{
|
|
|
|
|
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
|
|
|
|
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
|
|
|
|
return payload;
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
function decrement(selector) {{
|
|
|
|
|
var count = root.querySelector(selector);
|
|
|
|
|
if (!count) return;
|
|
|
|
|
var nextCount = Math.max(0, Number(count.textContent || '0') - 1);
|
|
|
|
|
count.textContent = String(nextCount);
|
|
|
|
|
}}
|
|
|
|
|
function postJson(url, body) {{
|
|
|
|
|
return fetch(url, {{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
|
|
|
body: JSON.stringify(body || {{}})
|
|
|
|
|
}}).then(readJson);
|
|
|
|
|
}}
|
|
|
|
|
function patchJson(url, body) {{
|
|
|
|
|
return fetch(url, {{
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
|
|
|
body: JSON.stringify(body || {{}})
|
|
|
|
|
}}).then(readJson);
|
|
|
|
|
}}
|
|
|
|
|
function runResourceAction(kind, action, resourceId, documentId) {{
|
|
|
|
|
if (kind === 'media') {{
|
|
|
|
|
return action === 'restore'
|
|
|
|
|
? postJson('/api/media/batch', {{ action: 'restore', assetIds: [resourceId] }})
|
|
|
|
|
: postJson('/api/media/purge', {{ assetId: resourceId }});
|
|
|
|
|
}}
|
|
|
|
|
if (kind === 'mindmap') {{
|
|
|
|
|
if (!documentId) return Promise.reject(new Error('mindmap_document_id_required'));
|
|
|
|
|
return patchJson('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(resourceId), {{ action: action }});
|
|
|
|
|
}}
|
|
|
|
|
if (kind === 'table') {{
|
|
|
|
|
return postJson(action === 'restore' ? '/api/tables/restore' : '/api/tables/purge', {{ tableId: resourceId }});
|
|
|
|
|
}}
|
|
|
|
|
return Promise.reject(new Error('resource_kind_unsupported'));
|
|
|
|
|
}}
|
|
|
|
|
root.addEventListener('click', function(event) {{
|
|
|
|
|
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
|
|
|
|
if (!button || button.disabled) return;
|
|
|
|
|
var action = button.getAttribute('data-trash-action');
|
|
|
|
|
var documentId = button.getAttribute('data-document-id') || '';
|
|
|
|
|
if (action === 'empty-documents') {{
|
|
|
|
|
if (!window.confirm('清空页面垃圾箱后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
fetch('/api/documents/empty-trash', {{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
|
|
|
body: JSON.stringify({{ workspaceId: workspaceId }})
|
|
|
|
|
}}).then(function(response) {{
|
|
|
|
|
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
|
|
|
|
if (!response.ok) throw new Error((payload && payload.message) || 'trash_empty_failed_' + response.status);
|
|
|
|
|
root.querySelectorAll('[data-trash-row="document"]').forEach(function(row) {{ row.remove(); }});
|
|
|
|
|
var count = root.querySelector('[data-trash-document-count]');
|
|
|
|
|
if (count) count.textContent = '0';
|
|
|
|
|
setStatus('已清空页面垃圾箱', false);
|
|
|
|
|
}});
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
return;
|
|
|
|
|
}}
|
|
|
|
|
if (action === 'empty-resources') {{
|
|
|
|
|
if (!window.confirm('清空资源垃圾箱后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
Promise.all([
|
|
|
|
|
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
|
|
|
|
|
postJson('/api/mindmap-trash/empty', {{ workspaceId: workspaceId }}),
|
|
|
|
|
postJson('/api/tables/empty-trash', {{ workspaceId: workspaceId }})
|
|
|
|
|
]).then(function() {{
|
|
|
|
|
root.querySelectorAll('[data-trash-row="resource"]').forEach(function(row) {{ row.remove(); }});
|
|
|
|
|
var count = root.querySelector('[data-trash-resource-count]');
|
|
|
|
|
if (count) count.textContent = '0';
|
|
|
|
|
setStatus('已清空资源垃圾箱', false);
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
return;
|
|
|
|
|
}}
|
|
|
|
|
if (action === 'resource-restore' || action === 'resource-purge') {{
|
|
|
|
|
var resourceId = button.getAttribute('data-resource-id') || '';
|
|
|
|
|
var kind = button.getAttribute('data-resource-kind') || '';
|
|
|
|
|
var resourceDocumentId = button.getAttribute('data-document-id') || '';
|
|
|
|
|
if (!resourceId) return;
|
|
|
|
|
if (action === 'resource-purge' && !window.confirm('彻底删除资源后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
|
|
|
|
|
var row = button.closest('[data-trash-row]');
|
|
|
|
|
if (row) row.remove();
|
|
|
|
|
decrement('[data-trash-resource-count]');
|
|
|
|
|
setStatus(action === 'resource-restore' ? '已恢复资源' : '已彻底删除资源', false);
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
return;
|
|
|
|
|
}}
|
|
|
|
|
if (!documentId) return;
|
|
|
|
|
if (action === 'purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
fetch('/api/tree/commands', {{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
|
|
|
body: JSON.stringify({{
|
|
|
|
|
action: action,
|
|
|
|
|
workspaceId: workspaceId,
|
|
|
|
|
documentId: documentId
|
|
|
|
|
}})
|
|
|
|
|
}}).then(function(response) {{
|
|
|
|
|
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
|
|
|
|
if (!response.ok) throw new Error((payload && payload.message) || 'trash_action_failed_' + response.status);
|
|
|
|
|
var row = button.closest('[data-trash-row]');
|
|
|
|
|
if (row) row.remove();
|
|
|
|
|
decrement('[data-trash-document-count]');
|
|
|
|
|
setStatus(action === 'restore' ? '已恢复页面' : '已彻底删除页面', false);
|
|
|
|
|
}});
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
}});
|
|
|
|
|
startTrashWorkbenchLiveRefresh();
|
|
|
|
|
}})();
|
|
|
|
|
</script>"#,
|
|
|
|
|
workspace_id = escape_html(workspace_id),
|
|
|
|
|
document_count = documents.len(),
|
|
|
|
|
empty_disabled = if documents.is_empty() {
|
|
|
|
|
" disabled"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
},
|
|
|
|
|
resource_empty_disabled = if resource_count == 0 { " disabled" } else { "" },
|
|
|
|
|
resource_count = resource_count,
|
|
|
|
|
document_rows = document_rows,
|
|
|
|
|
resource_body = resource_body,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn render_local_trash_workbench_html(
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
entries: &BTreeMap<String, crate::routes::local_folder_source::LocalTrashEntry>,
|
|
|
|
|
) -> String {
|
|
|
|
|
let mut document_rows = Vec::new();
|
|
|
|
|
let mut resource_rows = Vec::new();
|
|
|
|
|
for (entry_id, entry) in entries {
|
|
|
|
|
let deleted_at = entry.deleted_at_ms.to_string();
|
|
|
|
|
let original = escape_html(&entry.original_relative_path);
|
|
|
|
|
let trash_path = escape_html(&entry.trash_relative_path);
|
|
|
|
|
let kind = escape_html(&entry.resource_kind);
|
2026-05-21 15:45:11 +08:00
|
|
|
let filetree_row_id =
|
|
|
|
|
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
|
|
|
|
format!("doc:{}", entry.document_id)
|
|
|
|
|
} else {
|
|
|
|
|
format!("local:asset:{}", entry.original_relative_path)
|
|
|
|
|
};
|
2026-05-20 10:43:38 +08:00
|
|
|
let row = format!(
|
2026-05-21 14:39:38 +08:00
|
|
|
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-filetree-row-id="{filetree_row_id}">
|
2026-05-20 10:43:38 +08:00
|
|
|
<div class="mnote-trash-row-main">
|
|
|
|
|
<span class="mnote-trash-kind">{kind}</span>
|
|
|
|
|
<span class="mnote-trash-title">{original}</span>
|
|
|
|
|
<span class="mnote-trash-meta">回收站:{trash_path} · {deleted_at}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mnote-trash-actions">
|
|
|
|
|
<button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
|
|
|
|
|
<button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
|
|
|
|
|
</div>
|
|
|
|
|
</article>"#,
|
|
|
|
|
entry_id = escape_html(entry_id),
|
|
|
|
|
kind = kind,
|
2026-05-21 14:39:38 +08:00
|
|
|
filetree_row_id = escape_html(&filetree_row_id),
|
2026-05-20 10:43:38 +08:00
|
|
|
original = original,
|
|
|
|
|
trash_path = trash_path,
|
|
|
|
|
deleted_at = escape_html(&deleted_at),
|
|
|
|
|
command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()),
|
|
|
|
|
);
|
|
|
|
|
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
|
|
|
|
document_rows.push(row);
|
|
|
|
|
} else {
|
|
|
|
|
resource_rows.push(row);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let document_body = if document_rows.is_empty() {
|
|
|
|
|
r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
document_rows.join("")
|
|
|
|
|
};
|
|
|
|
|
let resource_body = if resource_rows.is_empty() {
|
|
|
|
|
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
resource_rows.join("")
|
|
|
|
|
};
|
|
|
|
|
format!(
|
|
|
|
|
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
|
|
|
|
|
<header class="mnote-trash-header">
|
|
|
|
|
<h1>本地文件夹垃圾箱</h1>
|
|
|
|
|
<p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
|
|
|
|
|
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
|
|
|
|
</header>
|
|
|
|
|
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
|
|
|
|
<div class="mnote-trash-section-title">
|
|
|
|
|
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
|
|
|
|
<button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
|
|
|
|
|
</div>
|
|
|
|
|
{document_body}
|
|
|
|
|
</section>
|
|
|
|
|
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
|
|
|
|
<div class="mnote-trash-section-title">
|
|
|
|
|
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
|
|
|
|
<button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
|
|
|
|
</div>
|
|
|
|
|
{resource_body}
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
<script>
|
|
|
|
|
(function() {{
|
|
|
|
|
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
|
|
|
|
if (!root) return;
|
|
|
|
|
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
|
|
|
|
var rootUri = root.getAttribute('data-root-uri') || '';
|
|
|
|
|
function setStatus(message, failed) {{
|
|
|
|
|
var status = root.querySelector('[data-trash-status]');
|
|
|
|
|
if (!status) return;
|
|
|
|
|
status.textContent = message || '';
|
|
|
|
|
status.setAttribute('data-type', failed ? 'error' : 'success');
|
|
|
|
|
}}
|
|
|
|
|
function readJson(response) {{
|
|
|
|
|
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
|
|
|
|
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
|
|
|
|
return payload;
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
function postJson(url, body) {{
|
|
|
|
|
return fetch(url, {{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {{ 'content-type': 'application/json' }},
|
|
|
|
|
body: JSON.stringify(body || {{}})
|
|
|
|
|
}}).then(readJson);
|
|
|
|
|
}}
|
|
|
|
|
function refresh() {{
|
|
|
|
|
var url = new URL('/trash', window.location.origin);
|
|
|
|
|
url.searchParams.set('sourceKind', 'local_folder');
|
|
|
|
|
url.searchParams.set('rootUri', rootUri);
|
|
|
|
|
return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
|
|
|
|
|
.then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
|
|
|
|
|
.then(function(result) {{
|
|
|
|
|
if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
|
|
|
|
|
var parsed = new DOMParser().parseFromString(result.html, 'text/html');
|
|
|
|
|
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
|
|
|
|
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
|
|
|
|
root.innerHTML = nextRoot.innerHTML;
|
|
|
|
|
return true;
|
|
|
|
|
}})
|
|
|
|
|
.catch(function(error) {{
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
return false;
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
root.addEventListener('click', function(event) {{
|
|
|
|
|
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
|
|
|
|
if (!button || button.disabled) return;
|
|
|
|
|
var action = button.getAttribute('data-trash-action');
|
|
|
|
|
var entryId = button.getAttribute('data-trash-entry-id') || '';
|
|
|
|
|
var documentId = button.getAttribute('data-document-id') || entryId;
|
|
|
|
|
var kind = button.getAttribute('data-resource-kind') || '';
|
|
|
|
|
if (action === 'local-restore' || action === 'local-purge') {{
|
|
|
|
|
if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
postJson('/api/tree/commands', {{
|
|
|
|
|
action: action === 'local-restore' ? 'restore' : 'purge',
|
|
|
|
|
sourceKind: 'local_folder',
|
|
|
|
|
rootUri: rootUri,
|
|
|
|
|
workspaceId: workspaceId,
|
|
|
|
|
documentId: entryId
|
|
|
|
|
}}).then(function() {{
|
2026-05-21 14:39:38 +08:00
|
|
|
if (action === 'local-restore') {{
|
|
|
|
|
var row = button.closest('[data-trash-row="local"]');
|
|
|
|
|
var filetreeRowId = row ? (row.getAttribute('data-filetree-row-id') || '') : '';
|
|
|
|
|
if (filetreeRowId && window.localStorage) {{
|
|
|
|
|
window.localStorage.setItem('mnote.pendingLocalFolderRestoreFiletreeRowId', filetreeRowId);
|
|
|
|
|
}}
|
|
|
|
|
if (filetreeRowId) {{
|
|
|
|
|
window.name = 'mnote.pendingLocalFolderRestoreFiletreeRowId=' + encodeURIComponent(filetreeRowId);
|
|
|
|
|
}}
|
|
|
|
|
}}
|
2026-05-20 10:43:38 +08:00
|
|
|
return refresh();
|
|
|
|
|
}}).then(function() {{
|
|
|
|
|
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
|
|
|
|
|
var selector = action === 'local-empty-documents'
|
|
|
|
|
? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
|
|
|
|
|
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
|
|
|
|
|
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
|
|
|
|
|
if (rows.length === 0) return;
|
|
|
|
|
if (!window.confirm('清空后无法恢复,确定继续吗?')) return;
|
|
|
|
|
button.disabled = true;
|
2026-05-21 15:45:11 +08:00
|
|
|
rows.reduce(function(chain, row) {{
|
|
|
|
|
return chain.then(function() {{
|
|
|
|
|
var entryId = row.getAttribute('data-trash-entry-id') || '';
|
|
|
|
|
return postJson('/api/tree/commands', {{
|
|
|
|
|
action: 'purge',
|
|
|
|
|
sourceKind: 'local_folder',
|
|
|
|
|
rootUri: rootUri,
|
|
|
|
|
workspaceId: workspaceId,
|
|
|
|
|
documentId: entryId
|
|
|
|
|
}});
|
2026-05-20 10:43:38 +08:00
|
|
|
}});
|
2026-05-21 15:45:11 +08:00
|
|
|
}}, Promise.resolve()).then(function() {{
|
2026-05-20 10:43:38 +08:00
|
|
|
return refresh();
|
|
|
|
|
}}).then(function() {{
|
|
|
|
|
setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
|
|
|
|
|
}}).catch(function(error) {{
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
setStatus(error && error.message ? error.message : String(error), true);
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
}})();
|
|
|
|
|
</script>"#,
|
|
|
|
|
workspace_id = escape_html(workspace_id),
|
|
|
|
|
root_uri = escape_html(root_uri),
|
|
|
|
|
document_count = document_rows.len(),
|
|
|
|
|
resource_count = resource_rows.len(),
|
|
|
|
|
document_empty_disabled = if document_rows.is_empty() {
|
|
|
|
|
" disabled"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
},
|
|
|
|
|
resource_empty_disabled = if resource_rows.is_empty() {
|
|
|
|
|
" disabled"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
},
|
|
|
|
|
document_body = document_body,
|
|
|
|
|
resource_body = resource_body,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_trash_command_id(
|
|
|
|
|
entry_id: &str,
|
|
|
|
|
entry: &crate::routes::local_folder_source::LocalTrashEntry,
|
|
|
|
|
) -> String {
|
|
|
|
|
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
|
|
|
|
return entry.document_id.clone();
|
|
|
|
|
}
|
|
|
|
|
entry_id.to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
|
|
|
|
|
dataset
|
|
|
|
|
.get(key)
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(Vec::as_slice)
|
|
|
|
|
.unwrap_or(&[])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_trashed_document_rows(workspace_id: &str, documents: &[Value]) -> String {
|
|
|
|
|
if documents.is_empty() {
|
|
|
|
|
return r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
documents
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|document| {
|
|
|
|
|
let id = document.get("id").and_then(Value::as_str)?;
|
|
|
|
|
let title = document
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("无标题");
|
|
|
|
|
let deleted_at = document
|
|
|
|
|
.get("deleted_at")
|
|
|
|
|
.or_else(|| document.get("deletedAt"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
Some(format!(
|
|
|
|
|
r#"<article class="mnote-trash-row" data-trash-row="document" data-document-id="{id}">
|
|
|
|
|
<div class="mnote-trash-row-main">
|
|
|
|
|
<a href="/documents/{id}?workspaceId={workspace_id}" class="mnote-trash-title">{title}</a>
|
|
|
|
|
<span class="mnote-trash-meta">{deleted_at}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mnote-trash-actions">
|
|
|
|
|
<button type="button" data-trash-action="restore" data-document-id="{id}">恢复</button>
|
|
|
|
|
<button type="button" data-trash-action="purge" data-document-id="{id}">彻底删除</button>
|
|
|
|
|
</div>
|
|
|
|
|
</article>"#,
|
|
|
|
|
id = escape_html(id),
|
|
|
|
|
workspace_id = escape_html(workspace_id),
|
|
|
|
|
title = escape_html(title),
|
|
|
|
|
deleted_at = escape_html(deleted_at),
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> String {
|
|
|
|
|
resources
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|resource| {
|
|
|
|
|
let id = resource.get("id").and_then(Value::as_str)?;
|
|
|
|
|
let document_id = resource
|
|
|
|
|
.get("document_id")
|
|
|
|
|
.or_else(|| resource.get("documentId"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
let title = resource
|
|
|
|
|
.get("file_name")
|
|
|
|
|
.or_else(|| resource.get("title"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("未命名资源");
|
|
|
|
|
let deleted_at = resource
|
|
|
|
|
.get("deleted_at")
|
|
|
|
|
.or_else(|| resource.get("deletedAt"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
Some(format!(
|
|
|
|
|
r#"<article class="mnote-trash-row" data-trash-row="resource" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">
|
|
|
|
|
<div class="mnote-trash-row-main">
|
|
|
|
|
<span class="mnote-trash-kind">{label}</span>
|
|
|
|
|
<span class="mnote-trash-title">{title}</span>
|
|
|
|
|
<span class="mnote-trash-meta">{deleted_at}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mnote-trash-actions">
|
|
|
|
|
<button type="button" data-trash-action="resource-restore" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">恢复</button>
|
|
|
|
|
<button type="button" data-trash-action="resource-purge" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">彻底删除</button>
|
|
|
|
|
</div>
|
|
|
|
|
</article>"#,
|
|
|
|
|
kind = escape_html(kind),
|
|
|
|
|
id = escape_html(id),
|
|
|
|
|
document_id = escape_html(document_id),
|
|
|
|
|
label = escape_html(label),
|
|
|
|
|
title = escape_html(title),
|
|
|
|
|
deleted_at = escape_html(deleted_at),
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
#[allow(dead_code)]
|
2026-04-29 12:24:44 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:16:34 +08:00
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub async fn legacy_next_websocket_proxy(
|
|
|
|
|
ws: WebSocketUpgrade,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
uri: Uri,
|
|
|
|
|
) -> 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 WebSocket。",
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let upstream_url = build_legacy_next_ws_url(base_url, &uri)?;
|
|
|
|
|
Ok(ws.on_upgrade(move |socket| async move {
|
|
|
|
|
if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await {
|
|
|
|
|
tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开");
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result<String, WebError> {
|
|
|
|
|
let upstream = reqwest::Url::parse(base_url)
|
|
|
|
|
.map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
|
|
|
|
|
let scheme = match upstream.scheme() {
|
|
|
|
|
"https" => "wss",
|
|
|
|
|
_ => "ws",
|
|
|
|
|
};
|
|
|
|
|
let host = upstream
|
|
|
|
|
.host_str()
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?;
|
|
|
|
|
let host_with_port = match upstream.port() {
|
|
|
|
|
Some(port) => format!("{host}:{port}"),
|
|
|
|
|
None => host.to_string(),
|
|
|
|
|
};
|
|
|
|
|
let path_and_query = uri
|
|
|
|
|
.path_and_query()
|
|
|
|
|
.map(|value| value.as_str())
|
|
|
|
|
.unwrap_or("/");
|
|
|
|
|
Ok(format!("{scheme}://{host_with_port}{path_and_query}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
async fn proxy_legacy_next_websocket(
|
|
|
|
|
socket: WebSocket,
|
|
|
|
|
upstream_url: String,
|
|
|
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
|
|
|
let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?;
|
|
|
|
|
let (mut client_tx, mut client_rx) = socket.split();
|
|
|
|
|
let (mut upstream_tx, mut upstream_rx) = upstream.split();
|
|
|
|
|
|
|
|
|
|
let client_to_upstream = async {
|
|
|
|
|
while let Some(message) = client_rx.next().await {
|
|
|
|
|
let Ok(message) = message else {
|
|
|
|
|
break;
|
|
|
|
|
};
|
|
|
|
|
if upstream_tx
|
|
|
|
|
.send(axum_ws_to_tungstenite(message))
|
|
|
|
|
.await
|
|
|
|
|
.is_err()
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let upstream_to_client = async {
|
|
|
|
|
while let Some(message) = upstream_rx.next().await {
|
|
|
|
|
let Ok(message) = message else {
|
|
|
|
|
break;
|
|
|
|
|
};
|
|
|
|
|
if client_tx
|
|
|
|
|
.send(tungstenite_to_axum_ws(message))
|
|
|
|
|
.await
|
|
|
|
|
.is_err()
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tokio::select! {
|
|
|
|
|
_ = client_to_upstream => {}
|
|
|
|
|
_ = upstream_to_client => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage {
|
|
|
|
|
match message {
|
|
|
|
|
AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()),
|
|
|
|
|
AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value),
|
|
|
|
|
AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value),
|
|
|
|
|
AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value),
|
|
|
|
|
AxumWsMessage::Close(_) => TungsteniteMessage::Close(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage {
|
|
|
|
|
match message {
|
|
|
|
|
TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()),
|
|
|
|
|
TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value),
|
|
|
|
|
TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value),
|
|
|
|
|
TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value),
|
|
|
|
|
TungsteniteMessage::Close(_) => AxumWsMessage::Close(None),
|
|
|
|
|
TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
async fn resolve_root_workspace_id(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
requested_workspace_id: Option<&str>,
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
|
|
|
|
|
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
|
|
|
|
|
{
|
|
|
|
|
return Ok(workspace_id.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let bootstrap = execute_convex_mutation_by_name(
|
|
|
|
|
state.config(),
|
|
|
|
|
context,
|
|
|
|
|
"workspaces:ensureDefaultWorkspace",
|
|
|
|
|
json!({
|
2026-05-22 17:45:22 +08:00
|
|
|
"fallbackName": current_actor_display_name(state, context)
|
|
|
|
|
.unwrap_or_else(|| state.config().dev_user_name.clone()),
|
2026-04-29 14:36:24 +08:00
|
|
|
"workspaceIdIfCreate": format!("ws_{}", context.trace.request_id),
|
|
|
|
|
}),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
"root_workspace_bootstrap",
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
bootstrap
|
|
|
|
|
.get("activeWorkspaceId")
|
|
|
|
|
.and_then(serde_json::Value::as_str)
|
|
|
|
|
.and_then(|value| normalize_optional_id(Some(value)))
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"root_workspace_bootstrap_bad_response",
|
|
|
|
|
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "root_workspace_bootstrap")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
pub(crate) fn default_workspace_name_for_context(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
) -> String {
|
|
|
|
|
let display_name = current_actor_display_name(state, context)
|
|
|
|
|
.unwrap_or_else(|| state.config().dev_user_name.clone());
|
|
|
|
|
format!("{} 的空间", display_name.trim())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn current_actor_display_name(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
|
|
|
|
let token_hash = session_token_hash(&raw_token);
|
|
|
|
|
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
|
|
|
|
|
return Some(resolved.user.display_name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
extract_encoded_cookie_value(context, COOKIE_MNOTE_ACTOR_NAME).or_else(|| {
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() || actor_id == "anonymous" {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(actor_id.to_string())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
|
|
|
|
|
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
|
|
|
|
let token_hash = session_token_hash(&raw_token);
|
|
|
|
|
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
|
|
|
|
|
return Some(resolved.user.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() || actor_id == "anonymous" {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(actor_id.to_string())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String {
|
|
|
|
|
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
|
|
|
|
let token_hash = session_token_hash(&raw_token);
|
|
|
|
|
if matches!(
|
|
|
|
|
state.control_plane().get_session_by_token_hash(&token_hash),
|
|
|
|
|
Ok(Some(_))
|
|
|
|
|
) {
|
|
|
|
|
return "user".to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
context.auth.actor_type.trim().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
|
|
|
|
|
value.map(str::trim).filter(|value| !value.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|
2026-05-06 21:44:20 +08:00
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
if !actor_id.is_empty() && actor_id != "anonymous" {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
|
|
|
|
let token_hash = session_token_hash(&raw_token);
|
|
|
|
|
if matches!(
|
|
|
|
|
state.control_plane().get_session_by_token_hash(&token_hash),
|
|
|
|
|
Ok(Some(_))
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some()
|
|
|
|
|
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
fn handle_sqlite_auth_action(
|
2026-05-06 21:44:20 +08:00
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
payload: &serde_json::Value,
|
2026-05-22 17:45:22 +08:00
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
let params = payload
|
|
|
|
|
.pointer("/args/params")
|
|
|
|
|
.and_then(Value::as_object)
|
2026-05-06 21:44:20 +08:00
|
|
|
.ok_or_else(|| {
|
2026-05-22 17:45:22 +08:00
|
|
|
WebError::bad_request_code("auth_bad_request", "登录请求缺少 args.params")
|
2026-05-06 21:44:20 +08:00
|
|
|
.with_context(context)
|
2026-05-22 17:45:22 +08:00
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-06 21:44:20 +08:00
|
|
|
})?;
|
2026-05-22 17:45:22 +08:00
|
|
|
let flow = params
|
|
|
|
|
.get("flow")
|
2026-05-22 01:47:40 +08:00
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("signIn");
|
2026-05-22 17:45:22 +08:00
|
|
|
let password = params
|
|
|
|
|
.get("password")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("auth_password_required", "请填写密码")
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
})?;
|
|
|
|
|
let session_token = new_session_token();
|
|
|
|
|
let token_hash = session_token_hash(&session_token);
|
|
|
|
|
let resolved = if flow == "signUp" {
|
|
|
|
|
let email = params
|
|
|
|
|
.get("email")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"auth_signup_email_required",
|
|
|
|
|
"注册账号时请填写邮箱;登录时可以使用用户名。",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "auth_signup_payload")
|
|
|
|
|
})?;
|
|
|
|
|
let username = params
|
|
|
|
|
.get("name")
|
|
|
|
|
.or_else(|| params.get("username"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
|
|
|
|
|
let user = state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.upsert_user(UpsertUserInput {
|
|
|
|
|
id: Some(username.to_string()),
|
|
|
|
|
email: Some(email.to_string()),
|
|
|
|
|
username: username.to_string(),
|
|
|
|
|
display_name: username.to_string(),
|
|
|
|
|
role: None,
|
|
|
|
|
password_hash: None,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| sqlite_auth_error(context, error))?;
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.create_password_identity(CreatePasswordIdentityInput {
|
|
|
|
|
user_id: user.id.clone(),
|
|
|
|
|
email: user.email.clone(),
|
|
|
|
|
username: user.username.clone(),
|
|
|
|
|
password: password.clone(),
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| sqlite_auth_error(context, error))?;
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.ensure_default_workspace(&user.id)
|
|
|
|
|
.map_err(|error| sqlite_auth_error(context, error))?;
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.authenticate_password(AuthenticatePasswordInput {
|
|
|
|
|
account: email.to_string(),
|
|
|
|
|
password,
|
|
|
|
|
session_id: None,
|
|
|
|
|
token_hash,
|
|
|
|
|
user_agent: None,
|
|
|
|
|
ip_hash: None,
|
|
|
|
|
expires_at: None,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| sqlite_auth_error(context, error))?
|
|
|
|
|
} else {
|
|
|
|
|
let account = params
|
|
|
|
|
.get("account")
|
|
|
|
|
.or_else(|| params.get("email"))
|
|
|
|
|
.or_else(|| params.get("name"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("auth_account_required", "请填写邮箱或用户名")
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
})?;
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.authenticate_password(AuthenticatePasswordInput {
|
|
|
|
|
account: account.to_string(),
|
|
|
|
|
password,
|
|
|
|
|
session_id: None,
|
|
|
|
|
token_hash,
|
|
|
|
|
user_agent: None,
|
|
|
|
|
ip_hash: None,
|
|
|
|
|
expires_at: None,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| sqlite_auth_error(context, error))?
|
2026-05-22 01:47:40 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
let audit_action = if flow == "signUp" {
|
|
|
|
|
"control.auth.signed_up"
|
|
|
|
|
} else {
|
|
|
|
|
"control.auth.signed_in"
|
|
|
|
|
};
|
|
|
|
|
let _ = state.control_plane().append_audit(AppendAuditInput {
|
|
|
|
|
actor_user_id: Some(resolved.user.id.clone()),
|
|
|
|
|
action: audit_action.to_string(),
|
|
|
|
|
target_kind: "auth_session".to_string(),
|
|
|
|
|
target_id: Some(resolved.session.id.clone()),
|
|
|
|
|
metadata_json: serde_json::to_string(&json!({
|
|
|
|
|
"email": resolved.user.email,
|
|
|
|
|
"name": resolved.user.display_name,
|
|
|
|
|
"flow": flow,
|
|
|
|
|
}))
|
|
|
|
|
.unwrap_or_else(|_| "{}".to_string()),
|
2026-05-22 01:47:40 +08:00
|
|
|
});
|
2026-05-22 17:45:22 +08:00
|
|
|
|
|
|
|
|
Ok(build_sqlite_auth_response(
|
|
|
|
|
context,
|
|
|
|
|
&session_token,
|
|
|
|
|
&resolved.user.id,
|
|
|
|
|
resolved.user.email.as_deref().unwrap_or_default(),
|
|
|
|
|
&resolved.user.display_name,
|
2026-05-23 23:38:42 +08:00
|
|
|
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
2026-05-22 17:45:22 +08:00
|
|
|
))
|
2026-05-22 01:47:40 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
fn sqlite_auth_error(
|
2026-05-06 21:44:20 +08:00
|
|
|
context: &RequestContext,
|
2026-05-22 17:45:22 +08:00
|
|
|
error: control_plane::ControlPlaneError,
|
|
|
|
|
) -> WebError {
|
|
|
|
|
match error {
|
|
|
|
|
control_plane::ControlPlaneError::Unauthorized(message) => {
|
|
|
|
|
WebError::bad_request_code("auth_invalid_credentials", message)
|
|
|
|
|
}
|
|
|
|
|
control_plane::ControlPlaneError::Conflict(message) => {
|
|
|
|
|
WebError::bad_request_code("auth_conflict", message)
|
|
|
|
|
}
|
|
|
|
|
control_plane::ControlPlaneError::InvalidInput(message) => {
|
|
|
|
|
WebError::bad_request_code("auth_bad_request", message)
|
|
|
|
|
}
|
|
|
|
|
other => WebError::internal(format!("SQLite Auth 失败: {other}")),
|
|
|
|
|
}
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_sqlite_auth_response(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
session_token: &str,
|
|
|
|
|
user_id: &str,
|
|
|
|
|
email: &str,
|
|
|
|
|
name: &str,
|
2026-05-23 23:38:42 +08:00
|
|
|
actor_type: &str,
|
2026-05-06 21:44:20 +08:00
|
|
|
) -> Response {
|
2026-05-22 17:45:22 +08:00
|
|
|
let mut response = axum::Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"userId": user_id,
|
|
|
|
|
"email": email,
|
|
|
|
|
"name": name,
|
2026-05-23 23:38:42 +08:00
|
|
|
"authMode": "sqliteSession",
|
|
|
|
|
"actorType": actor_type
|
2026-05-22 17:45:22 +08:00
|
|
|
}))
|
|
|
|
|
.into_response();
|
|
|
|
|
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token);
|
|
|
|
|
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id);
|
2026-05-23 23:38:42 +08:00
|
|
|
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type);
|
2026-05-22 17:45:22 +08:00
|
|
|
if !email.trim().is_empty() {
|
|
|
|
|
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email);
|
2026-05-06 21:44:20 +08:00
|
|
|
}
|
2026-05-22 17:45:22 +08:00
|
|
|
if !name.trim().is_empty() {
|
|
|
|
|
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, name);
|
2026-05-06 21:44:20 +08:00
|
|
|
}
|
2026-05-22 17:45:22 +08:00
|
|
|
expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_JWT);
|
|
|
|
|
expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
|
|
|
|
|
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
|
2026-05-06 21:44:20 +08:00
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
2026-05-21 23:53:39 +08:00
|
|
|
apply_trace_response_headers(context, response.headers_mut());
|
2026-05-06 21:44:20 +08:00
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:38:42 +08:00
|
|
|
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
|
|
|
|
|
let role = stored_role.trim();
|
|
|
|
|
let fallback_role = if role.is_empty() { "user" } else { role };
|
|
|
|
|
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
|
|
|
|
|
user_id,
|
|
|
|
|
fallback_role,
|
|
|
|
|
) {
|
|
|
|
|
"admin".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
fallback_role.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
|
|
|
|
|
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
|
|
|
|
let token_hash = session_token_hash(&raw_token);
|
|
|
|
|
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
|
|
|
|
|
let _ = state.control_plane().revoke_session(&resolved.session.id);
|
|
|
|
|
let _ = state.control_plane().append_audit(AppendAuditInput {
|
|
|
|
|
actor_user_id: Some(resolved.user.id.clone()),
|
|
|
|
|
action: "control.auth.signed_out".to_string(),
|
|
|
|
|
target_kind: "auth_session".to_string(),
|
|
|
|
|
target_id: Some(resolved.session.id.clone()),
|
|
|
|
|
metadata_json: serde_json::to_string(&json!({
|
|
|
|
|
"email": resolved.user.email,
|
|
|
|
|
"name": resolved.user.display_name,
|
|
|
|
|
}))
|
|
|
|
|
.unwrap_or_else(|_| "{}".to_string()),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
build_sign_out_response(context)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn new_session_token() -> String {
|
|
|
|
|
uuid::Uuid::new_v4().simple().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:53:39 +08:00
|
|
|
fn build_sign_out_response(context: &RequestContext) -> Response {
|
|
|
|
|
let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response();
|
|
|
|
|
clear_auth_cookies(response.headers_mut());
|
|
|
|
|
stamp_gateway_headers(response.headers_mut(), false);
|
|
|
|
|
apply_trace_response_headers(context, response.headers_mut());
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) {
|
|
|
|
|
insert_response_header(headers, "x-request-id", &context.trace.request_id);
|
|
|
|
|
insert_response_header(headers, "x-trace-id", &context.trace.trace_id);
|
|
|
|
|
if let Some(workspace_id) = &context.workspace.workspace_id {
|
|
|
|
|
insert_response_header(headers, "x-mnote-workspace-id", workspace_id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) {
|
|
|
|
|
let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Ok(value) = HeaderValue::from_str(value) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
headers.insert(name, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
|
|
|
|
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|
|
|
|
headers.append(header::SET_COOKIE, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:53:39 +08:00
|
|
|
fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
|
|
|
|
let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes());
|
|
|
|
|
set_literal_cookie(headers, name, &encoded);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
|
2026-05-22 17:45:22 +08:00
|
|
|
expire_cookie(headers, COOKIE_MNOTE_SESSION);
|
2026-05-06 21:44:20 +08:00
|
|
|
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
|
|
|
|
|
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
|
|
|
|
|
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
|
2026-05-20 10:43:38 +08:00
|
|
|
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
|
|
|
|
|
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
|
2026-05-21 23:53:39 +08:00
|
|
|
expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL);
|
|
|
|
|
expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME);
|
2026-05-06 21:44:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
|
|
|
|
|
let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
|
|
|
|
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
|
|
|
|
headers.append(header::SET_COOKIE, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
fn choose_root_entry_active_page_id(
|
|
|
|
|
requested_page_id: Option<&str>,
|
|
|
|
|
recent_page_id: Option<&str>,
|
|
|
|
|
projection_active_page_id: Option<&str>,
|
|
|
|
|
first_page_id: Option<&str>,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
normalize_optional_id(requested_page_id)
|
|
|
|
|
.or_else(|| normalize_optional_id(recent_page_id))
|
|
|
|
|
.or_else(|| normalize_optional_id(projection_active_page_id))
|
|
|
|
|
.or_else(|| normalize_optional_id(first_page_id))
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
2026-04-29 14:36:24 +08:00
|
|
|
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())
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
} else {
|
2026-04-29 14:36:24 +08:00
|
|
|
None
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
2026-04-29 14:36:24 +08:00
|
|
|
})
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
fn extract_encoded_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
|
|
|
|
let encoded = extract_cookie_value(context, name)?;
|
|
|
|
|
let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
|
|
|
|
|
String::from_utf8(decoded)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn temp_root(name: &str) -> std::path::PathBuf {
|
|
|
|
|
let stamp = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_nanos();
|
|
|
|
|
let path = std::env::temp_dir().join(format!("{name}-{stamp}"));
|
|
|
|
|
std::fs::create_dir_all(&path).expect("temp root");
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
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,
|
2026-05-06 21:44:20 +08:00
|
|
|
) -> axum::Router {
|
|
|
|
|
app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn app_with_config_and_convex_url(
|
|
|
|
|
legacy_next_base_url: String,
|
|
|
|
|
enable_legacy_next_compat: bool,
|
|
|
|
|
convex_url: Option<String>,
|
2026-05-13 22:43:16 +08:00
|
|
|
) -> axum::Router {
|
|
|
|
|
app_with_query_fixtures(
|
|
|
|
|
legacy_next_base_url,
|
|
|
|
|
enable_legacy_next_compat,
|
|
|
|
|
convex_url,
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn app_with_query_fixtures(
|
|
|
|
|
legacy_next_base_url: String,
|
|
|
|
|
enable_legacy_next_compat: bool,
|
|
|
|
|
convex_url: Option<String>,
|
|
|
|
|
query_fixtures_json: Option<String>,
|
2026-04-29 12:24:44 +08:00
|
|
|
) -> 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,
|
2026-05-16 22:03:14 +08:00
|
|
|
enable_editor_actor: true,
|
2026-04-29 12:24:44 +08:00
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
2026-05-06 21:44:20 +08:00
|
|
|
convex_url,
|
2026-04-29 12:24:44 +08:00
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
2026-05-13 22:43:16 +08:00
|
|
|
query_fixtures_json,
|
2026-04-29 14:36:24 +08:00
|
|
|
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
|
2026-04-29 12:24:44 +08:00
|
|
|
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}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:16:34 +08:00
|
|
|
async fn spawn_legacy_unmatched_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("/unmigrated", get(|| async { "legacy-ok" }));
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
axum::serve(listener, app).await.expect("legacy server");
|
|
|
|
|
});
|
|
|
|
|
format!("http://{addr}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
#[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("/")
|
2026-05-06 21:44:20 +08:00
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-04-29 12:24:44 +08:00
|
|
|
.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");
|
2026-05-20 12:17:44 +08:00
|
|
|
assert!(html.contains(r#"<body data-mnote-web-owner="mnote-web""#));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-actor-id="user_real""#));
|
2026-04-29 12:24:44 +08:00
|
|
|
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>"#));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn trash_entry_renders_real_workspace_trash_workbench() {
|
|
|
|
|
let response = app_with_query_fixtures(
|
|
|
|
|
"http://127.0.0.1:3100".into(),
|
|
|
|
|
false,
|
|
|
|
|
None,
|
|
|
|
|
Some(
|
|
|
|
|
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/trash?workspaceId=ws_demo")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.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-testid="mnote-trash-workbench""#));
|
|
|
|
|
assert!(html.contains("已删页面"));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="restore""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="purge""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="empty-documents""#));
|
|
|
|
|
assert!(html.contains(r#"data-document-id="page_trash""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="empty-resources""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="resource-restore""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="resource-purge""#));
|
|
|
|
|
assert!(html.contains(r#"data-resource-kind="media""#));
|
|
|
|
|
assert!(html.contains(r#"data-resource-kind="mindmap""#));
|
|
|
|
|
assert!(html.contains(r#"data-resource-kind="table""#));
|
|
|
|
|
assert!(html.contains("已删附件.png"));
|
|
|
|
|
assert!(html.contains("已删思维导图.json"));
|
|
|
|
|
assert!(html.contains("已删表格.luckysheet"));
|
|
|
|
|
assert!(html.contains("new EventSource"));
|
|
|
|
|
assert!(html.contains("/api/tree/events"));
|
2026-05-17 20:11:39 +08:00
|
|
|
assert!(!html.contains("pollMs"));
|
2026-05-16 07:38:45 +08:00
|
|
|
assert!(html.contains("refreshTrashWorkbenchFromServer"));
|
|
|
|
|
assert!(!html.contains("window.location.reload"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn trash_entry_renders_local_folder_trash_workbench() {
|
|
|
|
|
let root = temp_root("mnote-local-folder-trash-entry");
|
|
|
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
|
|
|
"user_real",
|
|
|
|
|
&format!("file://{}", root.display()),
|
|
|
|
|
)
|
|
|
|
|
.expect("init local workspace");
|
|
|
|
|
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
|
|
|
|
|
std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join(".mnote").join("trash-index.json"),
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"entries": {
|
|
|
|
|
"local-md:Deleted~2FDeleted.md": {
|
|
|
|
|
"documentId": "local-md:Deleted~2FDeleted.md",
|
|
|
|
|
"resourceKind": "markdown_bundle",
|
|
|
|
|
"resourceScope": "local_folder",
|
|
|
|
|
"originalRelativePath": "Deleted",
|
|
|
|
|
"trashRelativePath": ".mnote/trash/Deleted",
|
|
|
|
|
"deletedAtMs": 1770000000000u64
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect("write trash index");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let uri = format!(
|
|
|
|
|
"/trash?sourceKind=local_folder&rootUri={}",
|
|
|
|
|
root_uri.replace(':', "%3A").replace('/', "%2F")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(uri)
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.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-trash-source-kind="local_folder""#));
|
|
|
|
|
assert!(html.contains("Deleted"));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="local-restore""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="local-purge""#));
|
2026-05-21 14:39:38 +08:00
|
|
|
assert!(html.contains(r#"data-filetree-row-id="doc:local-md:Deleted~2FDeleted.md""#));
|
|
|
|
|
assert!(html.contains("mnote.pendingLocalFolderRestoreFiletreeRowId"));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
|
|
|
|
|
assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
|
2026-05-21 15:45:11 +08:00
|
|
|
assert!(html.contains("rows.reduce(function(chain, row)"));
|
|
|
|
|
assert!(!html.contains("Promise.all(rows.map(function(row)"));
|
2026-05-20 10:43:38 +08:00
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
#[test]
|
|
|
|
|
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
|
|
|
|
|
let selected = super::choose_root_entry_active_page_id(
|
|
|
|
|
Some("page_query"),
|
|
|
|
|
Some("page_recent"),
|
|
|
|
|
Some("page_projection"),
|
|
|
|
|
Some("page_first"),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(selected.as_deref(), Some("page_query"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() {
|
|
|
|
|
let from_recent = super::choose_root_entry_active_page_id(
|
|
|
|
|
None,
|
|
|
|
|
Some("page_recent"),
|
|
|
|
|
Some("page_projection"),
|
|
|
|
|
Some("page_first"),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(from_recent.as_deref(), Some("page_recent"));
|
|
|
|
|
|
|
|
|
|
let from_projection = super::choose_root_entry_active_page_id(
|
|
|
|
|
Some(" "),
|
|
|
|
|
None,
|
|
|
|
|
Some("page_projection"),
|
|
|
|
|
Some("page_first"),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(from_projection.as_deref(), Some("page_projection"));
|
|
|
|
|
|
|
|
|
|
let from_first =
|
|
|
|
|
super::choose_root_entry_active_page_id(None, None, None, Some("page_first"));
|
|
|
|
|
assert_eq!(from_first.as_deref(), Some("page_first"));
|
|
|
|
|
|
|
|
|
|
let empty = super::choose_root_entry_active_page_id(None, None, None, None);
|
|
|
|
|
assert_eq!(empty, None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
#[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()
|
2026-05-20 12:17:44 +08:00
|
|
|
.uri("/?workspaceId=ws_demo")
|
2026-04-29 12:24:44 +08:00
|
|
|
.header("cookie", "mnote_recent_page_id=page_child")
|
2026-04-29 14:36:24 +08:00
|
|
|
.header("x-mnote-workspace-id", "ws_demo")
|
2026-05-06 21:44:20 +08:00
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-04-29 12:24:44 +08:00
|
|
|
.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""#));
|
2026-04-29 14:36:24 +08:00
|
|
|
assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#));
|
|
|
|
|
assert!(html.contains(r#"data-active="true""#));
|
|
|
|
|
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 22:43:16 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_active_page_includes_document_panes_bootstrap() {
|
|
|
|
|
let response = app_with_query_fixtures(
|
|
|
|
|
"http://127.0.0.1:3100".into(),
|
|
|
|
|
false,
|
|
|
|
|
None,
|
|
|
|
|
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": 1
|
|
|
|
|
},
|
|
|
|
|
"documents:getContent": {
|
|
|
|
|
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
|
|
|
|
"revision": 7,
|
|
|
|
|
"conflict_detection_key": "doc_1:7",
|
|
|
|
|
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
|
|
|
|
}
|
|
|
|
|
}"#
|
|
|
|
|
.into(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/?pageId=doc_1&workspaceId=ws_demo")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.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("__MNOTE_PAGE_AGGREGATE__"));
|
|
|
|
|
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
|
|
|
|
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_renders_local_first_landing_without_convex() {
|
2026-05-20 10:43:38 +08:00
|
|
|
let _guard = crate::test_support::hermes_env_lock()
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("env lock");
|
|
|
|
|
let base = temp_root("mnote-root-local-first-landing");
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
2026-05-19 08:07:17 +08:00
|
|
|
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
2026-05-20 10:43:38 +08:00
|
|
|
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
2026-05-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
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");
|
2026-05-21 05:40:06 +08:00
|
|
|
assert!(!html.contains("初始化的新页面"));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
2026-05-24 01:49:51 +08:00
|
|
|
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
|
|
|
|
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
2026-05-19 08:07:17 +08:00
|
|
|
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
2026-05-20 10:43:38 +08:00
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
2026-05-19 08:07:17 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-22 17:45:22 +08:00
|
|
|
async fn root_entry_renders_access_policy_dialog_templates_for_all_actors() {
|
2026-05-19 08:07:17 +08:00
|
|
|
let admin_response =
|
|
|
|
|
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("x-mnote-actor-id", "admin_real")
|
|
|
|
|
.header("x-mnote-actor-type", "admin")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(admin_response.status(), StatusCode::OK);
|
|
|
|
|
let admin_body = to_bytes(admin_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
2026-05-25 23:34:03 +08:00
|
|
|
assert!(admin_html.contains(r#"data-mnote-action="open-account-menu""#));
|
|
|
|
|
assert!(admin_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
|
|
|
|
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
2026-05-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
let user_response =
|
|
|
|
|
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("user response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(user_response.status(), StatusCode::OK);
|
|
|
|
|
let user_body = to_bytes(user_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
2026-05-25 23:34:03 +08:00
|
|
|
assert!(user_html.contains(r#"data-mnote-action="open-account-menu""#));
|
|
|
|
|
assert!(user_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
|
|
|
|
|
assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
|
|
|
|
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
|
|
|
|
|
|
|
|
|
let app_state = AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: false,
|
|
|
|
|
enable_debug_shell_routes: false,
|
|
|
|
|
enable_editor_actor: true,
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: None,
|
|
|
|
|
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
});
|
|
|
|
|
app_state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.upsert_user(UpsertUserInput {
|
|
|
|
|
id: Some("shujuan".into()),
|
|
|
|
|
email: Some("shujuan@example.com".into()),
|
|
|
|
|
username: "shujuan".into(),
|
|
|
|
|
display_name: "shujuan".into(),
|
|
|
|
|
role: None,
|
|
|
|
|
password_hash: None,
|
|
|
|
|
})
|
|
|
|
|
.expect("upsert sqlite user");
|
|
|
|
|
app_state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.create_session(CreateSessionInput {
|
|
|
|
|
id: None,
|
|
|
|
|
user_id: "shujuan".into(),
|
|
|
|
|
token_hash: session_token_hash("session-shujuan"),
|
|
|
|
|
user_agent: None,
|
|
|
|
|
ip_hash: None,
|
|
|
|
|
expires_at: None,
|
|
|
|
|
})
|
|
|
|
|
.expect("create sqlite session");
|
|
|
|
|
|
|
|
|
|
let response = build_app(app_state)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("cookie", "mnote_session=session-shujuan")
|
|
|
|
|
.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("shujuan 的空间"));
|
|
|
|
|
assert!(!html.contains("开发用户 的空间"));
|
2026-05-19 08:07:17 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn admin_access_policy_entry_requires_admin_actor() {
|
|
|
|
|
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/admin/access-policy")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("user response");
|
|
|
|
|
assert_eq!(user_response.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
let admin_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/admin/access-policy")
|
|
|
|
|
.header("x-mnote-actor-id", "admin_real")
|
|
|
|
|
.header("x-mnote-actor-type", "admin")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(admin_response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(admin_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(html.contains("文件夹授权"));
|
|
|
|
|
assert!(html.contains("管理员可以授权任意本地文件夹"));
|
|
|
|
|
assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
2026-05-19 08:07:17 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 01:47:40 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn user_access_policy_entry_renders_user_share_management() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/user/access-policy")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("user access policy 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-mnote-shell="user-access-policy""#));
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(html.contains("文件夹授权"));
|
|
|
|
|
assert!(html.contains("普通用户只能授权自己空间下的文件夹"));
|
|
|
|
|
assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
|
|
|
|
|
assert!(!html.contains("仅管理员可见"));
|
2026-05-22 01:47:40 +08:00
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
|
|
|
|
let root =
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
|
|
|
|
|
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
|
|
|
|
|
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
|
|
|
"user_real",
|
|
|
|
|
&root_uri,
|
|
|
|
|
)
|
|
|
|
|
.expect("init local workspace");
|
2026-05-08 00:41:03 +08:00
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
|
|
|
|
))
|
2026-05-24 01:49:51 +08:00
|
|
|
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
|
2026-05-08 00:41:03 +08:00
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
|
|
|
|
assert!(html.contains("local_folder"));
|
|
|
|
|
assert!(html.contains("Local Root"));
|
|
|
|
|
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
|
|
|
|
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
|
|
|
|
assert!(!html.contains("legacy_next_compat_disabled"));
|
|
|
|
|
assert!(!html.contains(r#"href="/tree"#));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 01:49:51 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
|
|
|
|
|
let root = temp_root("mnote-root-local-folder-no-active-page");
|
|
|
|
|
std::fs::create_dir_all(root.join("attachments")).expect("create attachments");
|
|
|
|
|
std::fs::write(root.join("attachments").join("report-a.pdf"), b"%PDF-1.4\n")
|
|
|
|
|
.expect("write pdf");
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
|
|
|
"user_real",
|
|
|
|
|
&root_uri,
|
|
|
|
|
)
|
|
|
|
|
.expect("init local workspace");
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
|
|
|
|
))
|
|
|
|
|
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
|
|
|
|
assert!(html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
|
|
|
|
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
|
|
|
|
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
2026-05-26 00:35:35 +08:00
|
|
|
assert!(html.contains(
|
|
|
|
|
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
|
|
|
|
));
|
|
|
|
|
assert!(
|
|
|
|
|
include_str!("../../browser/document-editor-adapter-runtime.js")
|
|
|
|
|
.contains("openResourceInActiveTab")
|
|
|
|
|
);
|
2026-05-24 01:49:51 +08:00
|
|
|
assert!(!html.contains(r#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_redirects_anonymous_viewer_to_auth() {
|
|
|
|
|
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::SEE_OTHER);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get(header::LOCATION)
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("/auth")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-mnote-web-owner")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("mnote-web")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.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-mnote-shell="workspace""#));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn root_entry_initializes_default_local_workspace_page() {
|
|
|
|
|
let _guard = crate::test_support::hermes_env_lock()
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("env lock");
|
|
|
|
|
let base = temp_root("mnote-root-default-local-workspace");
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/")
|
|
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
let expected_root = base
|
|
|
|
|
.join("users")
|
|
|
|
|
.join("user_real")
|
|
|
|
|
.join("workspaces")
|
|
|
|
|
.join("my-space");
|
2026-05-21 05:40:06 +08:00
|
|
|
assert!(
|
|
|
|
|
!expected_root.join("初始化的新页面").exists(),
|
|
|
|
|
"默认工作区不应创建'初始化的新页面'目录"
|
|
|
|
|
);
|
|
|
|
|
assert!(!html.contains("初始化的新页面"));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html.contains("local_folder"));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
|
|
|
|
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
2026-05-21 23:53:39 +08:00
|
|
|
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
|
|
|
|
assert!(!html.contains("当前还没有可显示的本地工作区"));
|
|
|
|
|
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html.contains(r#""transport":"disabled""#));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
#[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]
|
2026-05-06 21:44:20 +08:00
|
|
|
async fn favicon_is_handled_by_gateway_when_compat_disabled() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/favicon.ico")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-mnote-web-owner")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("mnote-web")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-22 17:45:22 +08:00
|
|
|
async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
2026-05-06 21:44:20 +08:00
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/auth")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
2026-05-22 17:45:22 +08:00
|
|
|
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"new-user@example.com","name":"new-user","password":"MnoteE2E123!","flow":"signUp"}}}"#,
|
2026-05-06 21:44:20 +08:00
|
|
|
))
|
|
|
|
|
.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 cookies = response.headers().get_all(header::SET_COOKIE);
|
|
|
|
|
let values = cookies
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|value| value.to_str().unwrap_or_default())
|
|
|
|
|
.collect::<Vec<_>>();
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(values.iter().any(|value| value.contains("mnote_session=")));
|
2026-05-06 21:44:20 +08:00
|
|
|
assert!(values
|
|
|
|
|
.iter()
|
2026-05-22 17:45:22 +08:00
|
|
|
.any(|value| value.contains("mnote_actor_id=new-user")));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(values
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|value| value.contains("mnote_actor_type=user")));
|
2026-05-06 21:44:20 +08:00
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
2026-05-22 17:45:22 +08:00
|
|
|
assert_eq!(payload["userId"], "new-user");
|
|
|
|
|
assert_eq!(payload["email"], "new-user@example.com");
|
|
|
|
|
assert_eq!(payload["authMode"], "sqliteSession");
|
2026-05-06 21:44:20 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[tokio::test]
|
2026-05-22 17:45:22 +08:00
|
|
|
async fn auth_api_signin_accepts_sqlite_username_after_signup() {
|
|
|
|
|
let app = app_with_config("http://127.0.0.1:3100".into(), false);
|
|
|
|
|
let signup = app
|
|
|
|
|
.clone()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/auth")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","name":"mnote-e2e","password":"MnoteE2E123!","flow":"signUp"}}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
2026-05-22 01:47:40 +08:00
|
|
|
.await
|
2026-05-22 17:45:22 +08:00
|
|
|
.expect("signup response");
|
|
|
|
|
assert_eq!(signup.status(), StatusCode::OK);
|
2026-05-22 01:47:40 +08:00
|
|
|
|
2026-05-22 17:45:22 +08:00
|
|
|
let response = app
|
2026-05-22 01:47:40 +08:00
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/auth")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"account":"mnote-e2e","password":"MnoteE2E123!","flow":"signIn"}}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
2026-05-22 17:45:22 +08:00
|
|
|
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["userId"], "mnote-e2e");
|
|
|
|
|
assert_eq!(payload["email"], "mnote.e2e@example.com");
|
|
|
|
|
assert_eq!(payload["authMode"], "sqliteSession");
|
2026-05-22 01:47:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn auth_api_requires_email_for_signup() {
|
|
|
|
|
let response = app_with_config_and_convex_url(
|
|
|
|
|
"http://127.0.0.1:3100".into(),
|
|
|
|
|
false,
|
|
|
|
|
Some("http://127.0.0.1:9".into()),
|
|
|
|
|
)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/auth")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
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["code"], "auth_signup_email_required");
|
2026-05-22 17:45:22 +08:00
|
|
|
assert!(payload["message"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.contains("注册账号时请填写邮箱"));
|
2026-05-22 01:47:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-22 17:45:22 +08:00
|
|
|
async fn auth_api_signout_clears_sqlite_session_cookie() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/auth")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("cookie", "mnote_session=raw-session-token")
|
|
|
|
|
.body(Body::from(r#"{"action":"auth:signOut"}"#))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
2026-05-20 10:43:38 +08:00
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let cookies = response.headers().get_all(header::SET_COOKIE);
|
|
|
|
|
let values = cookies
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|value| value.to_str().unwrap_or_default())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
assert!(values
|
|
|
|
|
.iter()
|
2026-05-22 17:45:22 +08:00
|
|
|
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
|
2026-05-20 10:43:38 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
|
2026-04-29 12:24:44 +08:00
|
|
|
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()),
|
2026-05-06 21:44:20 +08:00
|
|
|
None
|
2026-04-29 12:24:44 +08:00
|
|
|
);
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
2026-05-06 21:44:20 +08:00
|
|
|
assert!(html.contains(r#"data-mnote-shell="auth""#));
|
2026-05-21 23:53:39 +08:00
|
|
|
assert!(html.contains("账号登录"));
|
|
|
|
|
assert!(html.contains("邮箱或用户名"));
|
|
|
|
|
assert!(!html.contains(r#"<span>"用户名"</span>"#));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(html.contains("测试账号快速登录"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-06 21:44:20 +08:00
|
|
|
async fn auth_entry_redirects_authenticated_viewer_to_root() {
|
|
|
|
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
2026-04-29 12:24:44 +08:00
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/auth")
|
2026-05-06 21:44:20 +08:00
|
|
|
.header("x-mnote-actor-id", "user_real")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-04-29 12:24:44 +08:00
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
2026-05-06 21:44:20 +08:00
|
|
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
2026-04-29 12:24:44 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
2026-05-06 21:44:20 +08:00
|
|
|
.get(header::LOCATION)
|
2026-04-29 12:24:44 +08:00
|
|
|
.and_then(|value| value.to_str().ok()),
|
2026-05-06 21:44:20 +08:00
|
|
|
Some("/")
|
2026-04-29 12:24:44 +08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-25 23:34:03 +08:00
|
|
|
async fn auth_api_no_longer_proxies_to_legacy_next_for_origin_normalization() {
|
2026-04-29 12:24:44 +08:00
|
|
|
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");
|
|
|
|
|
|
2026-05-25 23:34:03 +08:00
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
2026-04-29 12:24:44 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
2026-05-25 23:34:03 +08:00
|
|
|
.get("x-mnote-web-owner")
|
2026-04-29 12:24:44 +08:00
|
|
|
.and_then(|value| value.to_str().ok()),
|
2026-05-25 23:34:03 +08:00
|
|
|
Some("mnote-web")
|
2026-04-29 12:24:44 +08:00
|
|
|
);
|
2026-05-25 23:34:03 +08:00
|
|
|
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-25 23:34:03 +08:00
|
|
|
async fn auth_api_no_longer_uses_legacy_next_cookie_proxy() {
|
2026-04-29 12:24:44 +08:00
|
|
|
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");
|
|
|
|
|
|
2026-05-25 23:34:03 +08:00
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
2026-04-29 12:24:44 +08:00
|
|
|
let cookies = response.headers().get_all("set-cookie");
|
|
|
|
|
let values = cookies
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|value| value.to_str().unwrap_or_default())
|
|
|
|
|
.collect::<Vec<_>>();
|
2026-05-25 23:34:03 +08:00
|
|
|
assert!(!values
|
2026-04-29 12:24:44 +08:00
|
|
|
.iter()
|
2026-05-25 23:34:03 +08:00
|
|
|
.any(|value| value.contains("__convexAuthJWT=")));
|
|
|
|
|
assert!(!values
|
2026-04-29 12:24:44 +08:00
|
|
|
.iter()
|
2026-05-25 23:34:03 +08:00
|
|
|
.any(|value| value.contains("__convexAuthRefreshToken=")));
|
|
|
|
|
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
2026-05-11 13:16:34 +08:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() {
|
|
|
|
|
let legacy_base_url = spawn_legacy_unmatched_upstream().await;
|
|
|
|
|
let response = app_with_legacy_next_base_url(legacy_base_url)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/unmigrated")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("x-mnote-legacy-upstream")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
None
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|