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/notes/upload",
254 axum::routing::post(crate::api::notes::attachments::upload),
255 )
256 .route(
257 "/api/upload",
258 axum::routing::post(crate::api::upload::upload_image),
259 )
260 .route(
261 "/api/comments/upload",
262 axum::routing::post(crate::api::upload::comment_upload_image),
263 )
264 .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
265 .layer(TimeoutLayer::with_status_code(
266 StatusCode::REQUEST_TIMEOUT,
267 IMAGE_UPLOAD_TIMEOUT,
268 ))
269 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
270
271 let mcp_upload_route = axum::Router::new()
272 .route(
273 "/api/mcp/upload",
274 axum::routing::post(crate::api::upload::mcp_upload_image),
275 )
276 .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
277 .layer(TimeoutLayer::with_status_code(
278 StatusCode::REQUEST_TIMEOUT,
279 IMAGE_UPLOAD_TIMEOUT,
280 ));
281
282 let backup_import_max = crate::api::database::backup::import_max_bytes();
283 tracing::info!(
284 max_mb = backup_import_max / 1024 / 1024,
285 "备份导入单文件上限生效(BACKUP_IMPORT_MAX_MB)"
286 );
287 let backup_import_limit = backup_import_max
288 .saturating_add(crate::api::database::backup::MULTIPART_FRAME_SLACK)
289 .min(usize::MAX as u64) as usize;
290 let backup_import_route = axum::Router::new()
291 .route(
292 "/api/database/backups/import",
293 axum::routing::post(crate::api::database::backup::import_backup),
294 )
295 .layer(axum::extract::DefaultBodyLimit::max(backup_import_limit))
296 .layer(TimeoutLayer::with_status_code(
297 StatusCode::REQUEST_TIMEOUT,
298 BACKUP_IMPORT_TIMEOUT,
299 ))
300 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
301
302 let export_route = axum::Router::new()
303 .route(
304 "/api/database/export",
305 axum::routing::get(crate::api::database::export::export_data),
306 )
307 .route(
308 "/api/database/backups/{filename}",
309 axum::routing::get(crate::api::database::backup::download_backup),
310 )
311 .layer(TimeoutLayer::with_status_code(
312 StatusCode::REQUEST_TIMEOUT,
313 EXPORT_TIMEOUT,
314 ))
315 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
316
317 let sse_route = axum::Router::new()
318 .route(
319 "/api/exec/stream",
320 axum::routing::get(crate::api::code_runner::sse::exec_stream),
321 )
322 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
323
324 let logs_sse_route = axum::Router::new()
325 .route(
326 "/api/logs/stream",
327 axum::routing::get(crate::api::logs::sse::log_stream),
328 )
329 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
330
331 let notes_routes = axum::Router::new()
333 .route("/notes", axum::routing::get(dioxus::server::render_handler))
334 .route(
335 "/notes/{*path}",
336 axum::routing::get(dioxus::server::render_handler),
337 )
338 .with_state(dioxus::server::FullstackState::new(
339 ServeConfig::new(),
340 crate::router::AppRouter,
341 ));
342 let dioxus_app = axum::Router::new()
343 .serve_dioxus_application(config, crate::router::AppRouter)
344 .merge(notes_routes);
345
346 let mut app_routes = dioxus_app
347 .layer(axum::middleware::from_fn(
348 crate::middleware::ssr_generation_middleware,
349 ))
350 .layer(axum::middleware::from_fn(
351 crate::middleware::add_cache_control,
352 ))
353 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
354 if let Some(layer) = crate::middleware::compression_layer_from_env() {
355 app_routes = app_routes.layer(layer);
356 }
357 let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
358 StatusCode::REQUEST_TIMEOUT,
359 APP_REQUEST_TIMEOUT,
360 ));
361 let app_routes = app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
362
363 let static_routes = axum::Router::new()
364 .route(
365 "/note-media/{id}",
366 axum::routing::get(crate::api::notes::attachments::read),
367 )
368 .route(
370 "/tags",
371 axum::routing::get(|| async { axum::response::Redirect::permanent("/archives") }),
372 )
373 .route("/healthz", axum::routing::get(crate::api::health::healthz))
374 .route("/readyz", axum::routing::get(crate::api::health::readyz))
375 .route(
376 "/uploads/{*path}",
377 axum::routing::get(crate::api::image::serve_image),
378 )
379 .route(
380 "/uploads",
381 axum::routing::get(|| async { StatusCode::NOT_FOUND }),
382 )
383 .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
384 .route(
385 "/feed.json",
386 axum::routing::get(crate::api::feed::json_feed),
387 );
388
389 let router = upload_route
390 .merge(mcp_upload_route)
391 .merge(backup_import_route)
392 .merge(export_route)
393 .merge(sse_route)
394 .merge(logs_sse_route)
395 .merge(app_routes)
396 .merge(static_routes)
397 .merge(crate::mcp::router::mcp_route());
398
399 if options.expose_version_headers {
400 router.layer(axum::middleware::from_fn(
401 crate::middleware::version_headers_middleware,
402 ))
403 } else {
404 router
405 }
406}