use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use pagesift_net::{DnsLookup, SsrfPolicy, SystemLookup};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::error::{ApiError, ErrorCode};
use crate::metrics::Metrics;
mod deliver;
mod queue;
#[cfg(test)]
mod tests;
pub(crate) use queue::{Hook, ack_key, outbox_key, seq_key};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WebhookConfig {
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub metadata: Map<String, Value>,
#[serde(default = "all_events")]
pub events: Vec<String>,
}
const EVENTS: [&str; 4] = ["started", "page", "completed", "failed"];
fn all_events() -> Vec<String> {
EVENTS.iter().map(|e| e.to_string()).collect()
}
impl WebhookConfig {
pub(crate) fn parse(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
let cfg: Self = match v {
Value::String(u) => Self {
url: u.clone(),
headers: BTreeMap::new(),
metadata: Map::new(),
events: all_events(),
},
Value::Object(_) => serde_json::from_value(v.clone())
.map_err(|e| ApiError::invalid(format!("invalid webhook: {e}")))?,
_ => {
return Err(ApiError::invalid(
"webhook must be a URL or an object with a url",
));
}
};
if let Some(e) = cfg.events.iter().find(|e| !EVENTS.contains(&e.as_str())) {
return Err(ApiError::invalid(format!(
"unknown webhook event {e:?}: use started, page, completed or failed"
)));
}
for (k, v) in &cfg.headers {
if reqwest::header::HeaderName::from_bytes(k.as_bytes()).is_err()
|| reqwest::header::HeaderValue::from_str(v).is_err()
{
return Err(ApiError::invalid(format!("invalid webhook header {k:?}")));
}
}
policy.check_url(&cfg.url).map_err(|e| {
ApiError::new(
ErrorCode::SsrfBlocked,
format!("webhook URL is not allowed: {e}"),
)
})?;
Ok(cfg)
}
pub(crate) fn parse_monitor(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
const MONITOR_EVENTS: [&str; 2] = ["monitor.page", "monitor.check.completed"];
let mut obj = match v {
Value::String(u) => serde_json::json!({ "url": u }),
Value::Object(_) => v.clone(),
_ => {
return Err(ApiError::invalid(
"webhook must be a URL or an object with a url",
));
}
};
let events = obj.get("events").cloned();
obj["events"] = serde_json::json!(["started"]);
let mut cfg = Self::parse(&obj, policy)?;
cfg.events = match events {
Some(e) => serde_json::from_value(e)
.map_err(|e| ApiError::invalid(format!("invalid webhook events: {e}")))?,
None => MONITOR_EVENTS.iter().map(|e| e.to_string()).collect(),
};
if let Some(e) = cfg
.events
.iter()
.find(|e| !MONITOR_EVENTS.contains(&e.as_str()))
{
return Err(ApiError::invalid(format!(
"unknown monitor webhook event {e:?}: use monitor.page or monitor.check.completed"
)));
}
Ok(cfg)
}
pub(crate) fn parse_agent(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
const AGENT_EVENTS: [&str; 5] = ["started", "action", "completed", "failed", "cancelled"]; let mut obj = match v {
Value::String(u) => serde_json::json!({ "url": u }),
Value::Object(_) => v.clone(),
_ => {
return Err(ApiError::invalid(
"webhook must be a URL or an object with a url",
));
}
};
let events = obj.get("events").cloned();
obj["events"] = serde_json::json!(["started"]);
let mut cfg = Self::parse(&obj, policy)?;
cfg.events = match events {
Some(e) => serde_json::from_value::<Vec<String>>(e)
.map_err(|e| ApiError::invalid(format!("invalid webhook events: {e}")))?
.into_iter()
.map(|e| e.strip_prefix("agent.").map(str::to_string).unwrap_or(e))
.collect(),
None => AGENT_EVENTS.iter().map(|e| e.to_string()).collect(),
};
if let Some(e) = cfg
.events
.iter()
.find(|e| !AGENT_EVENTS.contains(&e.as_str()))
{
return Err(ApiError::invalid(format!(
"unknown agent webhook event {e:?}: use started, action, completed, failed or cancelled"
)));
}
Ok(cfg)
}
fn wants(&self, event: Event) -> bool {
self.events.iter().any(|e| e == event.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Event {
Started,
Page,
Completed,
Failed,
}
impl Event {
fn name(self) -> &'static str {
match self {
Event::Started => "started",
Event::Page => "page",
Event::Completed => "completed",
Event::Failed => "failed",
}
}
}
#[derive(Clone)]
pub struct WebhookSettings {
pub ssrf: SsrfPolicy,
pub dns: Arc<dyn DnsLookup>,
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub timeout: Duration,
pub queue: usize,
pub secret: Option<String>,
}
impl Default for WebhookSettings {
fn default() -> Self {
Self {
ssrf: SsrfPolicy::default(),
dns: Arc::new(SystemLookup),
max_attempts: 5,
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(60),
timeout: Duration::from_secs(10),
queue: 1024,
secret: None,
}
}
}
impl std::fmt::Debug for WebhookSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookSettings")
.field("ssrf", &self.ssrf)
.field("max_attempts", &self.max_attempts)
.field("initial_backoff", &self.initial_backoff)
.field("max_backoff", &self.max_backoff)
.field("timeout", &self.timeout)
.field("queue", &self.queue)
.field("secret", &self.secret.is_some())
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub(crate) struct Webhooks {
client: reqwest::Client,
pub(crate) settings: Arc<WebhookSettings>,
metrics: Arc<Metrics>,
pub(crate) mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
}
impl Webhooks {
pub(crate) fn new(settings: WebhookSettings, metrics: Arc<Metrics>) -> Self {
Self {
client: deliver::client(&settings),
settings: Arc::new(settings),
metrics,
mirror: None,
}
}
pub(crate) fn ssrf(&self) -> &SsrfPolicy {
&self.settings.ssrf
}
}