use crate::api::{
jobs::ScanJob,
models::{
error::{ApiError, ApiErrorResponse},
request::ScanRequest,
response::{ScanResponse, ScanStatus, ScanStatusResponse},
},
state::AppState,
ws::progress::scan_websocket_handler,
};
use crate::security::validate_target;
use axum::{
Json,
extract::{Path, State, WebSocketUpgrade},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use tracing::info;
#[utoipa::path(
post,
path = "/api/v1/scan",
tag = "scans",
request_body = ScanRequest,
responses(
(status = 201, description = "Scan queued successfully", body = ScanResponse),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 503, description = "Queue is full", body = ApiErrorResponse)
),
security(
("api_key" = [])
)
)]
pub async fn create_scan(
State(state): State<Arc<AppState>>,
Json(request): Json<ScanRequest>,
) -> Result<Json<ScanResponse>, ApiError> {
info!("Creating new scan for target: {}", request.target);
if request.target.is_empty() {
return Err(ApiError::BadRequest("Target cannot be empty".to_string()));
}
if request.target.len() > 255 {
return Err(ApiError::BadRequest(
"Target string is too long (max 255 characters)".to_string(),
));
}
let (validated_hostname, validated_port) = validate_target(&request.target, false)
.map_err(|e| ApiError::BadRequest(format!("Invalid target: {}", e)))?;
let final_target = if let Some(port) = validated_port {
format!("{}:{}", validated_hostname, port)
} else {
validated_hostname
};
info!("Validated target: {}", final_target);
state.record_scan().await;
let job = ScanJob::new(final_target.clone(), request.options, request.webhook_url);
let scan_id = job.id.clone();
let queued_at = job.queued_at;
state
.job_queue
.enqueue(job)
.await
.map_err(|e| ApiError::ServiceUnavailable(format!("Failed to queue scan: {}", e)))?;
let websocket_url = format!("/api/v1/scan/{}/stream", scan_id);
Ok(Json(ScanResponse {
scan_id,
status: ScanStatus::Queued,
target: final_target,
websocket_url: Some(websocket_url),
queued_at,
estimated_completion: None,
}))
}
#[utoipa::path(
get,
path = "/api/v1/scan/{id}",
tag = "scans",
params(
("id" = String, Path, description = "Scan ID")
),
responses(
(status = 200, description = "Scan status", body = ScanStatusResponse),
(status = 404, description = "Scan not found", body = ApiErrorResponse)
),
security(
("api_key" = [])
)
)]
pub async fn get_scan_status(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<ScanStatusResponse>, ApiError> {
let job = state
.job_queue
.get_job(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Scan {} not found", id)))?;
let results_url = if matches!(job.status, ScanStatus::Completed) {
Some(format!("/api/v1/scan/{}/results", id))
} else {
None
};
Ok(Json(ScanStatusResponse {
scan_id: job.id,
status: job.status,
progress: job.progress,
current_stage: job.current_stage,
eta_seconds: job.eta_seconds,
started_at: job.started_at,
completed_at: job.completed_at,
error: job.error,
results_url,
}))
}
#[utoipa::path(
get,
path = "/api/v1/scan/{id}/results",
tag = "scans",
params(
("id" = String, Path, description = "Scan ID")
),
responses(
(status = 200, description = "Scan results", body = serde_json::Value),
(status = 404, description = "Scan not found", body = ApiErrorResponse),
(status = 400, description = "Scan not completed", body = ApiErrorResponse)
),
security(
("api_key" = [])
)
)]
pub async fn get_scan_results(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let job = state
.job_queue
.get_job(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Scan {} not found", id)))?;
if !matches!(job.status, ScanStatus::Completed) {
return Err(ApiError::BadRequest(format!(
"Scan is not completed yet (status: {:?})",
job.status
)));
}
let results = job
.results
.ok_or_else(|| ApiError::Internal("Scan completed but results not found".to_string()))?;
let json = serde_json::to_value(&results)
.map_err(|e| ApiError::Internal(format!("Failed to serialize results: {}", e)))?;
Ok(Json(json))
}
#[utoipa::path(
delete,
path = "/api/v1/scan/{id}",
tag = "scans",
params(
("id" = String, Path, description = "Scan ID")
),
responses(
(status = 200, description = "Scan cancelled"),
(status = 404, description = "Scan not found", body = ApiErrorResponse),
(status = 400, description = "Scan cannot be cancelled", body = ApiErrorResponse)
),
security(
("api_key" = [])
)
)]
pub async fn cancel_scan(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let cancelled = state
.job_queue
.cancel_job(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if cancelled {
Ok(Json(serde_json::json!({
"message": "Scan cancelled successfully",
"scan_id": id
})))
} else {
Err(ApiError::BadRequest(
"Scan cannot be cancelled (already completed or not found)".to_string(),
))
}
}
#[utoipa::path(
get,
path = "/api/v1/scan/{id}/stream",
tag = "scans",
params(
("id" = String, Path, description = "Scan ID")
),
responses(
(status = 101, description = "WebSocket connection established"),
(status = 404, description = "Scan not found")
),
security(
("api_key" = [])
)
)]
pub async fn websocket_handler(
ws: WebSocketUpgrade,
Path(scan_id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Response {
if let Ok(Some(_)) = state.job_queue.get_job(&scan_id).await {
let ws_state = Arc::new(crate::api::ws::progress::WsState {
progress_tx: state.progress_tx.clone(),
});
ws.on_upgrade(move |socket| scan_websocket_handler(socket, scan_id, ws_state))
} else {
axum::http::StatusCode::NOT_FOUND.into_response()
}
}