1use std::collections::BTreeMap;
12use std::sync::Arc;
13use std::time::Duration;
14
15use pagesift_net::{DnsLookup, SsrfPolicy, SystemLookup};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18
19use crate::error::{ApiError, ErrorCode};
20use crate::metrics::Metrics;
21
22mod deliver;
23mod queue;
24#[cfg(test)]
25mod tests;
26
27pub(crate) use queue::{Hook, ack_key, outbox_key, seq_key};
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct WebhookConfig {
32 pub url: String,
33 #[serde(default)]
34 pub headers: BTreeMap<String, String>,
35 #[serde(default)]
36 pub metadata: Map<String, Value>,
37 #[serde(default = "all_events")]
38 pub events: Vec<String>,
39}
40
41const EVENTS: [&str; 4] = ["started", "page", "completed", "failed"];
42
43fn all_events() -> Vec<String> {
44 EVENTS.iter().map(|e| e.to_string()).collect()
45}
46
47impl WebhookConfig {
48 pub(crate) fn parse(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
50 let cfg: Self = match v {
51 Value::String(u) => Self {
52 url: u.clone(),
53 headers: BTreeMap::new(),
54 metadata: Map::new(),
55 events: all_events(),
56 },
57 Value::Object(_) => serde_json::from_value(v.clone())
58 .map_err(|e| ApiError::invalid(format!("invalid webhook: {e}")))?,
59 _ => {
60 return Err(ApiError::invalid(
61 "webhook must be a URL or an object with a url",
62 ));
63 }
64 };
65 if let Some(e) = cfg.events.iter().find(|e| !EVENTS.contains(&e.as_str())) {
66 return Err(ApiError::invalid(format!(
67 "unknown webhook event {e:?}: use started, page, completed or failed"
68 )));
69 }
70 for (k, v) in &cfg.headers {
71 if reqwest::header::HeaderName::from_bytes(k.as_bytes()).is_err()
72 || reqwest::header::HeaderValue::from_str(v).is_err()
73 {
74 return Err(ApiError::invalid(format!("invalid webhook header {k:?}")));
75 }
76 }
77 policy.check_url(&cfg.url).map_err(|e| {
78 ApiError::new(
79 ErrorCode::SsrfBlocked,
80 format!("webhook URL is not allowed: {e}"),
81 )
82 })?;
83 Ok(cfg)
84 }
85
86 pub(crate) fn parse_monitor(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
88 const MONITOR_EVENTS: [&str; 2] = ["monitor.page", "monitor.check.completed"];
89 let mut obj = match v {
90 Value::String(u) => serde_json::json!({ "url": u }),
91 Value::Object(_) => v.clone(),
92 _ => {
93 return Err(ApiError::invalid(
94 "webhook must be a URL or an object with a url",
95 ));
96 }
97 };
98 let events = obj.get("events").cloned();
99 obj["events"] = serde_json::json!(["started"]);
100 let mut cfg = Self::parse(&obj, policy)?;
101 cfg.events = match events {
102 Some(e) => serde_json::from_value(e)
103 .map_err(|e| ApiError::invalid(format!("invalid webhook events: {e}")))?,
104 None => MONITOR_EVENTS.iter().map(|e| e.to_string()).collect(),
105 };
106 if let Some(e) = cfg
107 .events
108 .iter()
109 .find(|e| !MONITOR_EVENTS.contains(&e.as_str()))
110 {
111 return Err(ApiError::invalid(format!(
112 "unknown monitor webhook event {e:?}: use monitor.page or monitor.check.completed"
113 )));
114 }
115 Ok(cfg)
116 }
117
118 pub(crate) fn parse_agent(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
121 const AGENT_EVENTS: [&str; 5] = ["started", "action", "completed", "failed", "cancelled"]; let mut obj = match v {
123 Value::String(u) => serde_json::json!({ "url": u }),
124 Value::Object(_) => v.clone(),
125 _ => {
126 return Err(ApiError::invalid(
127 "webhook must be a URL or an object with a url",
128 ));
129 }
130 };
131 let events = obj.get("events").cloned();
132 obj["events"] = serde_json::json!(["started"]);
133 let mut cfg = Self::parse(&obj, policy)?;
134 cfg.events = match events {
135 Some(e) => serde_json::from_value::<Vec<String>>(e)
136 .map_err(|e| ApiError::invalid(format!("invalid webhook events: {e}")))?
137 .into_iter()
138 .map(|e| e.strip_prefix("agent.").map(str::to_string).unwrap_or(e))
139 .collect(),
140 None => AGENT_EVENTS.iter().map(|e| e.to_string()).collect(),
141 };
142 if let Some(e) = cfg
143 .events
144 .iter()
145 .find(|e| !AGENT_EVENTS.contains(&e.as_str()))
146 {
147 return Err(ApiError::invalid(format!(
148 "unknown agent webhook event {e:?}: use started, action, completed, failed or cancelled"
149 )));
150 }
151 Ok(cfg)
152 }
153
154 fn wants(&self, event: Event) -> bool {
155 self.events.iter().any(|e| e == event.name())
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub(crate) enum Event {
161 Started,
162 Page,
163 Completed,
164 Failed,
165}
166
167impl Event {
168 fn name(self) -> &'static str {
169 match self {
170 Event::Started => "started",
171 Event::Page => "page",
172 Event::Completed => "completed",
173 Event::Failed => "failed",
174 }
175 }
176}
177
178#[derive(Clone)]
180pub struct WebhookSettings {
181 pub ssrf: SsrfPolicy,
183 pub dns: Arc<dyn DnsLookup>,
184 pub max_attempts: u32,
186 pub initial_backoff: Duration,
188 pub max_backoff: Duration,
189 pub timeout: Duration,
191 pub queue: usize,
194 pub secret: Option<String>,
196}
197
198impl Default for WebhookSettings {
199 fn default() -> Self {
200 Self {
201 ssrf: SsrfPolicy::default(),
202 dns: Arc::new(SystemLookup),
203 max_attempts: 5,
210 initial_backoff: Duration::from_secs(1),
211 max_backoff: Duration::from_secs(60),
212 timeout: Duration::from_secs(10),
213 queue: 1024,
214 secret: None,
215 }
216 }
217}
218
219impl std::fmt::Debug for WebhookSettings {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("WebhookSettings")
222 .field("ssrf", &self.ssrf)
223 .field("max_attempts", &self.max_attempts)
224 .field("initial_backoff", &self.initial_backoff)
225 .field("max_backoff", &self.max_backoff)
226 .field("timeout", &self.timeout)
227 .field("queue", &self.queue)
228 .field("secret", &self.secret.is_some())
229 .finish_non_exhaustive()
230 }
231}
232
233#[derive(Clone)]
235pub(crate) struct Webhooks {
236 client: reqwest::Client,
237 pub(crate) settings: Arc<WebhookSettings>,
238 metrics: Arc<Metrics>,
239 pub(crate) mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
241}
242
243impl Webhooks {
244 pub(crate) fn new(settings: WebhookSettings, metrics: Arc<Metrics>) -> Self {
245 Self {
246 client: deliver::client(&settings),
247 settings: Arc::new(settings),
248 metrics,
249 mirror: None,
250 }
251 }
252
253 pub(crate) fn ssrf(&self) -> &SsrfPolicy {
254 &self.settings.ssrf
255 }
256}