re-uploading work
This commit is contained in:
808
crates/api/src/routes/webhooks.rs
Normal file
808
crates/api/src/routes/webhooks.rs
Normal file
@@ -0,0 +1,808 @@
|
||||
//! Webhook management and receiver API routes
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use attune_common::{
|
||||
mq::{EventCreatedPayload, MessageEnvelope, MessageType},
|
||||
repositories::{
|
||||
event::{CreateEventInput, EventRepository},
|
||||
trigger::{TriggerRepository, WebhookEventLogInput},
|
||||
Create, FindById, FindByRef,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
trigger::TriggerResponse,
|
||||
webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
|
||||
ApiResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
webhook_security,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK CONFIG HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/// Helper to extract boolean value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_bool(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
default: bool,
|
||||
) -> bool {
|
||||
let config = match &trigger.webhook_config {
|
||||
Some(c) => c,
|
||||
None => return default,
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current
|
||||
.get(part)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(default);
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = match current.get(part) {
|
||||
Some(v) => v,
|
||||
None => return default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default
|
||||
}
|
||||
|
||||
/// Helper to extract string value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_str(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<String> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current
|
||||
.get(part)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to extract i64 value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_i64(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<i64> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current.get(part).and_then(|v| v.as_i64());
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to extract array of strings from webhook_config JSON using path notation
|
||||
fn get_webhook_config_array(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract array
|
||||
return current.get(part).and_then(|v| {
|
||||
v.as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK MANAGEMENT ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/// Enable webhooks for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/enable",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhooks enabled", body = TriggerResponse),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn enable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Enable webhooks for this trigger
|
||||
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Disable webhooks for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/disable",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhooks disabled", body = TriggerResponse),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn disable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Disable webhooks for this trigger
|
||||
TriggerRepository::disable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Regenerate webhook key for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/regenerate",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhook key regenerated", body = TriggerResponse),
|
||||
(status = 400, description = "Webhooks not enabled for this trigger"),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn regenerate_webhook_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Check if webhooks are enabled
|
||||
if !trigger.webhook_enabled {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Webhooks are not enabled for this trigger. Enable webhooks first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Regenerate the webhook key
|
||||
let _regenerate_result = TriggerRepository::regenerate_webhook_key(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK RECEIVER ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/// Webhook receiver endpoint - receives webhook events and creates events
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/webhooks/{webhook_key}",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("webhook_key" = String, Path, description = "Webhook key")
|
||||
),
|
||||
request_body = WebhookReceiverRequest,
|
||||
responses(
|
||||
(status = 200, description = "Webhook received and event created", body = WebhookReceiverResponse),
|
||||
(status = 404, description = "Invalid webhook key"),
|
||||
(status = 429, description = "Rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn receive_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(webhook_key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Extract metadata from headers
|
||||
let source_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let signature = headers
|
||||
.get("x-webhook-signature")
|
||||
.or_else(|| headers.get("x-hub-signature-256"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse JSON payload
|
||||
let payload: WebhookReceiverRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid JSON payload: {}", e)))?;
|
||||
|
||||
let payload_size_bytes = body.len() as i32;
|
||||
|
||||
// Look up trigger by webhook key
|
||||
let trigger = match TriggerRepository::find_by_webhook_key(&state.db, &webhook_key).await {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
// Log failed attempt
|
||||
let _ = log_webhook_failure(
|
||||
&state,
|
||||
webhook_key.clone(),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
404,
|
||||
"Invalid webhook key".to_string(),
|
||||
start_time,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::NotFound("Invalid webhook key".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_webhook_failure(
|
||||
&state,
|
||||
webhook_key.clone(),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
500,
|
||||
e.to_string(),
|
||||
start_time,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::InternalServerError(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify webhooks are enabled
|
||||
if !trigger.webhook_enabled {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
400,
|
||||
Some("Webhooks not enabled for this trigger".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::BadRequest(
|
||||
"Webhooks are not enabled for this trigger".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Phase 3: Check payload size limit
|
||||
if let Some(limit_kb) = get_webhook_config_i64(&trigger, "payload_size_limit_kb") {
|
||||
let limit_bytes = limit_kb * 1024;
|
||||
if i64::from(payload_size_bytes) > limit_bytes {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
413,
|
||||
Some(format!(
|
||||
"Payload too large: {} bytes (limit: {} bytes)",
|
||||
payload_size_bytes, limit_bytes
|
||||
)),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Payload too large. Maximum size: {} KB",
|
||||
limit_kb
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Check IP whitelist
|
||||
let ip_whitelist_enabled = get_webhook_config_bool(&trigger, "ip_whitelist/enabled", false);
|
||||
let ip_allowed = if ip_whitelist_enabled {
|
||||
if let Some(ref ip) = source_ip {
|
||||
if let Some(whitelist) = get_webhook_config_array(&trigger, "ip_whitelist/ips") {
|
||||
match webhook_security::check_ip_in_whitelist(ip, &whitelist) {
|
||||
Ok(allowed) => {
|
||||
if !allowed {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
403,
|
||||
Some("IP address not in whitelist".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
Some(false),
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Forbidden("IP address not allowed".to_string()));
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("IP whitelist check error: {}", e);
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Phase 3: Check rate limit
|
||||
let rate_limit_enabled = get_webhook_config_bool(&trigger, "rate_limit/enabled", false);
|
||||
if rate_limit_enabled {
|
||||
if let (Some(max_requests), Some(window_seconds)) = (
|
||||
get_webhook_config_i64(&trigger, "rate_limit/requests"),
|
||||
get_webhook_config_i64(&trigger, "rate_limit/window_seconds"),
|
||||
) {
|
||||
// Note: Rate limit checking would need to be implemented with a time-series approach
|
||||
// For now, we skip this check as the repository function was removed
|
||||
let allowed = true; // TODO: Implement proper rate limiting
|
||||
|
||||
if !allowed {
|
||||
{
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
429,
|
||||
Some("Rate limit exceeded".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
true,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::TooManyRequests(format!(
|
||||
"Rate limit exceeded. Maximum {} requests per {} seconds",
|
||||
max_requests, window_seconds
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Verify HMAC signature
|
||||
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
||||
let hmac_verified = if hmac_enabled {
|
||||
if let (Some(secret), Some(algorithm)) = (
|
||||
get_webhook_config_str(&trigger, "hmac/secret"),
|
||||
get_webhook_config_str(&trigger, "hmac/algorithm"),
|
||||
) {
|
||||
if let Some(sig) = signature {
|
||||
match webhook_security::verify_hmac_signature(&body, &sig, &secret, &algorithm) {
|
||||
Ok(valid) => {
|
||||
if !valid {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some("Invalid HMAC signature".to_string()),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid webhook signature".to_string(),
|
||||
));
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some(format!("HMAC verification error: {}", e)),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized(format!(
|
||||
"Signature verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some("HMAC signature required but not provided".to_string()),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized("Signature required".to_string()));
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build config with webhook context metadata
|
||||
let mut config = serde_json::json!({
|
||||
"source": "webhook",
|
||||
"webhook_key": webhook_key,
|
||||
"received_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Add optional metadata
|
||||
if let Some(headers) = payload.headers {
|
||||
config["headers"] = headers;
|
||||
}
|
||||
if let Some(ref ip) = source_ip {
|
||||
config["source_ip"] = serde_json::Value::String(ip.clone());
|
||||
}
|
||||
if let Some(ref ua) = user_agent {
|
||||
config["user_agent"] = serde_json::Value::String(ua.clone());
|
||||
}
|
||||
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
||||
if hmac_enabled {
|
||||
config["hmac_verified"] = serde_json::Value::Bool(hmac_verified.unwrap_or(false));
|
||||
}
|
||||
|
||||
// Create event
|
||||
let event_input = CreateEventInput {
|
||||
trigger: Some(trigger.id),
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
config: Some(config),
|
||||
payload: Some(payload.payload),
|
||||
source: None,
|
||||
source_ref: Some("webhook".to_string()),
|
||||
rule: None,
|
||||
rule_ref: None,
|
||||
};
|
||||
|
||||
let event = EventRepository::create(&state.db, event_input)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let _ = futures::executor::block_on(log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
500,
|
||||
Some(format!("Failed to create event: {}", e)),
|
||||
start_time,
|
||||
hmac_verified,
|
||||
false,
|
||||
ip_allowed,
|
||||
));
|
||||
ApiError::InternalServerError(e.to_string())
|
||||
})?;
|
||||
|
||||
// Publish EventCreated message to message queue if publisher is available
|
||||
tracing::info!(
|
||||
"Webhook event {} created, attempting to publish EventCreated message",
|
||||
event.id
|
||||
);
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let message_payload = EventCreatedPayload {
|
||||
event_id: event.id,
|
||||
trigger_id: event.trigger,
|
||||
trigger_ref: event.trigger_ref.clone(),
|
||||
sensor_id: event.source,
|
||||
sensor_ref: event.source_ref.clone(),
|
||||
payload: event.payload.clone().unwrap_or(serde_json::json!({})),
|
||||
config: event.config.clone(),
|
||||
};
|
||||
|
||||
let envelope = MessageEnvelope::new(MessageType::EventCreated, message_payload)
|
||||
.with_source("api-webhook-receiver");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
tracing::warn!(
|
||||
"Failed to publish EventCreated message for event {}: {}",
|
||||
event.id,
|
||||
e
|
||||
);
|
||||
// Continue even if message publishing fails - event is already recorded
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Published EventCreated message for event {} (trigger: {})",
|
||||
event.id,
|
||||
event.trigger_ref
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Publisher not available, cannot publish EventCreated message for event {}",
|
||||
event.id
|
||||
);
|
||||
}
|
||||
|
||||
// Log successful webhook
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
Some(event.id),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
200,
|
||||
None,
|
||||
start_time,
|
||||
hmac_verified,
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = WebhookReceiverResponse {
|
||||
event_id: event.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
received_at: event.created,
|
||||
message: "Webhook received successfully".to_string(),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
// Helper function to log webhook events
|
||||
async fn log_webhook_event(
|
||||
state: &AppState,
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
webhook_key: &str,
|
||||
event_id: Option<i64>,
|
||||
source_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
payload_size_bytes: i32,
|
||||
status_code: i32,
|
||||
error_message: Option<String>,
|
||||
start_time: Instant,
|
||||
hmac_verified: Option<bool>,
|
||||
rate_limited: bool,
|
||||
ip_allowed: Option<bool>,
|
||||
) -> Result<(), attune_common::error::Error> {
|
||||
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
||||
|
||||
let log_input = WebhookEventLogInput {
|
||||
trigger_id: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
webhook_key: webhook_key.to_string(),
|
||||
event_id,
|
||||
source_ip,
|
||||
user_agent,
|
||||
payload_size_bytes: Some(payload_size_bytes),
|
||||
headers: None, // Could be added if needed
|
||||
status_code,
|
||||
error_message,
|
||||
processing_time_ms: Some(processing_time_ms),
|
||||
hmac_verified,
|
||||
rate_limited,
|
||||
ip_allowed,
|
||||
};
|
||||
|
||||
TriggerRepository::log_webhook_event(&state.db, log_input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to log failures when trigger is not found
|
||||
async fn log_webhook_failure(
|
||||
_state: &AppState,
|
||||
webhook_key: String,
|
||||
source_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
payload_size_bytes: i32,
|
||||
status_code: i32,
|
||||
error_message: String,
|
||||
start_time: Instant,
|
||||
) -> Result<(), attune_common::error::Error> {
|
||||
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
||||
|
||||
// We can't log to webhook_event_log without a trigger_id, so just log to tracing
|
||||
tracing::warn!(
|
||||
webhook_key = %webhook_key,
|
||||
source_ip = ?source_ip,
|
||||
user_agent = ?user_agent,
|
||||
payload_size_bytes = payload_size_bytes,
|
||||
status_code = status_code,
|
||||
error_message = %error_message,
|
||||
processing_time_ms = processing_time_ms,
|
||||
"Webhook request failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROUTER
|
||||
// ============================================================================
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Webhook management routes (protected)
|
||||
.route("/triggers/{ref}/webhooks/enable", post(enable_webhook))
|
||||
.route("/triggers/{ref}/webhooks/disable", post(disable_webhook))
|
||||
.route(
|
||||
"/triggers/{ref}/webhooks/regenerate",
|
||||
post(regenerate_webhook_key),
|
||||
)
|
||||
// TODO: Add Phase 3 management endpoints for HMAC, rate limiting, IP whitelist
|
||||
// Webhook receiver route (public - no auth required)
|
||||
.route("/webhooks/{webhook_key}", post(receive_webhook))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_webhook_routes_structure() {
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user