pub mod cleanup; pub mod openapi; pub mod search; pub mod spa; use axum::Router; use axum::extract::{Request, State}; use axum::http::StatusCode; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use export::AppState; use export::middleware::AppTraceLayer; use tower_http::limit::RequestBodyLimitLayer; use tower_http::trace::TraceLayer; const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024; pub const SERVICE_KEY_HEADER: &str = "x-gitdata-service-key"; fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } let mut diff = 0u8; for (a, b) in left.iter().zip(right.iter()) { diff |= a ^ b; } diff == 0 } async fn require_service_key( State(expected): State, request: Request, next: Next, ) -> Response { let provided = request .headers() .get(SERVICE_KEY_HEADER) .and_then(|value| value.to_str().ok()) .unwrap_or_default(); if expected.is_empty() || !constant_time_eq(provided.as_bytes(), expected.as_bytes()) { return (StatusCode::UNAUTHORIZED, "invalid service key").into_response(); } next.run(request).await } fn build_cors_layer(web: &config::web::WebConfig) -> tower_http::cors::CorsLayer { use axum::http::{HeaderName, header}; use tower_http::cors::{AllowOrigin, CorsLayer}; if web.cors_allow_origins.is_empty() { return CorsLayer::new(); } let origins: Vec = web .cors_allow_origins .iter() .filter_map(|o| o.parse().ok()) .collect(); CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_credentials(web.cors_allow_credentials) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::PATCH, axum::http::Method::DELETE, axum::http::Method::OPTIONS, ]) .allow_headers([ header::CONTENT_TYPE, header::AUTHORIZATION, HeaderName::from_static("x-request-id"), ]) } pub async fn router(state: AppState) -> Result { let cors = build_cors_layer(&state.config.web); let auth = auth::services::AuthService::new(state.clone()); let agents = agents::services::service::AgentsService::new(state.clone()); let chat = chat::services::service::ChatService::new(state.clone()); let issues = issues::services::service::IssueService::new(state.clone()); let space = space::services::service::SpaceService::new(state.clone()); let user = user::services::service::UserService::new(state.clone()); let git = git::service::GitService::new(state.clone()); let ci = ci::CiService::new(state.clone()); let pulls = pulls::services::service::PullService::new(state.clone()); let search = ::search::SearchService::from_parts(state.database.clone(), state.config.search.clone()) .expect("validated search client configuration"); let import = ::import::ImportService::new(state.clone()); let internal_service_key = state.config.internal.service_key.clone(); let jwt_verifier = if state.config.oauth.enabled { match jwt::JwtVerifier::new(&state.config.oauth) { Ok(verifier) => Some(verifier), Err(error) => { tracing::warn!(%error, "oauth is enabled but the JWT verifier could not be built"); None } } } else { None }; let revalidate_state = state.clone(); let health_state = state; // 清理接口需要跨模块调用(space/issues/agents/git),这里的服务都还没被 // 其他 router 的 with_state 消费掉,先构造再合并。 let cleanup_router = cleanup::router(space.clone(), issues.clone(), agents.clone(), git.clone()); let public: Router = Router::new() .merge(auth::routes::router(auth.clone()).with_state(auth.clone())) .merge(agents::routes::router(agents.clone()).with_state(agents)) .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat)) .merge(issues::routes::router(issues.clone()).with_state(issues)) .merge(space::routes::router(space.clone()).with_state(space)) .merge(user::routes::router(user.clone()).with_state(user)) .merge(git::routes::router(git.clone()).with_state(git.clone())) .merge(pulls::routes::router().with_state(pulls)) .merge(crate::search::router().with_state(search.clone())) .merge(::import::routes::router(import.clone()).with_state(import.clone())) .merge(ci::routes::router().with_state(ci.clone())) .merge(cleanup_router); let internal: Router = Router::new() .merge(git::routes::internal_router(git.clone()).with_state(git)) .merge(crate::search::internal_router().with_state(search)) .merge(::import::routes::internal_router().with_state(import)) .route_layer(axum::middleware::from_fn_with_state( internal_service_key, require_service_key, )); let router: Router = Router::new() .nest("/api/v1", public) .nest("/api/v1/internal", internal) .nest("/pages", auth::routes::pages_router(auth.clone())) .merge(export::health::router(health_state)) .fallback(spa::fallback) .layer(cors) .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES)) .layer( TraceLayer::new_for_http() .make_span_with(export::telemetry::HttpSpan) .on_response(export::telemetry::HttpOnResponse), ) .layer(export::telemetry::metrics_layer()) .layer(AppTraceLayer::new()); let router = match jwt_verifier { Some(verifier) => { let warm = verifier.clone(); tokio::spawn(async move { warm.warm_up().await }); router .layer(axum::Extension(verifier)) .layer(axum::middleware::from_fn_with_state( revalidate_state, auth::oauth::revalidate::revalidate, )) } None => router, }; Ok(router.merge(ci::actions_runtime::router().with_state(ci))) }