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 config;
31mod context;
32mod db;
33pub mod infra;
34// highlight 模块仅在服务端构建时编译
35#[cfg(feature = "server")]
36mod highlight;
37// middleware:Axum 中间件与启动期纯函数(cache-control / admin 守卫 / 压缩层),
38// server-only。从 main.rs 抽出以便独立测试,路由组装处以 crate::middleware::xxx 调用。
39mod hooks;
40#[cfg(feature = "server")]
41mod middleware;
42mod models;
43// mcp:Model Context Protocol 服务器(/mcp Streamable HTTP,bearer token 鉴权)。
44// 仅 server feature 编译;WASM 前端不引用任何 mcp 符号。
45// allow(dead_code):原用于掩盖 T1 tracer bullet 期间未接线的 mcp/resources.rs(273 行
46// 完整资源子系统,从未 override ServerHandler::list_resources/read_resource)。
47// 已删除该模块(D1)—— MCP 现无死代码,allow 同步移除,以免未来再次静默掩盖死代码。
48#[cfg(feature = "server")]
49mod mcp;
50mod pages;
51mod router;
52// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
53#[cfg(feature = "server")]
54mod ssr_cache;
55mod tasks;
56mod theme;
57// bridges:libs/ 下各 JS IIFE(tiptap / codemirror / xterm)的 wasm-bindgen 桥接层。
58mod bridges;
59mod utils;
60
61/// 程序入口
62fn main() {
63 // server feature:启动服务端
64 #[cfg(feature = "server")]
65 {
66 // 加载 .env 环境变量
67 dotenvy::dotenv().ok();
68 // 初始化 tracing:registry 组合两个 Layer。
69 // - fmt 层:控制台输出,行为与原 fmt() subscriber 完全一致
70 // (RUST_LOG / 默认 info 的 EnvFilter 改为 per-layer filter)。
71 // - capture 层:把全部 target 的日志事件经 mpsc 送进日志查看器管道
72 // (独立 EnvFilter 读 LOG_VIEWER_LEVEL,默认 info,不吃 RUST_LOG)。
73 // capture 层在迁移 runtime 之前安装:启动期日志先进 mpsc 缓冲
74 // (容量 4096),log_writer 启动后批量补写落库。
75 use tracing_subscriber::prelude::*;
76 let fmt_filter = tracing_subscriber::EnvFilter::try_from_default_env()
77 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
78 let fmt_layer = tracing_subscriber::fmt::layer().with_filter(fmt_filter);
79 let capture_layer =
80 api::logs::capture::CaptureLayer.with_filter(api::logs::capture::log_viewer_filter());
81 tracing_subscriber::registry()
82 .with(fmt_layer)
83 .with(capture_layer)
84 .init();
85
86 // 打印构建元信息(版本 / git / 提交时间 / rustc / 编译时刻)。
87 // 必须在 tracing 初始化之后,否则日志被丢弃。
88 build_info::log_build_info();
89
90 // 校验数据库连接串,未设置则直接退出
91 if std::env::var("DATABASE_URL").is_err() {
92 tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported.");
93 eprintln!("ERROR: DATABASE_URL environment variable not set");
94 eprintln!(
95 "HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
96 );
97 std::process::exit(1);
98 }
99
100 // 前置校验 DATABASE_URL 格式 + DB_POOL_SIZE,避免触发 DB_POOL LazyLock 闭包里
101 // 不可达的 .expect() panic——让用户可修复的配置错误走统一友好的 exit(1) 路径。
102 // 此处必须在任何 DB_POOL.get() 调用之前执行(即迁移之前)。
103 if let Err(e) = db::pool::validate_database_url() {
104 tracing::error!("{e}");
105 eprintln!("ERROR: {e}");
106 if e.starts_with("DB_POOL_SIZE") {
107 eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
108 } else {
109 eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
110 }
111 std::process::exit(1);
112 }
113
114 // 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头,
115 // 反向代理后存在绕过风险。本地开发未设置时也会打一条 WARN(代价可接受)。
116 api::csrf::warn_if_app_base_url_unset();
117
118 // 启动前执行数据库迁移。阻塞:完成前不监听端口。
119 // 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
120 // 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
121 //
122 // main() 是同步函数,这里用一个独立的多线程 runtime 驱动迁移的异步逻辑,
123 // 完成后再交给 dioxus::server::serve() 启动它自己的 runtime。
124 // 两个 runtime 不重叠,避免与 Dioxus 内部 runtime 产生交互。
125 let migrate_rt = tokio::runtime::Builder::new_multi_thread()
126 .enable_all()
127 .build()
128 .expect("failed to build migration runtime");
129 migrate_rt.block_on(async {
130 tracing::info!("running database migrations");
131
132 // 连接池指向目标库,但目标库可能尚不存在(全新部署)。
133 // 先连 postgres 维护库确保目标库存在,复用启动超时窗口应对 DB 起得慢。
134 if let Err(e) = db::pool::ensure_database().await {
135 tracing::error!("failed to ensure target database exists: {e}");
136 eprintln!("ERROR: failed to ensure target database exists: {e}");
137 eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
138 std::process::exit(1);
139 }
140
141 // 启动期用长重试窗口拿连接:DB 可能还在初始化(docker-compose 无 healthcheck、
142 // 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。
143 let mut conn = match db::pool::get_conn_for_startup().await {
144 Ok(conn) => conn,
145 Err(e) => {
146 let secs = crate::utils::server::parse_migrate_startup_timeout();
147 tracing::error!("could not connect to database within {secs}s startup window: {e}");
148 eprintln!("ERROR: could not connect to database within {secs}s startup window: {e}");
149 eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
150 eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
151 std::process::exit(1);
152 }
153 };
154
155 // 连接拿到后再执行迁移主体(咨询锁 + 建表 + 应用迁移)。
156 if let Err(e) = db::migrate::run_on_conn(&mut conn).await {
157 tracing::error!("database migration failed: {e}");
158 eprintln!("ERROR: database migration failed: {e}");
159 eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
160 std::process::exit(1);
161 }
162
163 // BACKUP_* 环境变量播种自动备份初始配置(仅键缺失时生效,之后以面板为准)。
164 // 播种失败不阻断启动——面板默认值仍可用。
165 if let Err(e) = crate::api::settings::seed_backup_settings_from_env(&conn).await {
166 tracing::warn!("backup settings env seeding failed: {e:?}");
167 }
168
169 // UPLOAD_CONCURRENCY 环境变量播种素材上传并发数(语义同上)。
170 if let Err(e) = crate::api::settings::seed_upload_settings_from_env(&conn).await {
171 tracing::warn!("upload settings env seeding failed: {e:?}");
172 }
173
174 // 安全配置(APP_BASE_URL / COOKIE_SECURE / TRUSTED_PROXY_COUNT /
175 // MAX_SESSIONS_PER_USER)env 播种,语义同上(仅键缺失时生效)。
176 if let Err(e) =
177 crate::api::settings::seed_security_settings_from_env(&conn).await
178 {
179 tracing::warn!("security settings env seeding failed: {e:?}");
180 }
181
182 // 图片磁盘缓存配置(IMAGE_DISK_CACHE_*)env 播种,语义同上。
183 if let Err(e) =
184 crate::api::settings::seed_image_cache_settings_from_env(&conn).await
185 {
186 tracing::warn!("image cache settings env seeding failed: {e:?}");
187 }
188
189 // ASSET_ORPHAN_PURGE_* 环境变量播种孤儿素材清理配置(语义同上)。
190 if let Err(e) =
191 crate::api::settings::seed_asset_purge_settings_from_env(&conn).await
192 {
193 tracing::warn!("asset purge settings env seeding failed: {e:?}");
194 }
195
196 // 限流配置(RATE_LIMIT_*)env 播种,语义同上(仅键缺失时生效)。
197 // Tier B:播种后立即从 DB 加载到 config::RATE_LIMIT_CFG,rate_limit.rs
198 // 的 LazyLock 在首次请求时从 config 读取——修改 DB 值需重启生效。
199 if let Err(e) =
200 crate::api::settings::seed_rate_limit_from_env(&conn).await
201 {
202 tracing::warn!("rate limit settings env seeding failed: {e:?}");
203 }
204 let rate_limit_cfg = crate::api::settings::load_rate_limit_settings(&conn)
205 .await
206 .unwrap_or_default();
207 crate::config::set_rate_limit(rate_limit_cfg);
208
209 // WebP 配置(WEBP_QUALITY / WEBP_METHOD)env 播种,语义同上。
210 // Tier B:播种后从 DB 加载到 config::WEBP_CFG,infra/webp.rs 的 LazyLock
211 // 首次编码时从 config 读取——修改 DB 值需重启生效。
212 if let Err(e) =
213 crate::api::settings::seed_webp_settings_from_env(&conn).await
214 {
215 tracing::warn!("webp settings env seeding failed: {e:?}");
216 }
217 let webp_cfg = crate::api::settings::load_webp_settings(&conn)
218 .await
219 .unwrap_or_default();
220 crate::config::set_webp(webp_cfg);
221
222 // 图片尺寸限制配置(MAX_IMAGE_DIMENSION / MAX_IMAGE_PIXELS /
223 // IMAGE_DIMENSIONS_CACHE_TTL_SECS)env 播种,语义同上。
224 // Tier B:播种后从 DB 加载到 config::IMAGE_LIMIT_CFG,image.rs 的
225 // LazyLock 首次请求时从 config 读取——修改 DB 值需重启生效。
226 if let Err(e) =
227 crate::api::settings::seed_image_limit_settings_from_env(&conn).await
228 {
229 tracing::warn!("image limit settings env seeding failed: {e:?}");
230 }
231 let image_limit_cfg =
232 crate::api::settings::load_image_limit_settings(&conn)
233 .await
234 .unwrap_or_default();
235 crate::config::set_image_limit(image_limit_cfg);
236
237 // 代码运行器配置(CODE_RUNNER_*)env 播种,语义同上。
238 // Tier B:播种后从 DB 加载到 config::RUNNER_CFG,runner_config.rs 的
239 // LazyLock 首次请求时从 config 读取——修改 DB 值需重启生效。
240 // 注意 DOCKER_SOCKET_PATH 仍是 env-only,不经 settings 表。
241 if let Err(e) =
242 crate::api::settings::seed_runner_settings_from_env(&conn).await
243 {
244 tracing::warn!("runner settings env seeding failed: {e:?}");
245 }
246 let runner_cfg = crate::api::settings::load_runner_settings(&conn)
247 .await
248 .unwrap_or_default();
249 crate::config::set_runner(runner_cfg);
250
251 // ADMIN_* 环境变量同步初始管理员(env 为凭据源:每次启动覆盖密码、
252 // 确保 admin 角色,可免去首次注册)。失败只告警,不阻断启动。
253 if let Err(e) = crate::api::auth::sync_admin_from_env(&conn).await {
254 tracing::warn!("初始管理员 env 同步失败: {e:?}");
255 }
256
257 // 端口预探测:dioxus::server::serve() 内部对
258 // `TcpListener::bind(addr).await...unwrap()`(dioxus-server 0.7.10 launch.rs:143)
259 // 失败会直接 panic(SIGABRT)。这里在交接给 serve() 之前先探测同一地址,
260 // 失败则走统一的 exit(1) 路径,输出可操作的提示,而不是丢一个裸 panic 栈。
261 // 探测用的 listener 立即 drop,由 serve() 重新绑定(同进程内快速 rebind,
262 // 不经过 TIME_WAIT,无窗口问题)。
263 let addr = dioxus::cli_config::fullstack_address_or_localhost();
264 if let Err(e) = tokio::net::TcpListener::bind(addr).await {
265 tracing::error!("无法绑定监听地址 {addr}: {e}");
266 eprintln!("ERROR: 无法绑定监听地址 {addr}: {e}");
267 eprintln!(
268 "HINT: 端口 {} 可能已被占用。用 `lsof -i :{}` 查看占用进程,\
269 或设置 PORT 环境变量换一个端口。",
270 addr.port(),
271 addr.port()
272 );
273 std::process::exit(1);
274 }
275 });
276 // 迁移 runtime 用完即弃,显式 drop 以在 serve() 前释放其线程资源。
277 drop(migrate_rt);
278
279 // 启动 Dioxus 服务端,返回构建好的 Axum Router
280 dioxus::server::serve(|| async move {
281 use axum::http::StatusCode;
282 use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
283 use std::time::Duration;
284 use tower_http::timeout::TimeoutLayer;
285
286 // 启动后台定时任务:IP 信息清理
287 tokio::spawn(async {
288 tasks::ip_purge::run_purge().await;
289 });
290
291 // 启动后台定时任务:过期 session 清理
292 tokio::spawn(async {
293 tasks::session_cleanup::run_cleanup().await;
294 });
295
296 // 启动后台定时任务:回收站自动清理
297 tokio::spawn(async {
298 tasks::post_purge::run_purge().await;
299 });
300
301 // 启动后台定时任务:每天定点自动备份(设置存 DB,面板可改)
302 tokio::spawn(async {
303 tasks::backup::run_scheduler().await;
304 });
305
306 // 启动后台定时任务:图片磁盘缓存清理
307 tokio::spawn(async {
308 tasks::image_cache_cleanup::run_cleanup().await;
309 });
310
311 // 启动后台定时任务:孤儿素材定期清理(设置存 DB,面板可改)
312 tokio::spawn(async {
313 tasks::orphan_asset_purge::run_purge().await;
314 });
315
316 // 启动后台任务:运行日志批量落库(tracing capture → mpsc → logs 表)
317 tokio::spawn(async {
318 tasks::log_writer::run_writer().await;
319 });
320
321 // 启动后台定时任务:运行日志保留策略裁剪(龄期 + 行数上限,设置存 DB)
322 tokio::spawn(async {
323 tasks::log_purge::run_purge().await;
324 });
325
326 // 启动后台采样任务:sysinfo 主机指标(CPU/内存/磁盘),server function 只读快照。
327 tasks::sysinfo_sampler::spawn_sampler();
328
329 // 启动期探测代码运行器就绪度(Docker daemon + runner 镜像),缺失时打印可操作日志。
330 // 不阻塞启动、不 exit——代码运行是可选功能(博客本身不依赖 Docker)。
331 tokio::spawn(async {
332 crate::api::code_runner::readiness::log_runner_readiness().await;
333 });
334
335 // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
336 // 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
337 // Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用
338 // 之前,SSR_CACHE_SECS 仍是唯一有效的兜底 TTL——它就是内容写入后
339 // SSR 页面可见滞后的上界。
340 let ssr_cache_secs = std::env::var("SSR_CACHE_SECS")
341 .ok()
342 .and_then(|s| s.parse().ok())
343 .unwrap_or(3600);
344 tracing::info!(
345 ssr_cache_secs,
346 "增量渲染缓存生效(写入后内容可见滞后的上界);\
347 调小可缩短滞后,代价是 SSR 重渲染更频繁"
348 );
349 let config = ServeConfig::builder().incremental(
350 dioxus::server::IncrementalRendererConfig::default()
351 .invalidate_after(std::time::Duration::from_secs(ssr_cache_secs)),
352 );
353
354 // 版本响应头开关:默认开启。设 0/false/no 关闭(注重安全、不想对外暴露版本/commit 时)。
355 // bool 解析约定与 COOKIE_SECURE 一致(matches "1"/"true"/"yes");这里取反为
356 // "非 false 值即开",使默认行为(unwrap_or(true))对应「暴露」。
357 let expose_version_headers = std::env::var("EXPOSE_VERSION_HEADERS")
358 .ok()
359 .map(|v| !matches!(v.as_str(), "0" | "false" | "no"))
360 .unwrap_or(true);
361 tracing::info!(
362 expose_version_headers,
363 "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git / X-Yggdrasil-Hash)"
364 );
365
366 // 自定义 API 路由:图片上传(大文件,需要更长超时)
367 // CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
368 // /api/comments/upload 与 /api/upload 共用同一组约束(10MiB / 300s / CSRF):
369 // 前者允许匿名(评论区传图,handler 内做 IP 双层限流),后者要求 admin 会话。
370 let upload_route = axum::Router::new()
371 .route(
372 "/api/upload",
373 axum::routing::post(crate::api::upload::upload_image),
374 )
375 .route(
376 "/api/comments/upload",
377 axum::routing::post(crate::api::upload::comment_upload_image),
378 )
379 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
380 .layer(TimeoutLayer::with_status_code(
381 StatusCode::REQUEST_TIMEOUT,
382 Duration::from_secs(300),
383 ))
384 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
385
386 // MCP bearer 上传端点(带外二进制传输):bearer 鉴权在 handler 内部完成,
387 // 不挂 CSRF(bearer 在请求头,浏览器不自动附带,无 CSRF 风险)。
388 // 10MiB body + 300s 超时与 web 上传一致;二进制不经 JSON-RPC,绕开
389 // rmcp 的 4MiB 请求体上限。token-keyed 限流在 handler 内 check。
390 let mcp_upload_route = axum::Router::new()
391 .route(
392 "/api/mcp/upload",
393 axum::routing::post(crate::api::upload::mcp_upload_image),
394 )
395 .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
396 .layer(TimeoutLayer::with_status_code(
397 StatusCode::REQUEST_TIMEOUT,
398 Duration::from_secs(300),
399 ));
400
401 // 备份导入:大文件 multipart 流式落盘。body 上限与流式计数共用
402 // import_max_bytes()(BACKUP_IMPORT_MAX_MB,默认 512MB)+ 框架宽限;
403 // 600s 超时覆盖慢网上行。CSRF 最外层先拦截非法来源。
404 // 注意:生产反代(nginx client_max_body_size,现 12m)需同步放大,
405 // 否则请求在到达应用前就被反代 413。
406 let backup_import_max = crate::api::database::backup::import_max_bytes();
407 tracing::info!(
408 max_mb = backup_import_max / 1024 / 1024,
409 "备份导入单文件上限生效(BACKUP_IMPORT_MAX_MB)"
410 );
411 let backup_import_route = axum::Router::new()
412 .route(
413 "/api/database/backups/import",
414 axum::routing::post(crate::api::database::backup::import_backup),
415 )
416 .layer(axum::extract::DefaultBodyLimit::max(
417 (backup_import_max + crate::api::database::backup::MULTIPART_FRAME_SLACK)
418 as usize,
419 ))
420 .layer(TimeoutLayer::with_status_code(
421 StatusCode::REQUEST_TIMEOUT,
422 Duration::from_secs(600),
423 ))
424 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
425
426 // 数据导出:流式响应,走 GET + query(参数较短)。
427 // 鉴权在 handler 内部从 cookie 校验 admin;CSRF 最外层拦截非法来源。
428 let export_route = axum::Router::new()
429 .route(
430 "/api/database/export",
431 axum::routing::get(crate::api::database::export::export_data),
432 )
433 // 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录)
434 .route(
435 "/api/database/backups/{filename}",
436 axum::routing::get(crate::api::database::backup::download_backup),
437 )
438 .layer(TimeoutLayer::with_status_code(
439 StatusCode::REQUEST_TIMEOUT,
440 Duration::from_secs(120),
441 ))
442 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
443
444 // SSE 流式输出端点:GET /api/exec/stream?task_id=X
445 // 不挂 TimeoutLayer!SSE 是长连接,30s timeout 会杀掉流。
446 // 鉴权 + 限流已在 start_exec_stream server function 完成(校验链),
447 // 此处只校验 task_id 存在;CSRF 对 GET 放行(is_write_method 返回 false)。
448 // CompressionLayer 跳过 text/event-stream(见 compression_layer_from_env 注释),
449 // 但 sse_route 本身不挂 compression,更安全。
450 let sse_route = axum::Router::new()
451 .route(
452 "/api/exec/stream",
453 axum::routing::get(crate::api::code_runner::sse::exec_stream),
454 )
455 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
456
457 // 日志实时流 SSE 端点:GET /api/logs/stream?levels=ERROR,WARN&target=X&q=Y
458 // 与 /api/exec/stream 同理:长连接不挂 TimeoutLayer、不挂压缩;
459 // CSRF 对 GET 放行(is_write_method 返回 false);
460 // admin 鉴权在 handler 内从 cookie 校验(export.rs 模式)。
461 let logs_sse_route = axum::Router::new()
462 .route(
463 "/api/logs/stream",
464 axum::routing::get(crate::api::logs::sse::log_stream),
465 )
466 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
467
468 // Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
469 let dioxus_app =
470 axum::Router::new().serve_dioxus_application(config, router::AppRouter);
471
472 // 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
473 // layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
474 let mut app_routes = dioxus_app
475 .layer(axum::middleware::from_fn(
476 crate::middleware::ssr_generation_middleware,
477 ))
478 .layer(axum::middleware::from_fn(
479 crate::middleware::add_cache_control,
480 ))
481 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
482 if let Some(layer) = crate::middleware::compression_layer_from_env() {
483 app_routes = app_routes.layer(layer);
484 }
485 let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
486 StatusCode::REQUEST_TIMEOUT,
487 Duration::from_secs(30),
488 ));
489 // admin_guard 置于最外层(最后添加 = 最先执行):未登录的 /admin* 请求
490 // 在 CSRF / cache / SSR 渲染之前就被 302 短路,零渲染开销。
491 let app_routes =
492 app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
493
494 // 静态资源路由:图片文件服务。
495 // 注意:`dioxus::server::serve()` 接管了 listener 与 `into_make_service`
496 // 调用,没有机会换成 `into_make_service_with_connect_info::<SocketAddr>()`,
497 // 所以手动 merge 进来的路由(含 static_routes)拿不到 `ConnectInfo` 扩展。
498 // serve_image / upload_image 因此都用 `Option<Extension<ConnectInfo<SocketAddr>>>`
499 // 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
500 // 使限流能拿到真实客户端 IP。
501 let static_routes = axum::Router::new()
502 .route("/healthz", axum::routing::get(crate::api::health::healthz))
503 .route("/readyz", axum::routing::get(crate::api::health::readyz))
504 .route(
505 "/uploads/{*path}",
506 axum::routing::get(crate::api::image::serve_image),
507 )
508 .route(
509 "/uploads",
510 axum::routing::get(|| async { StatusCode::NOT_FOUND }),
511 )
512 .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
513 .route(
514 "/feed.json",
515 axum::routing::get(crate::api::feed::json_feed),
516 );
517
518 let router = upload_route
519 .merge(mcp_upload_route)
520 .merge(backup_import_route)
521 .merge(export_route)
522 .merge(sse_route)
523 .merge(logs_sse_route)
524 .merge(app_routes)
525 .merge(static_routes)
526 .merge(crate::mcp::router::mcp_route());
527
528 // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、
529 // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。
530 let router = if expose_version_headers {
531 router.layer(axum::middleware::from_fn(
532 crate::middleware::version_headers_middleware,
533 ))
534 } else {
535 router
536 };
537
538 Ok(router)
539 });
540 }
541
542 // 非 server feature(通常为 WASM 前端):启动客户端应用
543 #[cfg(not(feature = "server"))]
544 {
545 use router::AppRouter;
546 dioxus::launch(AppRouter);
547 }
548}