1use 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
26pub fn run() {
28 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 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 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 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 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 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 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(
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}