gitdataai/lib/git/sync/webhook.rs
2026-05-30 01:38:40 +08:00

195 lines
5.7 KiB
Rust

use std::time::Duration;
use deadpool_redis::cluster::Pool as RedisPool;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
fn webhook_queue_keys(repo_id: uuid::Uuid) -> (String, String) {
let hash_tag = format!("{{wh:{}}}", repo_id);
(
format!("{}:pending", hash_tag),
format!("{}:processing", hash_tag),
)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct WebhookDeliveryTask {
pub id: String,
pub webhook_id: String,
pub repo_id: String,
pub event: String,
pub url: String,
pub secret: Option<String>,
pub payload: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub retry_count: usize,
}
pub async fn enqueue_delivery(
task: WebhookDeliveryTask,
redis_pool: &RedisPool,
) -> Result<(), String> {
let task_json = serde_json::to_string(&task)
.map_err(|e| format!("serialize error: {}", e))?;
let repo_id: uuid::Uuid = task
.repo_id
.parse()
.map_err(|e| format!("invalid repo_id: {}", e))?;
let (pending_key, _) = webhook_queue_keys(repo_id);
let redis = redis_pool
.get()
.await
.map_err(|e| format!("redis pool: {}", e))?;
let mut conn: deadpool_redis::cluster::Connection = redis;
redis::cmd("LPUSH")
.arg(&pending_key)
.arg(&task_json)
.query_async::<()>(&mut conn)
.await
.map_err(|e| format!("LPUSH error: {}", e))?;
tracing::info!(
webhook_id = %task.webhook_id,
repo_id = %task.repo_id,
event = %task.event,
"webhook delivery enqueued"
);
Ok(())
}
pub async fn poll_delivery_for_repo(
redis_pool: &RedisPool,
repo_id: uuid::Uuid,
block_timeout_secs: usize,
) -> Option<String> {
let (pending_key, processing_key) = webhook_queue_keys(repo_id);
let redis = redis_pool.get().await.ok()?;
let mut conn: deadpool_redis::cluster::Connection = redis;
redis::cmd("BLMOVE")
.arg(&pending_key)
.arg(&processing_key)
.arg("RIGHT")
.arg("LEFT")
.arg(block_timeout_secs)
.query_async::<Option<String>>(&mut conn)
.await
.ok()
.flatten()
}
pub async fn ack_delivery(
redis_pool: &RedisPool,
repo_id: uuid::Uuid,
task_json: &str,
) {
let (_, processing_key) = webhook_queue_keys(repo_id);
let redis = match redis_pool.get().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "webhook ack: failed to get redis connection");
return;
}
};
let mut conn: deadpool_redis::cluster::Connection = redis;
if let Err(e) = redis::cmd("LREM")
.arg(&processing_key)
.arg(1)
.arg(task_json)
.query_async::<()>(&mut conn)
.await
{
tracing::warn!(error = %e, "webhook ack: LREM failed");
}
}
fn compute_hmac_signature(secret: &str, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(body);
let result = mac.finalize();
let code_bytes = result.into_bytes();
hex::encode(code_bytes)
}
pub async fn deliver_webhook(
task: &WebhookDeliveryTask,
) -> WebhookDeliveryResult {
let body_bytes = serde_json::to_vec(&task.payload).unwrap_or_default();
let signature = task
.secret
.as_ref()
.map(|s| compute_hmac_signature(s, &body_bytes));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default();
let mut request = client
.post(&task.url)
.header("Content-Type", "application/json")
.header("X-GitData-Event", &task.event)
.header("X-GitData-Delivery", &task.id);
if let Some(sig) = &signature {
request = request
.header("X-GitData-Signature-256", format!("sha256={}", sig));
}
let response = request.body(body_bytes.clone()).send().await;
match response {
Ok(resp) => {
let status = resp.status().as_u16() as i32;
let resp_headers: String = resp
.headers()
.iter()
.map(|(k, v)| {
format!("{}: {}", k, v.to_str().unwrap_or_default())
})
.collect::<Vec<_>>()
.join("\n");
let resp_body = resp.text().await.unwrap_or_default();
WebhookDeliveryResult {
response_status: Some(status),
response_headers: Some(resp_headers),
response_body: Some(resp_body),
error: None,
request_headers: Some(format!(
"Content-Type: application/json\nX-GitData-Event: {}\nX-GitData-Delivery: {}",
task.event, task.id
)),
request_body: Some(
String::from_utf8_lossy(&body_bytes).to_string(),
),
}
}
Err(e) => WebhookDeliveryResult {
response_status: None,
response_headers: None,
response_body: None,
error: Some(e.to_string()),
request_headers: Some(format!(
"Content-Type: application/json\nX-GitData-Event: {}\nX-GitData-Delivery: {}",
task.event, task.id
)),
request_body: Some(
String::from_utf8_lossy(&body_bytes).to_string(),
),
},
}
}
pub struct WebhookDeliveryResult {
pub request_headers: Option<String>,
pub request_body: Option<String>,
pub response_status: Option<i32>,
pub response_headers: Option<String>,
pub response_body: Option<String>,
pub error: Option<String>,
}