Skip to main content

yggdrasil/
startup.rs

1//! 服务端启动流程与 Axum 路由组装。
2//!
3//! `main.rs` 只保留全局分配器与入口调用;本模块负责服务端启动期的
4//! 配置、数据库自举、后台任务生命周期和路由组合。路由装配可能被重复调用
5//! (例如测试),因此后台任务启动必须具备进程级幂等性。
6
7use std::future::IntoFuture;
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10use std::time::Duration;
11
12use axum::extract::{Request, State};
13use axum::response::{IntoResponse, Response};
14use axum::serve::ListenerExt;
15use leptos::config::{Env, LeptosOptions};
16use leptos_axum::LeptosRoutes;
17
18const IMAGE_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
19const IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_secs(300);
20const BACKUP_IMPORT_TIMEOUT: Duration = Duration::from_secs(600);
21const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
22const APP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
23/// 收到退出信号后等待在途请求结束的上限;须小于容器停止超时(podman 默认 10 秒),
24/// 否则仍会被 SIGKILL。
25const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
26
27static BACKGROUND_TASKS_STARTED: OnceLock<()> = OnceLock::new();
28
29#[derive(Clone, Copy)]
30struct ServerOptions {
31    ssr_cache_secs: u64,
32    expose_version_headers: bool,
33}
34
35/// 服务端入口。
36pub fn run() {
37    // 加载 .env 环境变量;不存在 .env 是正常部署场景。
38    dotenvy::dotenv().ok();
39    init_tracing();
40    crate::build_info::log_build_info();
41    validate_required_configuration();
42    crate::api::csrf::warn_if_app_base_url_unset();
43
44    run_database_bootstrap();
45
46    // 启动与开发热重载时清除进程内 HTML 快照,避免保留旧版本渲染结果。
47    crate::ssr_cache::invalidate_ssr_all_public();
48    crate::ssr_cache::invalidate_ssr_route("/admin/preview");
49
50    let options = ServerOptions {
51        ssr_cache_secs: crate::utils::server::parse_ssr_cache_secs(),
52        expose_version_headers: crate::utils::server::parse_env_bool(
53            "EXPOSE_VERSION_HEADERS",
54            true,
55        ),
56    };
57    tracing::info!(
58        ssr_cache_secs = options.ssr_cache_secs,
59        max_page_bytes = crate::config::ssr_cache().max_page_bytes,
60        "匿名公开页 HTML 缓存:32 MiB 上限、写入通知失效、TTL 兜底"
61    );
62    let edge_ttl_secs = crate::config::ssr_cache().edge_ttl_secs;
63    let edge_purge = crate::tasks::edge_purge::is_configured();
64    if edge_ttl_secs > 0 && !edge_purge {
65        tracing::warn!(
66            edge_ttl_secs,
67            "公开页 HTML 允许 CDN 缓存,但未配置 EdgeOne 清缓存凭据:内容变更要等 s-maxage 到期才在边缘生效"
68        );
69    } else {
70        tracing::info!(edge_ttl_secs, edge_purge, "公开页 HTML 边缘缓存");
71    }
72    tracing::info!(
73        expose_version_headers = options.expose_version_headers,
74        "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git / X-Yggdrasil-Hash)"
75    );
76
77    serve_application(options);
78}
79
80fn init_tracing() {
81    use tracing_subscriber::prelude::*;
82
83    // capture 层在迁移 runtime 之前安装:启动期日志先进 mpsc 缓冲,
84    // log_writer 启动后批量补写落库。
85    let fmt_filter = tracing_subscriber::EnvFilter::try_from_default_env()
86        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
87    let fmt_layer = tracing_subscriber::fmt::layer().with_filter(fmt_filter);
88    let capture_layer = crate::api::logs::capture::CaptureLayer
89        .with_filter(crate::api::logs::capture::log_viewer_filter());
90    tracing_subscriber::registry()
91        .with(fmt_layer)
92        .with(capture_layer)
93        .init();
94}
95
96fn validate_required_configuration() {
97    if std::env::var("DATABASE_URL").is_err() {
98        tracing::error!(
99            "DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported."
100        );
101        eprintln!("ERROR: DATABASE_URL environment variable not set");
102        eprintln!(
103            "HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
104        );
105        std::process::exit(1);
106    }
107
108    // 必须在任何 DB_POOL.get() 调用之前执行,避免配置错误落入 LazyLock
109    // 内的不可达 panic 路径。
110    if let Err(error) = crate::db::pool::validate_database_url() {
111        tracing::error!(%error);
112        eprintln!("ERROR: {error}");
113        if error.starts_with("DB_POOL_SIZE") {
114            eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
115        } else {
116            eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
117        }
118        std::process::exit(1);
119    }
120}
121
122fn run_database_bootstrap() {
123    // 启动期工作主要是单连接 I/O;Argon2 等 CPU 工作由 spawn_blocking 执行,
124    // 因此不需要为这个一次性阶段创建 Tokio worker pool。
125    let migrate_rt = match tokio::runtime::Builder::new_current_thread()
126        .enable_all()
127        .build()
128    {
129        Ok(runtime) => runtime,
130        Err(error) => {
131            tracing::error!(%error, "failed to build migration runtime");
132            eprintln!("ERROR: failed to build migration runtime: {error}");
133            std::process::exit(1);
134        }
135    };
136
137    migrate_rt.block_on(async {
138        tracing::info!("running database migrations");
139
140        if let Err(error) = crate::db::pool::ensure_database().await {
141            tracing::error!("failed to ensure target database exists: {error}");
142            eprintln!("ERROR: failed to ensure target database exists: {error}");
143            eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
144            std::process::exit(1);
145        }
146
147        let mut conn = match crate::db::pool::get_conn_for_startup().await {
148            Ok(conn) => conn,
149            Err(error) => {
150                let secs = crate::utils::server::parse_migrate_startup_timeout();
151                tracing::error!(%error, "could not connect to database within {secs}s startup window");
152                eprintln!("ERROR: could not connect to database within {secs}s startup window: {error}");
153                eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
154                eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
155                std::process::exit(1);
156            }
157        };
158
159        if let Err(error) = crate::db::migrate::run_on_conn(&mut conn).await {
160            tracing::error!("database migration failed: {error}");
161            eprintln!("ERROR: database migration failed: {error}");
162            eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
163            std::process::exit(1);
164        }
165
166        if let Err(error) = crate::api::settings::bootstrap_startup_settings(&conn).await {
167            tracing::error!(error = ?error, "critical startup settings failed to load");
168            eprintln!("ERROR: critical startup settings failed to load: {error:?}");
169            eprintln!("HINT: verify the settings table and PostgreSQL health; the server will not start with unknown security limits.");
170            std::process::exit(1);
171        }
172
173        // ADMIN_* 是启动凭据源;同步失败仍不阻止博客本身启动。
174        if let Err(error) = crate::api::auth::sync_admin_from_env(&conn).await {
175            tracing::warn!(error = ?error, "初始管理员 env 同步失败");
176        }
177
178    });
179
180    drop(migrate_rt);
181}
182
183fn serve_application(options: ServerOptions) -> ! {
184    let runtime = match tokio::runtime::Builder::new_multi_thread()
185        .enable_all()
186        .build()
187    {
188        Ok(runtime) => runtime,
189        Err(error) => {
190            tracing::error!(%error, "failed to build server runtime");
191            eprintln!("ERROR: failed to build server runtime: {error}");
192            std::process::exit(1);
193        }
194    };
195
196    let result = runtime.block_on(async move {
197        let addr = server_addr();
198        let listener = match tokio::net::TcpListener::bind(addr).await {
199            Ok(listener) => listener,
200            Err(error) => fatal_bind_error(addr, error),
201        };
202        // 关闭 Nagle:hyper 分两次写响应头与包体,开着 Nagle 时第二次写要等对端 ACK,
203        // 而反向代理的 keepalive 连接上对端会延迟 ACK 40 ms,复用连接的每个响应都慢 40 ms。
204        let listener = listener.tap_io(|tcp| {
205            if let Err(error) = tcp.set_nodelay(true) {
206                tracing::debug!(%error, "failed to set TCP_NODELAY");
207            }
208        });
209        // 路由装配会启动后台任务(tokio::spawn),必须在 runtime 内执行。
210        let router = build_router(options);
211        tracing::info!("server listening on {addr}");
212        // 注入 ConnectInfo:上传等手写路由在未配置可信代理时按 TCP 对端地址限流,
213        // 否则全站共享 "unknown" 限流桶。
214        let server = axum::serve(
215            listener,
216            router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
217        )
218        .with_graceful_shutdown(shutdown_signal())
219        .into_future();
220        let drain_deadline = async {
221            crate::shutdown::requested().await;
222            tokio::time::sleep(SHUTDOWN_DRAIN_TIMEOUT).await;
223        };
224        tokio::select! {
225            result = server => result,
226            () = drain_deadline => {
227                tracing::warn!(
228                    "在途请求 {} 秒内未结束,放弃等待并退出",
229                    SHUTDOWN_DRAIN_TIMEOUT.as_secs()
230                );
231                Ok(())
232            }
233        }
234    });
235
236    match result {
237        Ok(()) => std::process::exit(0),
238        Err(error) => {
239            tracing::error!(%error, "server stopped unexpectedly");
240            eprintln!("ERROR: server stopped unexpectedly: {error}");
241            std::process::exit(1);
242        }
243    }
244}
245
246/// 等待 Ctrl-C 或 SIGTERM,触发优雅退出。
247async fn shutdown_signal() {
248    let ctrl_c = async {
249        let _ = tokio::signal::ctrl_c().await;
250    };
251    #[cfg(unix)]
252    let terminate = async {
253        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
254            Ok(mut signal) => {
255                signal.recv().await;
256            }
257            Err(_) => std::future::pending::<()>().await,
258        }
259    };
260    #[cfg(not(unix))]
261    let terminate = std::future::pending::<()>();
262
263    tokio::select! {
264        _ = ctrl_c => {},
265        _ = terminate => {},
266    }
267    tracing::info!("收到退出信号,开始优雅关闭");
268    crate::shutdown::trigger();
269}
270
271fn fatal_bind_error(addr: std::net::SocketAddr, error: impl std::fmt::Display) -> ! {
272    tracing::error!(%error, "无法绑定监听地址 {addr}");
273    eprintln!("ERROR: 无法绑定监听地址 {addr}: {error}");
274    eprintln!(
275        "HINT: 端口 {} 可能已被占用。用 `lsof -i :{}` 查看占用进程,或设置 PORT 环境变量换一个端口。",
276        addr.port(),
277        addr.port()
278    );
279    std::process::exit(1);
280}
281
282fn spawn_background_tasks_once() {
283    if BACKGROUND_TASKS_STARTED.set(()).is_err() {
284        return;
285    }
286
287    use crate::tasks::{self, spawn_supervised};
288    spawn_supervised("ip_purge", tasks::ip_purge::run_purge());
289    spawn_supervised("session_cleanup", tasks::session_cleanup::run_cleanup());
290    spawn_supervised("post_purge", tasks::post_purge::run_purge());
291    spawn_supervised("backup", tasks::backup::run_scheduler());
292    spawn_supervised("cache_sync", tasks::cache_sync::run());
293    spawn_supervised("edge_purge", tasks::edge_purge::run());
294    spawn_supervised(
295        "image_cache_cleanup",
296        tasks::image_cache_cleanup::run_cleanup(),
297    );
298    spawn_supervised("orphan_asset_purge", tasks::orphan_asset_purge::run_purge());
299    spawn_supervised("log_writer", tasks::log_writer::run_writer());
300    spawn_supervised("log_purge", tasks::log_purge::run_purge());
301    tasks::sysinfo_sampler::spawn_sampler();
302    tokio::spawn(crate::api::code_runner::readiness::log_runner_readiness());
303}
304
305/// 发布命名空间(plan §6.7):一个 SSR 缓存实例只服务一个发布,
306/// 发布变化即整表失效,杜绝缓存命中跨发布「串味」。release 取
307/// 发布清单(`<site_root>/release-manifest.json`)的 release_id;
308/// dev 无清单时回退构建信息(git describe 含 dirty 标记)。
309fn init_cache_namespace(mode: &str) {
310    let release = std::fs::read_to_string(site_root().join("release-manifest.json"))
311        .ok()
312        .and_then(|json| serde_json::from_str::<serde_json::Value>(&json).ok())
313        .and_then(|value| {
314            value
315                .get("release_id")
316                .and_then(|v| v.as_str())
317                .map(str::to_owned)
318        })
319        .unwrap_or_else(|| crate::build_info::BUILD_INFO.git_describe.to_owned());
320    crate::ssr_cache::set_release_namespace(&format!("{mode}/{release}"));
321}
322
323/// 上一发布资源目录(plan §6.7 滚动发布的旧资源保留窗口)。配置了但不是
324/// 目录时仅告警停用——保留窗口是兜底增强,不能反过来阻止服务启动。
325fn previous_public_dir() -> Option<PathBuf> {
326    let dir = std::env::var_os("YGG_PREVIOUS_PUBLIC_DIR").filter(|v| !v.is_empty())?;
327    let dir = PathBuf::from(dir);
328    if !dir.is_dir() {
329        tracing::warn!(
330            dir = %dir.display(),
331            "YGG_PREVIOUS_PUBLIC_DIR 不是目录,旧发布资源保留窗口停用"
332        );
333        return None;
334    }
335    Some(dir)
336}
337
338/// 站点根目录(`public/` 资源与 `pkg/` 前端产物所在)。
339///
340/// `LEPTOS_SITE_ROOT` 非空则用它(cargo-leptos 在 watch/serve 时自动设置,
341/// Docker 镜像显式设置);否则是可执行文件旁的 `public/`。
342pub(crate) fn site_root() -> PathBuf {
343    match std::env::var_os("LEPTOS_SITE_ROOT").filter(|v| !v.is_empty()) {
344        Some(root) => PathBuf::from(root),
345        None => exe_dir().join("public"),
346    }
347}
348
349/// 可执行文件所在目录;`hash.txt` 与它同目录。
350fn exe_dir() -> PathBuf {
351    let mut dir = std::env::current_exe().unwrap_or_else(|e| {
352        panic!("无法定位可执行文件以推导站点目录:{e}");
353    });
354    dir.pop();
355    dir
356}
357
358/// 监听地址:`IP`(默认 127.0.0.1)+ `PORT`(默认 8080)。解析失败直接退出。
359fn server_addr() -> std::net::SocketAddr {
360    fn env_or(key: &str, default: &str) -> String {
361        std::env::var(key)
362            .ok()
363            .filter(|v| !v.is_empty())
364            .unwrap_or_else(|| default.to_string())
365    }
366
367    let ip = env_or("IP", "127.0.0.1");
368    let port = env_or("PORT", "8080");
369    let ip = ip.parse::<std::net::IpAddr>().unwrap_or_else(|error| {
370        eprintln!("ERROR: IP 环境变量 {ip:?} 不是有效的 IP 地址:{error}");
371        std::process::exit(1);
372    });
373    let port = port.parse::<u16>().unwrap_or_else(|error| {
374        eprintln!("ERROR: PORT 环境变量 {port:?} 不是有效的端口号:{error}");
375        std::process::exit(1);
376    });
377    std::net::SocketAddr::new(ip, port)
378}
379
380/// Leptos 运行配置,与 `Cargo.toml` 的 `[package.metadata.leptos]` 保持一致。
381fn leptos_options() -> LeptosOptions {
382    LeptosOptions::builder()
383        .output_name("yggdrasil")
384        .site_root(site_root().to_string_lossy().into_owned())
385        .site_pkg_dir("pkg")
386        .site_addr(server_addr())
387        .reload_port(3001)
388        .hash_files(true)
389        .env(if cfg!(debug_assertions) {
390            Env::DEV
391        } else {
392            Env::PROD
393        })
394        .build()
395}
396
397/// 校验前端产物与服务端二进制配套:`hash.txt`(与可执行文件同目录)指向的
398/// 脚本、WASM 与分包清单必须在 `<site_root>/pkg/` 下真实存在,缺失即拒绝启动,
399/// 不静默拼出混合版本。debug 与 release 都执行。
400///
401/// 返回入口是否已按内容重命名(hash.txt 的 `entry: content-addressed`,由发布装配写入)。
402fn validate_site_assets(options: &LeptosOptions) -> bool {
403    let hash_path = exe_dir().join("hash.txt");
404    let hashes = std::fs::read_to_string(&hash_path).unwrap_or_else(|_| {
405        panic!(
406            "hash.txt 缺失({}):前端产物与服务端二进制不配套,拒绝启动",
407            hash_path.display()
408        )
409    });
410
411    let (mut js, mut wasm, mut manifest, mut entry_content_addressed) = (None, None, None, false);
412    for line in hashes.lines() {
413        if let Some((key, value)) = line.trim().split_once(':') {
414            let value = value.trim().to_string();
415            match key.trim() {
416                "js" => js = Some(value),
417                "wasm" => wasm = Some(value),
418                "manifest" => manifest = Some(value),
419                "entry" => entry_content_addressed = value == "content-addressed",
420                _ => {}
421            }
422        }
423    }
424
425    let pkg = Path::new(options.site_root.as_ref()).join(options.site_pkg_dir.as_ref());
426    let name = options.output_name.as_ref();
427    let mut missing: Vec<String> = Vec::new();
428    match js {
429        Some(hash) => {
430            let file = pkg.join(format!("{name}.{hash}.js"));
431            if !file.is_file() {
432                missing.push(file.display().to_string());
433            }
434        }
435        None => missing.push(format!("{}(缺少 js 行)", hash_path.display())),
436    }
437    match wasm {
438        Some(hash) => {
439            let file = pkg.join(format!("{name}.{hash}.wasm"));
440            if !file.is_file() {
441                missing.push(file.display().to_string());
442            }
443        }
444        None => missing.push(format!("{}(缺少 wasm 行)", hash_path.display())),
445    }
446    if let Some(hash) = manifest {
447        let file = pkg.join(format!("__wasm_split_manifest.{hash}.json"));
448        if !file.is_file() {
449            missing.push(file.display().to_string());
450        }
451    }
452    if !missing.is_empty() {
453        panic!("前端产物缺失:{missing:?}——前端产物与服务端二进制不配套,拒绝启动");
454    }
455    entry_content_addressed
456}
457
458/// 未匹配任何路由的请求:先查站点目录里的静态文件(带 `.br` 预压缩协商),
459/// 其余交给 Leptos 渲染(未知页面由 `NotFound` 设置 404)。`/pkg/` 下缺失的文件
460/// 直接返回空 404,不渲染页面。
461async fn site_fallback(State(options): State<LeptosOptions>, req: Request) -> Response {
462    use axum::http::{Method, StatusCode};
463    use tower::ServiceExt;
464
465    if req.method() != Method::GET && req.method() != Method::HEAD {
466        return StatusCode::NOT_FOUND.into_response();
467    }
468
469    let mut static_request = axum::http::Request::builder()
470        .method(req.method())
471        .uri(req.uri().clone());
472    for (name, value) in req.headers() {
473        static_request = static_request.header(name, value);
474    }
475    if let Ok(static_request) = static_request.body(axum::body::Body::empty()) {
476        let served = tower_http::services::ServeDir::new(site_root())
477            .append_index_html_on_directories(false)
478            .precompressed_br()
479            .oneshot(static_request)
480            .await;
481        // ServeDir 的错误类型是 Infallible,解构不会失败。
482        let Ok(asset) = served;
483        if asset.status() != StatusCode::NOT_FOUND {
484            return asset.map(axum::body::Body::new);
485        }
486    }
487
488    if req.uri().path().starts_with("/pkg/") {
489        return StatusCode::NOT_FOUND.into_response();
490    }
491
492    leptos_axum::render_app_async(move || crate::app::shell(options.clone()))(req).await
493}
494
495fn build_router(options: ServerOptions) -> axum::Router {
496    use axum::http::StatusCode;
497    use tower_http::timeout::TimeoutLayer;
498
499    let leptos_opts = leptos_options();
500    let entry_content_addressed = validate_site_assets(&leptos_opts);
501    init_cache_namespace("leptos");
502
503    spawn_background_tasks_once();
504
505    // 前台与后台页面由 Leptos 渲染;server fn 随 `leptos_routes` 自动注册到 /api/<名称>。
506    // 未匹配的路径(静态资源、未知页面)交给 `site_fallback`。
507    let routes = leptos_axum::generate_route_list(crate::app::App);
508    let leptos_app = axum::Router::new()
509        .leptos_routes(&leptos_opts, routes, {
510            let leptos_opts = leptos_opts.clone();
511            move || crate::app::shell(leptos_opts.clone())
512        })
513        .fallback(site_fallback)
514        .with_state(leptos_opts.clone());
515
516    // 旧发布资源保留窗口(plan §6.7):滚动更新后,已打开标签页与缓存 HTML
517    // 仍引用上一发布的带 hash 资源(含按路由懒加载的 WASM 分包);当前发布未收录时
518    // 按 404 回退到 YGG_PREVIOUS_PUBLIC_DIR,保留窗口覆盖部署、HTML 缓存、回滚与长开标签页。
519    let leptos_app = match previous_public_dir() {
520        Some(previous_root) => {
521            tracing::info!(
522                dir = %previous_root.display(),
523                "旧发布资源保留窗口已启用(/pkg 缺失时回退)"
524            );
525            leptos_app.layer(axum::middleware::from_fn(
526                move |req: axum::extract::Request, next: axum::middleware::Next| {
527                    crate::middleware::previous_release_assets(previous_root.clone(), req, next)
528                },
529            ))
530        }
531        None => leptos_app,
532    };
533
534    let upload_route = axum::Router::new()
535        .route(
536            "/api/notes/upload",
537            axum::routing::post(crate::api::notes::attachments::upload),
538        )
539        .route(
540            "/api/upload",
541            axum::routing::post(crate::api::upload::upload_image),
542        )
543        .route(
544            "/api/comments/upload",
545            axum::routing::post(crate::api::upload::comment_upload_image),
546        )
547        .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
548        .layer(TimeoutLayer::with_status_code(
549            StatusCode::REQUEST_TIMEOUT,
550            IMAGE_UPLOAD_TIMEOUT,
551        ))
552        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
553
554    let mcp_upload_route = axum::Router::new()
555        .route(
556            "/api/mcp/upload",
557            axum::routing::post(crate::api::upload::mcp_upload_image),
558        )
559        .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
560        .layer(TimeoutLayer::with_status_code(
561            StatusCode::REQUEST_TIMEOUT,
562            IMAGE_UPLOAD_TIMEOUT,
563        ));
564
565    let backup_import_max = crate::api::database::backup::import_max_bytes();
566    tracing::info!(
567        max_mb = backup_import_max / 1024 / 1024,
568        "备份导入单文件上限生效(BACKUP_IMPORT_MAX_MB)"
569    );
570    let backup_import_limit = backup_import_max
571        .saturating_add(crate::api::database::backup::MULTIPART_FRAME_SLACK)
572        .min(usize::MAX as u64) as usize;
573    let backup_import_route = axum::Router::new()
574        .route(
575            "/api/database/backups/import",
576            axum::routing::post(crate::api::database::backup::import_backup),
577        )
578        .layer(axum::extract::DefaultBodyLimit::max(backup_import_limit))
579        .layer(TimeoutLayer::with_status_code(
580            StatusCode::REQUEST_TIMEOUT,
581            BACKUP_IMPORT_TIMEOUT,
582        ))
583        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
584
585    let export_route = axum::Router::new()
586        .route(
587            "/api/database/export",
588            axum::routing::get(crate::api::database::export::export_data).layer(
589                axum::middleware::from_fn(crate::api::csrf::same_origin_read_middleware),
590            ),
591        )
592        .route(
593            "/api/database/backups/{filename}",
594            axum::routing::get(crate::api::database::backup::download_backup),
595        )
596        .layer(TimeoutLayer::with_status_code(
597            StatusCode::REQUEST_TIMEOUT,
598            EXPORT_TIMEOUT,
599        ))
600        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
601
602    let sse_route = axum::Router::new()
603        .route(
604            "/api/exec/stream",
605            axum::routing::get(crate::api::code_runner::sse::exec_stream),
606        )
607        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
608
609    let logs_sse_route = axum::Router::new()
610        .route(
611            "/api/logs/stream",
612            axum::routing::get(crate::api::logs::sse::log_stream),
613        )
614        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
615
616    let mut app_routes = leptos_app
617        .layer(axum::middleware::from_fn(
618            crate::middleware::share_context_middleware,
619        ))
620        .layer(axum::middleware::from_fn(
621            crate::ssr_cache::cache_public_html,
622        ))
623        .layer(axum::middleware::from_fn(
624            crate::middleware::ssr_generation_middleware,
625        ))
626        .layer(axum::middleware::from_fn(
627            move |req: axum::extract::Request, next: axum::middleware::Next| {
628                crate::middleware::add_cache_control(entry_content_addressed, req, next)
629            },
630        ))
631        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
632    let compression = crate::compression::algorithms().map(|algorithms| {
633        axum::middleware::from_fn(
634            move |req: axum::extract::Request, next: axum::middleware::Next| {
635                crate::compression::compress(algorithms, req, next)
636            },
637        )
638    });
639    if let Some(layer) = compression.clone() {
640        app_routes = app_routes.layer(layer);
641    }
642    let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
643        StatusCode::REQUEST_TIMEOUT,
644        APP_REQUEST_TIMEOUT,
645    ));
646    let app_routes = app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
647
648    let static_routes = axum::Router::new()
649        .route(
650            "/note-media/{id}",
651            axum::routing::get(crate::api::notes::attachments::read),
652        )
653        // 旧收藏链接直接返回永久重定向;Leptos 路由表不注册 /tags(同一路径注册两次会让 merge panic)。
654        .route(
655            "/tags",
656            axum::routing::get(|| async { axum::response::Redirect::permanent("/archives") }),
657        )
658        .route(
659            "/og/site.png",
660            axum::routing::get(crate::api::og::site_image),
661        )
662        .route(
663            "/og/post/{file}",
664            axum::routing::get(crate::api::og::post_image),
665        )
666        .route("/healthz", axum::routing::get(crate::api::health::healthz))
667        .route("/readyz", axum::routing::get(crate::api::health::readyz))
668        .route(
669            "/uploads/{*path}",
670            axum::routing::get(crate::api::image::serve_image),
671        )
672        .route(
673            "/uploads",
674            axum::routing::get(|| async { StatusCode::NOT_FOUND }),
675        );
676
677    // Feed 含 20 篇全文(MB 级),反代 gzip_types 也不含其 MIME,必须由压缩层处理。
678    let mut feed_routes = axum::Router::new()
679        .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
680        .route(
681            "/feed.json",
682            axum::routing::get(crate::api::feed::json_feed),
683        );
684    if let Some(layer) = compression {
685        feed_routes = feed_routes.layer(layer);
686    }
687
688    let router = upload_route
689        .merge(mcp_upload_route)
690        .merge(backup_import_route)
691        .merge(export_route)
692        .merge(sse_route)
693        .merge(logs_sse_route)
694        .merge(app_routes)
695        .merge(static_routes)
696        .merge(feed_routes)
697        .merge(crate::mcp::router::mcp_route());
698
699    // 最外层兜底:任何路由或中间件在响应头发出前 panic 都返回 500,而不是断开连接。
700    let router = router.layer(crate::middleware::catch_panic_layer());
701
702    if options.expose_version_headers {
703        router.layer(axum::middleware::from_fn(
704            crate::middleware::version_headers_middleware,
705        ))
706    } else {
707        router
708    }
709}