195 lines
5.7 KiB
Rust
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>,
|
|
}
|