use models::chat::outbound_webhooks; use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus}; use sea_orm::sea_query::Expr; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set}; use serde::{Deserialize, Serialize}; use social::net::is_public_ip; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::authz::can_manage_space; use crate::share::lookup::require_room_readable; const MAX_DELIVERY_ATTEMPTS: i32 = 5; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct OutboundWebhookDto { pub id: Uuid, pub room_id: Uuid, pub name: String, pub url: String, pub disabled: bool, pub last_status: Option, pub last_used_at: Option, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct OutboundWebhookSecretDto { pub id: Uuid, pub room_id: Uuid, pub name: String, pub url: String, pub secret: String, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateOutboundWebhookCmd { pub name: String, pub url: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateOutboundWebhookCmd { #[serde(default)] pub name: Option, #[serde(default)] pub url: Option, #[serde(default)] pub disabled: Option, } impl From for OutboundWebhookDto { fn from(w: outbound_webhooks::Model) -> Self { Self { id: w.id, room_id: w.room_id, name: w.name, url: w.url, disabled: w.disabled, last_status: w.last_status, last_used_at: w.last_used_at, created_at: w.created_at, } } } pub(crate) fn validate_outbound_url(url: &str) -> Result { let uri: http::Uri = url .parse() .map_err(|_| ChatError::BadRequest("invalid url".to_string()))?; let scheme = uri.scheme_str().unwrap_or(""); if scheme != "http" && scheme != "https" { return Err(ChatError::BadRequest( "url scheme must be http or https".to_string(), )); } let host = uri .host() .ok_or_else(|| ChatError::BadRequest("url host is required".to_string()))?; if host.is_empty() { return Err(ChatError::BadRequest("url host is required".to_string())); } if let Ok(ip) = host.parse::() && !is_public_ip(&ip) { return Err(ChatError::BadRequest( "url must not point at a private or loopback address".to_string(), )); } Ok(uri) } async fn require_room_manager( service: &ChatService, room_id: Uuid, caller: i64, ) -> Result<(), ChatError> { let ctx = require_room_readable(service, room_id, caller).await?; match &ctx.space { Some(space) => { if !can_manage_space(service, space, caller).await? { return Err(ChatError::Forbidden); } } None => return Err(ChatError::Forbidden), } Ok(()) } fn generate_secret() -> String { use rand::Rng; const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::thread_rng(); (0..48) .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) .collect() } impl ChatService { pub async fn create_outbound_webhook( &self, caller: i64, room_id: Uuid, cmd: CreateOutboundWebhookCmd, ) -> Result { require_room_manager(self, room_id, caller).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len { return Err(ChatError::BadRequest("invalid webhook name".to_string())); } validate_outbound_url(cmd.url.trim())?; let now = OffsetDateTime::now_utc(); let row = outbound_webhooks::ActiveModel { id: Set(Uuid::now_v7()), room_id: Set(room_id), user_id: Set(caller), name: Set(name.to_string()), url: Set(cmd.url.trim().to_string()), secret: Set(generate_secret()), disabled: Set(false), last_status: Set(None), last_used_at: Set(None), created_at: Set(now), updated_at: Set(now), } .insert(self.database.write()) .await?; Ok(OutboundWebhookSecretDto { id: row.id, room_id: row.room_id, name: row.name, url: row.url, secret: row.secret, created_at: row.created_at, }) } pub async fn list_outbound_webhooks( &self, caller: i64, room_id: Uuid, ) -> Result, ChatError> { require_room_manager(self, room_id, caller).await?; let rows = outbound_webhooks::Entity::find() .filter(outbound_webhooks::Column::RoomId.eq(room_id)) .all(self.database.read()) .await?; Ok(rows.into_iter().map(OutboundWebhookDto::from).collect()) } pub async fn update_outbound_webhook( &self, caller: i64, room_id: Uuid, webhook_id: Uuid, cmd: UpdateOutboundWebhookCmd, ) -> Result { require_room_manager(self, room_id, caller).await?; let webhook = outbound_webhooks::Entity::find_by_id(webhook_id) .filter(outbound_webhooks::Column::RoomId.eq(room_id)) .one(self.database.write()) .await? .ok_or(ChatError::NotFound)?; let mut am: outbound_webhooks::ActiveModel = webhook.into(); if let Some(name) = cmd.name { let name = name.trim(); if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len { return Err(ChatError::BadRequest("invalid webhook name".to_string())); } am.name = Set(name.to_string()); } if let Some(url) = cmd.url { validate_outbound_url(url.trim())?; am.url = Set(url.trim().to_string()); } if let Some(disabled) = cmd.disabled { am.disabled = Set(disabled); } am.updated_at = Set(OffsetDateTime::now_utc()); let updated = am.update(self.database.write()).await?; Ok(OutboundWebhookDto::from(updated)) } pub async fn delete_outbound_webhook( &self, caller: i64, room_id: Uuid, webhook_id: Uuid, ) -> Result<(), ChatError> { require_room_manager(self, room_id, caller).await?; let deleted = outbound_webhooks::Entity::delete_many() .filter(outbound_webhooks::Column::Id.eq(webhook_id)) .filter(outbound_webhooks::Column::RoomId.eq(room_id)) .exec(self.database.write()) .await?; if deleted.rows_affected == 0 { return Err(ChatError::NotFound); } webhook_deliveries::Entity::delete_many() .filter(webhook_deliveries::Column::WebhookId.eq(webhook_id)) .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending)) .exec(self.database.write()) .await?; Ok(()) } #[tracing::instrument(name = "chat.deliver_webhooks", skip_all)] pub async fn deliver_pending_webhook_events(&self) -> Result { let now = OffsetDateTime::now_utc(); let _ = webhook_deliveries::Entity::update_many() .col_expr( webhook_deliveries::Column::Status, Expr::value(WebhookDeliveryStatus::Pending), ) .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Delivering)) .filter( webhook_deliveries::Column::NextRetryAt .lt(now - time::Duration::minutes(crate::worker::STUCK_CLAIM_MINUTES)), ) .exec(self.database.write()) .await?; let due = webhook_deliveries::Entity::find() .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending)) .filter(webhook_deliveries::Column::NextRetryAt.lte(now)) .limit(50) .all(self.database.write()) .await?; let count = due.len() as u64; for delivery in due { let claimed = webhook_deliveries::Entity::update_many() .col_expr( webhook_deliveries::Column::Status, Expr::value(WebhookDeliveryStatus::Delivering), ) .filter(webhook_deliveries::Column::Id.eq(delivery.id)) .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending)) .exec(self.database.write()) .await?; if claimed.rows_affected == 0 { continue; } let Some(webhook) = outbound_webhooks::Entity::find_by_id(delivery.webhook_id) .one(self.database.read()) .await? else { self.finish_delivery( delivery.id, WebhookDeliveryStatus::Failed, Some("webhook deleted".to_string()), delivery.attempts, ) .await; continue; }; let result = if webhook.disabled { Err("webhook disabled".to_string()) } else { self.post_webhook_payload(&webhook, &delivery).await }; let (status, error) = match result { Ok(status_line) => { let mut am: outbound_webhooks::ActiveModel = webhook.into(); am.last_status = Set(Some(status_line.clone())); am.last_used_at = Set(Some(OffsetDateTime::now_utc())); am.updated_at = Set(OffsetDateTime::now_utc()); let _ = am.update(self.database.write()).await; (WebhookDeliveryStatus::Done, None) } Err(error) => { let attempts = delivery.attempts + 1; if attempts >= MAX_DELIVERY_ATTEMPTS { (WebhookDeliveryStatus::Failed, Some(error)) } else { let mut am: webhook_deliveries::ActiveModel = delivery.clone().into(); am.status = Set(WebhookDeliveryStatus::Pending); am.attempts = Set(attempts); am.next_retry_at = Set(OffsetDateTime::now_utc() + time::Duration::seconds(60 * attempts as i64)); am.last_error = Set(Some(error.clone())); am.updated_at = Set(OffsetDateTime::now_utc()); let _ = am.update(self.database.write()).await; continue; } } }; self.finish_delivery(delivery.id, status, error, delivery.attempts) .await; } Ok(count) } async fn finish_delivery( &self, id: Uuid, status: WebhookDeliveryStatus, error: Option, attempts: i32, ) { let am = webhook_deliveries::ActiveModel { id: Set(id), status: Set(status), attempts: Set(attempts + 1), last_error: Set(error), updated_at: Set(OffsetDateTime::now_utc()), ..Default::default() }; let _ = am.update(self.database.write()).await; } async fn post_webhook_payload( &self, webhook: &outbound_webhooks::Model, delivery: &webhook_deliveries::Model, ) -> Result { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let uri = validate_outbound_url(&webhook.url).map_err(|e| e.to_string())?; let host = uri.host().unwrap_or("").to_string(); let port = uri.port_u16(); let addrs = tokio::net::lookup_host((host.as_str(), port.unwrap_or(443))) .await .map_err(|e| format!("dns resolution failed: {e}"))? .collect::>(); if addrs.is_empty() { return Err("dns resolution returned no address".to_string()); } if addrs.iter().any(|addr| !is_public_ip(&addr.ip())) { return Err("url resolves to a private or loopback address".to_string()); } let body = serde_json::to_vec(&delivery.payload).map_err(|e| e.to_string())?; let mut mac = Hmac::::new_from_slice(webhook.secret.as_bytes()).map_err(|e| e.to_string())?; mac.update(&body); let hex_lower = |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() }; let signature = format!("sha256={}", hex_lower(&mac.finalize().into_bytes())); let mut client_builder = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .redirect(reqwest::redirect::Policy::none()); for addr in &addrs { client_builder = client_builder.resolve(&host, *addr); } let client = client_builder.build().map_err(|e| e.to_string())?; let span = oltp::client::http_client_span("POST", &webhook.url); let delivery_request_id = delivery.id.to_string(); let response = api::middleware::with_request_id( Some(delivery_request_id.as_str()), oltp::client::send( span, client .post(&webhook.url) .header("content-type", "application/json") .header("x-gitdata-event", &delivery.event_type) .header("x-gitdata-delivery", delivery.id.to_string()) .header("x-gitdata-signature", &signature) .body(body), ), ) .await .map_err(|e| { oltp::metrics::chat_webhook_delivery("error"); format!("request failed: {e}") })?; let status = response.status().as_u16(); if (200..300).contains(&status) { oltp::metrics::chat_webhook_delivery("ok"); Ok(format!("delivered {status}")) } else { oltp::metrics::chat_webhook_delivery("error"); Err(format!("endpoint returned {status}")) } } } pub async fn enqueue_outbound_event( service: &ChatService, room_id: Uuid, event_type: &str, payload: &serde_json::Value, ) { let hooks = outbound_webhooks::Entity::find() .filter(outbound_webhooks::Column::RoomId.eq(room_id)) .filter(outbound_webhooks::Column::Disabled.eq(false)) .all(service.database.read()) .await .unwrap_or_default(); if hooks.is_empty() { return; } let now = OffsetDateTime::now_utc(); for hook in hooks { let row = webhook_deliveries::ActiveModel { id: Set(Uuid::now_v7()), webhook_id: Set(hook.id), room_id: Set(room_id), event_type: Set(event_type.to_string()), payload: Set(payload.clone()), status: Set(WebhookDeliveryStatus::Pending), attempts: Set(0), next_retry_at: Set(now), last_error: Set(None), created_at: Set(now), updated_at: Set(now), }; if let Err(error) = row.insert(service.database.write()).await { tracing::warn!(%error, "failed to enqueue outbound webhook delivery"); } } }