diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 74c1c43..7a948a0 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -23,15 +23,22 @@ use axum::extract::ConnectInfo; use axum::http::{HeaderValue, Request, StatusCode}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; +use axum::routing::get; use dashmap::DashMap; use fc_common::config::ServerConfig; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; +use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; +use axum::body::Body; +use axum::http::header; + use crate::auth_middleware::{extract_session, require_api_key}; use crate::state::AppState; +static STYLE_CSS: &str = include_str!("../../static/style.css"); + struct RateLimitState { requests: DashMap>, _rps: u64, @@ -57,8 +64,8 @@ async fn rate_limit_middleware( .unwrap_or_default() .as_secs(); let last = rl.last_cleanup.load(std::sync::atomic::Ordering::Relaxed); - if now_secs - last > 60 { - if rl + if now_secs - last > 60 + && rl .last_cleanup .compare_exchange( last, @@ -73,7 +80,6 @@ async fn rate_limit_middleware( !v.is_empty() }); } - } let mut entry = rl.requests.entry(ip).or_default(); entry.retain(|t| now.duration_since(*t) < window); @@ -89,6 +95,15 @@ async fn rate_limit_middleware( next.run(request).await } +async fn serve_style_css() -> Response { + Response::builder() + .header(header::CONTENT_TYPE, "text/css") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from(STYLE_CSS)) + .unwrap() + .into_response() +} + pub fn router(state: AppState, config: &ServerConfig) -> Router { let cors_layer = if config.cors_permissive { CorsLayer::permissive() @@ -104,6 +119,8 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router { }; let mut app = Router::new() + // Static assets + .route("/static/style.css", get(serve_style_css)) // Dashboard routes with session extraction middleware .merge( dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state( @@ -137,7 +154,20 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router { ) .layer(TraceLayer::new_for_http()) .layer(cors_layer) - .layer(RequestBodyLimitLayer::new(config.max_body_size)); + .layer(RequestBodyLimitLayer::new(config.max_body_size)) + // Security headers + .layer(SetResponseHeaderLayer::overriding( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + )); // Add rate limiting if configured if let (Some(rps), Some(burst)) = (config.rate_limit_rps, config.rate_limit_burst) { diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index 034fc21..45584b4 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -2,8 +2,9 @@ use axum::{ Json, Router, extract::{Path, Query, State}, http::Extensions, - routing::get, + routing::{get, post}, }; +use fc_common::nix_probe; use fc_common::{ CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project, UpdateProject, Validate, @@ -142,6 +143,8 @@ async fn create_project_jobset( enabled: body.enabled, flake_mode: body.flake_mode, check_interval: body.check_interval, + branch: None, + scheduling_shares: None, }; input .validate() @@ -152,9 +155,99 @@ async fn create_project_jobset( Ok(Json(jobset)) } +#[derive(Debug, Deserialize)] +struct ProbeRequest { + repository_url: String, + revision: Option, +} + +async fn probe_repository( + _extensions: Extensions, + Json(body): Json, +) -> Result, ApiError> { + let result = nix_probe::probe_flake(&body.repository_url, body.revision.as_deref()) + .await + .map_err(ApiError)?; + Ok(Json(result)) +} + +#[derive(Debug, Deserialize)] +struct SetupJobsetInput { + name: String, + nix_expression: String, + #[allow(dead_code)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct SetupProjectRequest { + repository_url: String, + name: String, + description: Option, + jobsets: Vec, +} + +#[derive(serde::Serialize)] +struct SetupProjectResponse { + project: Project, + jobsets: Vec, +} + +async fn setup_project( + extensions: Extensions, + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| { + ApiError(if s == axum::http::StatusCode::FORBIDDEN { + fc_common::CiError::Forbidden("Insufficient permissions".to_string()) + } else { + fc_common::CiError::Unauthorized("Authentication required".to_string()) + }) + })?; + + let create_project = CreateProject { + name: body.name, + repository_url: body.repository_url, + description: body.description, + }; + create_project + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + + let project = fc_common::repo::projects::create(&state.pool, create_project) + .await + .map_err(ApiError)?; + + let mut jobsets = Vec::new(); + for js_input in body.jobsets { + let input = CreateJobset { + project_id: project.id, + name: js_input.name, + nix_expression: js_input.nix_expression, + enabled: Some(true), + flake_mode: Some(true), + check_interval: None, + branch: None, + scheduling_shares: None, + }; + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let jobset = fc_common::repo::jobsets::create(&state.pool, input) + .await + .map_err(ApiError)?; + jobsets.push(jobset); + } + + Ok(Json(SetupProjectResponse { project, jobsets })) +} + pub fn router() -> Router { Router::new() .route("/projects", get(list_projects).post(create_project)) + .route("/projects/probe", post(probe_repository)) + .route("/projects/setup", post(setup_project)) .route( "/projects/{id}", get(get_project).put(update_project).delete(delete_project), diff --git a/crates/server/templates/project_setup.html b/crates/server/templates/project_setup.html new file mode 100644 index 0000000..5868c00 --- /dev/null +++ b/crates/server/templates/project_setup.html @@ -0,0 +1,249 @@ +{% extends "base.html" %} +{% block title %}New Project - FC CI{% endblock %} +{% block auth %} +{% if !auth_name.is_empty() %} +{{ auth_name }} +
+{% else %} +Login +{% endif %} +{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +
+

New Project Setup

+ +
+

Step 1: Repository URL

+
+
+ + +
+ +
+
+
+ + + + + + + + + +
+{% endblock %} +{% block scripts %} + +{% endblock %}