crates/server: add project setup endpoint and update routes

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9087c90a3b81cfa6f148a8d0131e87796a6a6964
This commit is contained in:
raf 2026-02-02 01:29:24 +03:00
commit 0782d891f1
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 377 additions and 5 deletions

View file

@ -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<IpAddr, Vec<Instant>>,
_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) {

View file

@ -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<String>,
}
async fn probe_repository(
_extensions: Extensions,
Json(body): Json<ProbeRequest>,
) -> Result<Json<nix_probe::FlakeProbeResult>, 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<String>,
}
#[derive(Debug, Deserialize)]
struct SetupProjectRequest {
repository_url: String,
name: String,
description: Option<String>,
jobsets: Vec<SetupJobsetInput>,
}
#[derive(serde::Serialize)]
struct SetupProjectResponse {
project: Project,
jobsets: Vec<Jobset>,
}
async fn setup_project(
extensions: Extensions,
State(state): State<AppState>,
Json(body): Json<SetupProjectRequest>,
) -> Result<Json<SetupProjectResponse>, 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<AppState> {
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),