Files
mnote/rust/crates/mnote-web/src/app.rs
T

449 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::document_buffer_store::BufferStore;
use crate::editor_actor::EditorRuntimeActor;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::ControlPlaneStore;
#[cfg(test)]
use control_plane::SqliteControlPlaneStore;
#[cfg(not(test))]
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
use std::env;
use std::fs;
use std::sync::Arc;
#[cfg(not(test))]
use std::time::Duration;
use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub service_name: String,
pub service_version: String,
pub bind_addr: String,
pub public_bind_addr: String,
pub legacy_next_base_url: Option<String>,
pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool,
pub enable_page_ai_pi_lab: bool,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
pub convex_admin_key: Option<String>,
pub allow_dev_fixtures: bool,
pub query_fixtures_json: Option<String>,
pub mutation_fixtures_json: Option<String>,
pub dev_user_id: String,
pub dev_user_name: String,
pub dev_user_email: String,
/// "dev" | "prod" — 影响前端显示与 env 片段中的 base URL。
pub environment: String,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
bind_addr: env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.unwrap_or_else(|_| "127.0.0.1:0".into()),
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
dev_user_id: env::var("DEV_USER_ID")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
.unwrap_or_else(|| "dev-user".into()),
dev_user_name: env::var("DEV_USER_NAME")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_NAME"))
.unwrap_or_else(|| "开发用户".into()),
dev_user_email: env::var("DEV_USER_EMAIL")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()),
environment: detect_environment(),
}
}
}
/// 推断运行环境:显式 MNOTE_WEB_ENV > 端口匹配(3000=dev, 3003=prod> 默认 "dev"。
fn detect_environment() -> String {
if let Ok(v) = env::var("MNOTE_WEB_ENV") {
let t = v.trim().to_lowercase();
if !t.is_empty() {
return t;
}
}
let port = env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.ok()
.and_then(|addr| addr.rsplit(':').next()?.parse::<u16>().ok());
match port {
Some(3003) => "prod".into(),
_ => "dev".into(),
}
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
#[cfg(not(test))]
fn parse_turso_sync_interval_ms() -> Option<Duration> {
let value = env::var("MNOTE_TURSO_SYNC_INTERVAL_MS")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())?;
let ms: u64 = value
.parse()
.expect("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数(毫秒),例如 5000");
if ms == 0 {
panic!("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数,当前值: {ms}");
}
Some(Duration::from_millis(ms))
}
use tokio::sync::broadcast;
#[derive(Clone)]
pub struct AppState {
config: Arc<AppConfig>,
local_folder_watcher_registry: LocalFolderWatcherRegistry,
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
let (block_delta_tx, _) = broadcast::channel(256);
let (stream_delta_tx, _) = broadcast::channel(256);
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
let buffer_store = BufferStore::new();
let control_plane = open_control_plane_store();
Self {
config: Arc::new(config),
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(
buffer_store.clone(),
control_plane.clone(),
),
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
buffer_store,
control_plane,
}
}
pub fn config(&self) -> &AppConfig {
self.config.as_ref()
}
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
&self.local_folder_watcher_registry
}
pub fn control_plane(&self) -> &dyn ControlPlaneStore {
self.control_plane.as_ref()
}
}
#[cfg(test)]
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 control-plane"))
}
#[cfg(not(test))]
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let backend = env::var("MNOTE_CONTROL_PLANE_BACKEND")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "libsql-local".to_string());
match backend.as_str() {
"libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(),
"turso-remote" => open_turso_remote_control_plane_store(),
"turso-local-replica" | "turso-remote-replica" => {
open_turso_remote_replica_control_plane_store()
}
"turso-synced" => open_turso_synced_control_plane_store(),
other => panic!(
"不支持的控制面后端 {other}mnote-web 运行时只支持 libsql-local/turso-remote/turso-local-replica/turso-syncedSQLite 仅保留给 control-plane-admin 迁移/导出和测试"
),
}
}
#[cfg(not(test))]
fn open_turso_local_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string()
});
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 libSQL local 控制面目录");
}
Arc::new(TursoControlPlaneStore::open_local(&db_path).expect("初始化 libSQL local 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(TursoControlPlaneStore::open_remote(url, token).expect("初始化 Turso remote 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_replica_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let replica_path = env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db".to_string()
});
if let Some(parent) = std::path::Path::new(&replica_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso local replica 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_remote_replica(
replica_path,
url,
token,
parse_turso_sync_interval_ms(),
)
.expect("初始化 Turso local replica 控制面"),
)
}
#[cfg(not(test))]
fn open_turso_synced_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let local_path = env::var("MNOTE_TURSO_SYNCED_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db".to_string()
});
if let Some(parent) = std::path::Path::new(&local_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso synced 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig {
mode: TursoControlPlaneMode::Synced,
path: Some(local_path.into()),
remote_url: Some(url),
auth_token: Some(token),
sync_interval: parse_turso_sync_interval_ms(),
})
.expect("初始化 Turso synced 控制面"),
)
}
pub fn build_app(state: AppState) -> Router {
build_router(state)
// Outer layers run first on request / last on response. Compress HTML/JSON
// for large local-folder SSR shells (PageTree/FileTree).
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http().on_failure(()))
.layer(axum::middleware::from_fn(log_failed_response))
.layer(axum::middleware::from_fn(inject_request_context))
}
async fn log_failed_response(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let response = next.run(request).await;
let status = response.status();
if status.is_server_error() {
let error_code = response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
if error_code == "convex_retired" {
warn!(
method = %method,
uri = %uri,
status = %status,
error_code = %error_code,
"退役 legacy cloud/Convex 兼容路径被请求"
);
} else {
error!(
method = %method,
uri = %uri,
status = %status,
error_code = %error_code,
"mnote-web 请求返回服务端错误"
);
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use control_plane::UpsertUserInput;
fn test_config() -> AppConfig {
AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}
}
#[test]
fn app_state_initializes_control_plane_store_for_tests() {
let state = AppState::new(test_config());
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("shujuan".into()),
email: Some("shujuan@163.com".into()),
username: "shujuan".into(),
display_name: "shujuan".into(),
role: None,
password_hash: None,
})
.expect("upsert user");
let workspace = state
.control_plane()
.ensure_default_workspace("shujuan")
.expect("default workspace");
assert_eq!(workspace.name, "shujuan 的空间");
let access = state
.control_plane()
.resolve_access("shujuan", &workspace.root_uri)
.expect("owner access");
assert_eq!(access.permission, "write");
}
}