yggdrasil/main.rs
1//! 服务端入口与启动配置
2//!
3//! 本文件是 Dioxus fullstack 应用的启动入口。
4//! 当启用 `server` feature 时,启动 Axum 服务器并挂载:
5//! - Dioxus server function(由 `serve_dioxus_application` 自动注册);
6//! - 自定义 Axum 路由:图片上传 `/api/upload`、图片服务 `/uploads/{*path}`;
7//! - 增量渲染(Incremental Rendering)缓存配置。
8//!
9//! 当未启用 `server` feature(例如编译为 WASM 前端)时,
10//! 仅调用 `dioxus::launch` 启动客户端应用。
11
12// 全局内存分配器:mimalloc。
13// 多线程高频小对象分配场景下吞吐显著优于系统 malloc,且对全静态 musl 链接友好。
14// cfg 门控(与项目「双目标编译」约定一致):
15// - feature = "server":分配器只服务端二进制需要。
16// - not(wasm32):mimalloc_rust 在 wasm32 上无法编译(mimalloc_rust Issue #76),
17// WASM 前端走默认分配器。两个门控同时满足才注册。
18#[cfg(all(feature = "server", not(target_arch = "wasm32")))]
19#[global_allocator]
20static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
21
22// 业务模块
23mod api;
24mod auth;
25// build_info:编译期注入的 git/rustc/构建时间信息。模块内部 gate 在 server feature,
26// 模块声明本身不需要再加 cfg(空模块在 WASM 端也能编译)。
27mod build_info;
28mod cache;
29mod components;
30mod context;
31mod db;
32pub mod infra;
33// highlight 模块仅在服务端构建时编译
34#[cfg(feature = "server")]
35mod highlight;
36// middleware:Axum 中间件与启动期纯函数(cache-control / admin 守卫 / 压缩层),
37// server-only。从 main.rs 抽出以便独立测试,路由组装处以 crate::middleware::xxx 调用。
38mod hooks;
39#[cfg(feature = "server")]
40mod middleware;
41mod models;
42// mcp:Model Context Protocol 服务器(/mcp Streamable HTTP,bearer token 鉴权)。
43// 仅 server feature 编译;WASM 前端不引用任何 mcp 符号。
44// allow(dead_code):原用于掩盖 T1 tracer bullet 期间未接线的 mcp/resources.rs(273 行
45// 完整资源子系统,从未 override ServerHandler::list_resources/read_resource)。
46// 已删除该模块(D1)—— MCP 现无死代码,allow 同步移除,以免未来再次静默掩盖死代码。
47#[cfg(feature = "server")]
48mod mcp;
49mod pages;
50mod router;
51// sysinfo_sampler:主机指标快照。
52// SystemSnapshot 结构体两端都编译(被 system_status 的 ServerStatus 字段引用);
53// 真正的采样任务 / RwLock / read_snapshot 实现在本模块内部自行 #[cfg(feature = "server")] gate。
54mod sysinfo_sampler;
55// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
56#[cfg(feature = "server")]
57mod ssr_cache;
58mod tasks;
59mod theme;
60// tiptap_bridge:共享类型(UploadsInFlight/UploadErrorEntry)两端都编译;
61// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
62mod tiptap_bridge;
63// codemirror_bridge:SQL 编辑器的 wasm-bindgen 绑定,结构镜像 tiptap_bridge。
64// 共享类型(SqlSchema/SqlTable)两端都编译;extern 与 EditorHandle 在 #[cfg(wasm32)] 子模块里。
65mod codemirror_bridge;
66mod utils;
67mod webp;
68mod xterm_bridge;
69
70/// 程序入口
71fn main() {
72 // server feature:启动服务端
73 #[cfg(feature = "server")]
74 {
75 // 加载 .env 环境变量
76 dotenvy::dotenv().ok();
77 // 初始化 tracing 日志,默认级别为 info
78 tracing_subscriber::fmt()
79 .with_env_filter(
80 tracing_subscriber::EnvFilter::try_from_default_env()
81 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
82 )
83 .init();
84
85 // 打印构建元信息(版本 / git / 提交时间 / rustc / 编译时刻)。
86 // 必须在 tracing 初始化之后,否则日志被丢弃。
87 build_info::log_build_info();
88
89 // 校验数据库连接串,未设置则直接退出
90 if std::env::var("DATABASE_URL").is_err() {
91 tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported.");
92 eprintln!("ERROR: DATABASE_URL environment variable not set");
93 eprintln!(
94 "HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
95 );
96 std::process::exit(1);
97 }
98
99 // 前置校验 DATABASE_URL 格式 + DB_POOL_SIZE,避免触发 DB_POOL LazyLock 闭包里
100 // 不可达的 .expect() panic——让用户可修复的配置错误走统一友好的 exit(1) 路径。
101 // 此处必须在任何 DB_POOL.get() 调用之前执行(即迁移之前)。
102 if let Err(e) = db::pool::validate_database_url() {
103 tracing::error!("{e}");
104 eprintln!("ERROR: {e}");
105 if e.starts_with("DB_POOL_SIZE") {
106 eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
107 } else {
108 eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
109 }
110 std::process::exit(1);
111 }
112
113 // 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头,
114 // 反向代理后存在绕过风险。本地开发未设置时也会打一条 WARN(代价可接受)。
115 api::csrf::warn_if_app_base_url_unset();
116
117 // 启动前执行数据库迁移。阻塞:完成前不监听端口。
118 // 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
119 // 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
120 //
121 // main() 是同步函数,这里用一个独立的多线程 runtime 驱动迁移的异步逻辑,
122 // 完成后再交给 dioxus::server::serve() 启动它自己的 runtime。
123 // 两个 runtime 不重叠,避免与 Dioxus 内部 runtime 产生交互。
124 let migrate_rt = tokio::runtime::Builder::new_multi_thread()
125 .enable_all()
126 .build()
127 .expect("failed to build migration runtime");
128 migrate_rt.block_on(async {
129 tracing::info!("running database migrations");
130
131 // 连接池指向目标库,但目标库可能尚不存在(全新部署)。
132 // 先连 postgres 维护库确保目标库存在,复用启动超时窗口应对 DB 起得慢。
133 if let Err(e) = db::pool::ensure_database().await {
134 tracing::error!("failed to ensure target database exists: {e}");
135 eprintln!("ERROR: failed to ensure target database exists: {e}");
136 eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
137 std::process::exit(1);
138 }
139
140 // 启动期用长重试窗口拿连接:DB 可能还在初始化(docker-compose 无 healthcheck、
141 // 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。
142 let mut conn = match db::pool::get_conn_for_startup().await {
143 Ok(conn) => conn,
144 Err(e) => {
145 let secs = crate::utils::server::parse_migrate_startup_timeout();
146 tracing::error!("could not connect to database within {secs}s startup window: {e}");
147 eprintln!("ERROR: could not connect to database within {secs}s startup window: {e}");
148 eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
149 eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
150 std::process::exit(1);
151 }
152 };
153
154 // 连接拿到后再执行迁移主体(咨询锁 + 建表 + 应用迁移)。
155 if let Err(e) = db::migrate::run_on_conn(&mut conn).await {
156 tracing::error!("database migration failed: {e}");
157 eprintln!("ERROR: database migration failed: {e}");
158 eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
159 std::process::exit(1);
160 }
161
162 // 端口预探测:dioxus::server::serve() 内部对
163 // `TcpListener::bind(addr).await...unwrap()`(dioxus-server 0.7.9 launch.rs:143)
164 // 失败会直接 panic(SIGABRT)。这里在交接给 serve() 之前先探测同一地址,
165 // 失败则走统一的 exit(1) 路径,输出可操作的提示,而不是丢一个裸 panic 栈。
166 // 探测用的 listener 立即 drop,由 serve() 重新绑定(同进程内快速 rebind,
167 // 不经过 TIME_WAIT,无窗口问题)。
168 let addr = dioxus::cli_config::fullstack_address_or_localhost();
169 if let Err(e) = tokio::net::TcpListener::bind(addr).await {
170 tracing::error!("无法绑定监听地址 {addr}: {e}");
171 eprintln!("ERROR: 无法绑定监听地址 {addr}: {e}");
172 eprintln!(
173 "HINT: 端口 {} 可能已被占用。用 `lsof -i :{}` 查看占用进程,\
174 或设置 PORT 环境变量换一个端口。",
175 addr.port(),
176 addr.port()
177 );
178 std::process::exit(1);
179 }
180 });
181 // 迁移 runtime 用完即弃,显式 drop 以在 serve() 前释放其线程资源。
182 drop(migrate_rt);
183
184 // 启动 Dioxus 服务端,返回构建好的 Axum Router
185 dioxus::server::serve(|| async move {
186 use axum::http::StatusCode;
187 use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
188 use std::time::Duration;
189 use tower_http::timeout::TimeoutLayer;
190
191 // 启动后台定时任务:IP 信息清理
192 tokio::spawn(async {
193 tasks::ip_purge::run_purge().await;
194 });
195
196 // 启动后台定时任务:过期 session 清理
197 tokio::spawn(async {
198 tasks::session_cleanup::run_cleanup().await;
199 });
200
201 // 启动后台定时任务:回收站自动清理
202 tokio::spawn(async {
203 tasks::post_purge::run_purge().await;
204 });
205
206 // 启动后台定时任务:图片磁盘缓存清理
207 tokio::spawn(async {
208 tasks::image_cache_cleanup::run_cleanup().await;
209 });
210
211 // 启动后台采样任务:sysinfo 主机指标(CPU/内存/磁盘),server function 只读快照。
212 sysinfo_sampler::spawn_sampler();
213
214 // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
215 // 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
216 // Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用
217 // 之前,SSR_CACHE_SECS 仍是唯一有效的兜底 TTL——它就是内容写入后
218 // SSR 页面可见滞后的上界。
219 let ssr_cache_secs = std::env::var("SSR_CACHE_SECS")
220 .ok()
221 .and_then(|s| s.parse().ok())
222 .unwrap_or(3600);
223 tracing::info!(
224 ssr_cache_secs,
225 "增量渲染缓存生效(写入后内容可见滞后的上界);\
226 调小可缩短滞后,代价是 SSR 重渲染更频繁"
227 );
228 let config = ServeConfig::builder().incremental(
229 dioxus::server::IncrementalRendererConfig::default()
230 .invalidate_after(std::time::Duration::from_secs(ssr_cache_secs)),
231 );
232
233 // 版本响应头开关:默认开启。设 0/false/no 关闭(注重安全、不想对外暴露版本/commit 时)。
234 // bool 解析约定与 COOKIE_SECURE 一致(matches "1"/"true"/"yes");这里取反为
235 // "非 false 值即开",使默认行为(unwrap_or(true))对应「暴露」。
236 let expose_version_headers = std::env::var("EXPOSE_VERSION_HEADERS")
237 .ok()
238 .map(|v| !matches!(v.as_str(), "0" | "false" | "no"))
239 .unwrap_or(true);
240 tracing::info!(
241 expose_version_headers,
242 "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)"
243 );
244
245 // 自定义 API 路由:图片上传(大文件,需要更长超时)
246 // CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
247 let upload_route = axum::Router::new()
248 .route(
249 "/api/upload",
250 axum::routing::post(crate::api::upload::upload_image),
251 )
252 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
253 .layer(TimeoutLayer::with_status_code(
254 StatusCode::REQUEST_TIMEOUT,
255 Duration::from_secs(300),
256 ))
257 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
258
259 // MCP bearer 上传端点(带外二进制传输):bearer 鉴权在 handler 内部完成,
260 // 不挂 CSRF(bearer 在请求头,浏览器不自动附带,无 CSRF 风险)。
261 // 10MiB body + 300s 超时与 web 上传一致;二进制不经 JSON-RPC,绕开
262 // rmcp 的 4MiB 请求体上限。token-keyed 限流在 handler 内 check。
263 let mcp_upload_route = axum::Router::new()
264 .route(
265 "/api/mcp/upload",
266 axum::routing::post(crate::api::upload::mcp_upload_image),
267 )
268 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
269 .layer(TimeoutLayer::with_status_code(
270 StatusCode::REQUEST_TIMEOUT,
271 Duration::from_secs(300),
272 ));
273
274 // 数据导出:流式响应,走 GET + query(参数较短)。
275 // 鉴权在 handler 内部从 cookie 校验 admin;CSRF 最外层拦截非法来源。
276 let export_route = axum::Router::new()
277 .route(
278 "/api/database/export",
279 axum::routing::get(crate::api::database::export::export_data),
280 )
281 // 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录)
282 .route(
283 "/api/database/backups/{filename}",
284 axum::routing::get(crate::api::database::backup::download_backup),
285 )
286 .layer(TimeoutLayer::with_status_code(
287 StatusCode::REQUEST_TIMEOUT,
288 Duration::from_secs(120),
289 ))
290 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
291
292 // SSE 流式输出端点:GET /api/exec/stream?task_id=X
293 // 不挂 TimeoutLayer!SSE 是长连接,30s timeout 会杀掉流。
294 // 鉴权 + 限流已在 start_exec_stream server function 完成(校验链),
295 // 此处只校验 task_id 存在;CSRF 对 GET 放行(is_write_method 返回 false)。
296 // CompressionLayer 跳过 text/event-stream(见 compression_layer_from_env 注释),
297 // 但 sse_route 本身不挂 compression,更安全。
298 let sse_route = axum::Router::new()
299 .route(
300 "/api/exec/stream",
301 axum::routing::get(crate::api::code_runner::sse::exec_stream),
302 )
303 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
304
305 // Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
306 let dioxus_app =
307 axum::Router::new().serve_dioxus_application(config, router::AppRouter);
308
309 // 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
310 // layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
311 let mut app_routes = dioxus_app
312 .layer(axum::middleware::from_fn(
313 crate::middleware::ssr_generation_middleware,
314 ))
315 .layer(axum::middleware::from_fn(
316 crate::middleware::add_cache_control,
317 ))
318 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
319 if let Some(layer) = crate::middleware::compression_layer_from_env() {
320 app_routes = app_routes.layer(layer);
321 }
322 let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
323 StatusCode::REQUEST_TIMEOUT,
324 Duration::from_secs(30),
325 ));
326 // admin_guard 置于最外层(最后添加 = 最先执行):未登录的 /admin* 请求
327 // 在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。
328 let app_routes =
329 app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
330
331 // 静态资源路由:图片文件服务。
332 // 注意:`dioxus::server::serve()` 接管了 listener 与 `into_make_service`
333 // 调用,没有机会换成 `into_make_service_with_connect_info::<SocketAddr>()`,
334 // 所以手动 merge 进来的路由(含 static_routes)拿不到 `ConnectInfo` 扩展。
335 // serve_image / upload_image 因此都用 `Option<Extension<ConnectInfo<SocketAddr>>>`
336 // 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
337 // 使限流能拿到真实客户端 IP。
338 let static_routes = axum::Router::new()
339 .route("/healthz", axum::routing::get(crate::api::health::healthz))
340 .route("/readyz", axum::routing::get(crate::api::health::readyz))
341 .route(
342 "/uploads/{*path}",
343 axum::routing::get(crate::api::image::serve_image),
344 )
345 .route(
346 "/uploads",
347 axum::routing::get(|| async { StatusCode::NOT_FOUND }),
348 )
349 .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
350 .route(
351 "/feed.json",
352 axum::routing::get(crate::api::feed::json_feed),
353 );
354
355 let router = upload_route
356 .merge(mcp_upload_route)
357 .merge(export_route)
358 .merge(sse_route)
359 .merge(app_routes)
360 .merge(static_routes)
361 .merge(crate::mcp::router::mcp_route());
362
363 // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、
364 // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。
365 let router = if expose_version_headers {
366 router.layer(axum::middleware::from_fn(
367 crate::middleware::version_headers_middleware,
368 ))
369 } else {
370 router
371 };
372
373 Ok(router)
374 });
375 }
376
377 // 非 server feature(通常为 WASM 前端):启动客户端应用
378 #[cfg(not(feature = "server"))]
379 {
380 use router::AppRouter;
381 dioxus::launch(AppRouter);
382 }
383}