Skip to main content

yggdrasil/
startup.rs

1//! 服务端启动流程与 Axum 路由组装。
2//!
3//! `main.rs` 只保留目标选择与全局分配器;本模块负责服务端启动期的
4//! 配置、数据库自举、后台任务生命周期和路由组合。Dioxus 开发态会在
5//! 热重载时多次调用 router callback,因此后台任务启动必须具备进程级幂等性。
6
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use dioxus::server::axum;
11
12const IMAGE_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
13const IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_secs(300);
14const BACKUP_IMPORT_TIMEOUT: Duration = Duration::from_secs(600);
15const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
16const APP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
17
18static BACKGROUND_TASKS_STARTED: OnceLock<()> = OnceLock::new();
19
20#[derive(Clone, Copy)]
21struct ServerOptions {
22    ssr_cache_secs: u64,
23    expose_version_headers: bool,
24}
25
26/// 服务端入口。
27pub fn run() {
28    // 加载 .env 环境变量;不存在 .env 是正常部署场景。
29    dotenvy::dotenv().ok();
30    init_tracing();
31    crate::build_info::log_build_info();
32    validate_required_configuration();
33    crate::api::csrf::warn_if_app_base_url_unset();
34
35    run_database_bootstrap();
36
37    // 数据迁移已切换持久化公式 HTML;每个实例都清除旧渲染缓存,避免另一实例
38    // 已提交迁移或上次启动在提交后中断时,仍向新版 KaTeX CSS 发送旧 class。
39    crate::ssr_cache::invalidate_ssr_all_public();
40    crate::ssr_cache::invalidate_ssr_route("/admin/preview");
41
42    let options = ServerOptions {
43        ssr_cache_secs: crate::utils::server::parse_ssr_cache_secs(),
44        expose_version_headers: crate::utils::server::parse_env_bool(
45            "EXPOSE_VERSION_HEADERS",
46            true,
47        ),
48    };
49    tracing::info!(
50        ssr_cache_secs = options.ssr_cache_secs,
51        "增量渲染缓存生效(写入后内容可见滞后的上界);调小可缩短滞后,代价是 SSR 重渲染更频繁"
52    );
53    tracing::info!(
54        expose_version_headers = options.expose_version_headers,
55        "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git / X-Yggdrasil-Hash)"
56    );
57
58    serve_application(options);
59}
60
61fn init_tracing() {
62    use tracing_subscriber::prelude::*;
63
64    // capture 层在迁移 runtime 之前安装:启动期日志先进 mpsc 缓冲,
65    // log_writer 启动后批量补写落库。
66    let fmt_filter = tracing_subscriber::EnvFilter::try_from_default_env()
67        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
68    let fmt_layer = tracing_subscriber::fmt::layer().with_filter(fmt_filter);
69    let capture_layer = crate::api::logs::capture::CaptureLayer
70        .with_filter(crate::api::logs::capture::log_viewer_filter());
71    tracing_subscriber::registry()
72        .with(fmt_layer)
73        .with(capture_layer)
74        .init();
75}
76
77fn validate_required_configuration() {
78    if std::env::var("DATABASE_URL").is_err() {
79        tracing::error!(
80            "DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported."
81        );
82        eprintln!("ERROR: DATABASE_URL environment variable not set");
83        eprintln!(
84            "HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
85        );
86        std::process::exit(1);
87    }
88
89    // 必须在任何 DB_POOL.get() 调用之前执行,避免配置错误落入 LazyLock
90    // 内的不可达 panic 路径。
91    if let Err(error) = crate::db::pool::validate_database_url() {
92        tracing::error!(%error);
93        eprintln!("ERROR: {error}");
94        if error.starts_with("DB_POOL_SIZE") {
95            eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
96        } else {
97            eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
98        }
99        std::process::exit(1);
100    }
101}
102
103fn run_database_bootstrap() {
104    // 启动期工作主要是单连接 I/O;Argon2 等 CPU 工作由 spawn_blocking 执行,
105    // 因此不需要为这个一次性阶段创建 Tokio worker pool。
106    let migrate_rt = match tokio::runtime::Builder::new_current_thread()
107        .enable_all()
108        .build()
109    {
110        Ok(runtime) => runtime,
111        Err(error) => {
112            tracing::error!(%error, "failed to build migration runtime");
113            eprintln!("ERROR: failed to build migration runtime: {error}");
114            std::process::exit(1);
115        }
116    };
117
118    migrate_rt.block_on(async {
119        tracing::info!("running database migrations");
120
121        if let Err(error) = crate::db::pool::ensure_database().await {
122            tracing::error!("failed to ensure target database exists: {error}");
123            eprintln!("ERROR: failed to ensure target database exists: {error}");
124            eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
125            std::process::exit(1);
126        }
127
128        let mut conn = match crate::db::pool::get_conn_for_startup().await {
129            Ok(conn) => conn,
130            Err(error) => {
131                let secs = crate::utils::server::parse_migrate_startup_timeout();
132                tracing::error!(%error, "could not connect to database within {secs}s startup window");
133                eprintln!("ERROR: could not connect to database within {secs}s startup window: {error}");
134                eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
135                eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
136                std::process::exit(1);
137            }
138        };
139
140        if let Err(error) = crate::db::migrate::run_on_conn(&mut conn).await {
141            tracing::error!("database migration failed: {error}");
142            eprintln!("ERROR: database migration failed: {error}");
143            eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
144            std::process::exit(1);
145        }
146
147        if let Err(error) = crate::api::settings::bootstrap_startup_settings(&conn).await {
148            tracing::error!(error = ?error, "critical startup settings failed to load");
149            eprintln!("ERROR: critical startup settings failed to load: {error:?}");
150            eprintln!("HINT: verify the settings table and PostgreSQL health; the server will not start with unknown security limits.");
151            std::process::exit(1);
152        }
153
154        // ADMIN_* 是启动凭据源;同步失败仍不阻止博客本身启动。
155        if let Err(error) = crate::api::auth::sync_admin_from_env(&conn).await {
156            tracing::warn!(error = ?error, "初始管理员 env 同步失败");
157        }
158
159    });
160
161    drop(migrate_rt);
162}
163
164fn serve_application(options: ServerOptions) -> ! {
165    #[cfg(debug_assertions)]
166    {
167        // 开发态继续使用 Dioxus serve 以保留 devtools/hot reload。这里只做
168        // best-effort 预检;真实 listener 仍由 Dioxus 绑定。
169        let addr = dioxus::cli_config::fullstack_address_or_localhost();
170        if let Err(error) = std::net::TcpListener::bind(addr) {
171            fatal_bind_error(addr, error);
172        }
173        dioxus::server::serve(move || async move { Ok(build_router(options)) });
174    }
175
176    #[cfg(not(debug_assertions))]
177    {
178        let runtime = match tokio::runtime::Builder::new_multi_thread()
179            .enable_all()
180            .build()
181        {
182            Ok(runtime) => runtime,
183            Err(error) => {
184                tracing::error!(%error, "failed to build server runtime");
185                eprintln!("ERROR: failed to build server runtime: {error}");
186                std::process::exit(1);
187            }
188        };
189
190        let result = runtime.block_on(async move {
191            let addr = dioxus::cli_config::fullstack_address_or_localhost();
192            let listener = match tokio::net::TcpListener::bind(addr).await {
193                Ok(listener) => listener,
194                Err(error) => fatal_bind_error(addr, error),
195            };
196            tracing::info!("server listening on {addr}");
197            axum::serve(listener, build_router(options).into_make_service()).await
198        });
199
200        match result {
201            Ok(()) => std::process::exit(0),
202            Err(error) => {
203                tracing::error!(%error, "server stopped unexpectedly");
204                eprintln!("ERROR: server stopped unexpectedly: {error}");
205                std::process::exit(1);
206            }
207        }
208    }
209}
210
211fn fatal_bind_error(addr: std::net::SocketAddr, error: impl std::fmt::Display) -> ! {
212    tracing::error!(%error, "无法绑定监听地址 {addr}");
213    eprintln!("ERROR: 无法绑定监听地址 {addr}: {error}");
214    eprintln!(
215        "HINT: 端口 {} 可能已被占用。用 `lsof -i :{}` 查看占用进程,或设置 PORT 环境变量换一个端口。",
216        addr.port(),
217        addr.port()
218    );
219    std::process::exit(1);
220}
221
222fn spawn_background_tasks_once() {
223    if BACKGROUND_TASKS_STARTED.set(()).is_err() {
224        return;
225    }
226
227    tokio::spawn(crate::tasks::ip_purge::run_purge());
228    tokio::spawn(crate::tasks::session_cleanup::run_cleanup());
229    tokio::spawn(crate::tasks::post_purge::run_purge());
230    tokio::spawn(crate::tasks::backup::run_scheduler());
231    tokio::spawn(crate::tasks::image_cache_cleanup::run_cleanup());
232    tokio::spawn(crate::tasks::orphan_asset_purge::run_purge());
233    tokio::spawn(crate::tasks::log_writer::run_writer());
234    tokio::spawn(crate::tasks::log_purge::run_purge());
235    crate::tasks::sysinfo_sampler::spawn_sampler();
236    tokio::spawn(crate::api::code_runner::readiness::log_runner_readiness());
237}
238
239fn build_router(options: ServerOptions) -> axum::Router {
240    use axum::http::StatusCode;
241    use dioxus::server::{DioxusRouterExt, ServeConfig};
242    use tower_http::timeout::TimeoutLayer;
243
244    spawn_background_tasks_once();
245
246    let config = ServeConfig::builder().incremental(
247        dioxus::server::IncrementalRendererConfig::default()
248            .invalidate_after(Duration::from_secs(options.ssr_cache_secs)),
249    );
250
251    let upload_route = axum::Router::new()
252        .route(
253            "/api/upload",
254            axum::routing::post(crate::api::upload::upload_image),
255        )
256        .route(
257            "/api/comments/upload",
258            axum::routing::post(crate::api::upload::comment_upload_image),
259        )
260        .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
261        .layer(TimeoutLayer::with_status_code(
262            StatusCode::REQUEST_TIMEOUT,
263            IMAGE_UPLOAD_TIMEOUT,
264        ))
265        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
266
267    let mcp_upload_route = axum::Router::new()
268        .route(
269            "/api/mcp/upload",
270            axum::routing::post(crate::api::upload::mcp_upload_image),
271        )
272        .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
273        .layer(TimeoutLayer::with_status_code(
274            StatusCode::REQUEST_TIMEOUT,
275            IMAGE_UPLOAD_TIMEOUT,
276        ));
277
278    let backup_import_max = crate::api::database::backup::import_max_bytes();
279    tracing::info!(
280        max_mb = backup_import_max / 1024 / 1024,
281        "备份导入单文件上限生效(BACKUP_IMPORT_MAX_MB)"
282    );
283    let backup_import_limit = backup_import_max
284        .saturating_add(crate::api::database::backup::MULTIPART_FRAME_SLACK)
285        .min(usize::MAX as u64) as usize;
286    let backup_import_route = axum::Router::new()
287        .route(
288            "/api/database/backups/import",
289            axum::routing::post(crate::api::database::backup::import_backup),
290        )
291        .layer(axum::extract::DefaultBodyLimit::max(backup_import_limit))
292        .layer(TimeoutLayer::with_status_code(
293            StatusCode::REQUEST_TIMEOUT,
294            BACKUP_IMPORT_TIMEOUT,
295        ))
296        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
297
298    let export_route = axum::Router::new()
299        .route(
300            "/api/database/export",
301            axum::routing::get(crate::api::database::export::export_data),
302        )
303        .route(
304            "/api/database/backups/{filename}",
305            axum::routing::get(crate::api::database::backup::download_backup),
306        )
307        .layer(TimeoutLayer::with_status_code(
308            StatusCode::REQUEST_TIMEOUT,
309            EXPORT_TIMEOUT,
310        ))
311        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
312
313    let sse_route = axum::Router::new()
314        .route(
315            "/api/exec/stream",
316            axum::routing::get(crate::api::code_runner::sse::exec_stream),
317        )
318        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
319
320    let logs_sse_route = axum::Router::new()
321        .route(
322            "/api/logs/stream",
323            axum::routing::get(crate::api::logs::sse::log_stream),
324        )
325        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
326
327    let dioxus_app = axum::Router::new().serve_dioxus_application(config, crate::router::AppRouter);
328
329    let mut app_routes = dioxus_app
330        .layer(axum::middleware::from_fn(
331            crate::middleware::ssr_generation_middleware,
332        ))
333        .layer(axum::middleware::from_fn(
334            crate::middleware::add_cache_control,
335        ))
336        .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
337    if let Some(layer) = crate::middleware::compression_layer_from_env() {
338        app_routes = app_routes.layer(layer);
339    }
340    let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
341        StatusCode::REQUEST_TIMEOUT,
342        APP_REQUEST_TIMEOUT,
343    ));
344    let app_routes = app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
345
346    let static_routes = axum::Router::new()
347        // 旧收藏链接直接返回永久重定向,客户端导航由 Route 的 redirect 同步兼容。
348        .route(
349            "/tags",
350            axum::routing::get(|| async { axum::response::Redirect::permanent("/archives") }),
351        )
352        .route("/healthz", axum::routing::get(crate::api::health::healthz))
353        .route("/readyz", axum::routing::get(crate::api::health::readyz))
354        .route(
355            "/uploads/{*path}",
356            axum::routing::get(crate::api::image::serve_image),
357        )
358        .route(
359            "/uploads",
360            axum::routing::get(|| async { StatusCode::NOT_FOUND }),
361        )
362        .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
363        .route(
364            "/feed.json",
365            axum::routing::get(crate::api::feed::json_feed),
366        );
367
368    let router = upload_route
369        .merge(mcp_upload_route)
370        .merge(backup_import_route)
371        .merge(export_route)
372        .merge(sse_route)
373        .merge(logs_sse_route)
374        .merge(app_routes)
375        .merge(static_routes)
376        .merge(crate::mcp::router::mcp_route());
377
378    if options.expose_version_headers {
379        router.layer(axum::middleware::from_fn(
380            crate::middleware::version_headers_middleware,
381        ))
382    } else {
383        router
384    }
385}