pagesift_api/webhook/
mod.rs1use 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 fn wants(&self, event: Event) -> bool {
87 self.events.iter().any(|e| e == event.name())
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub(crate) enum Event {
93 Started,
94 Page,
95 Completed,
96 Failed,
97}
98
99impl Event {
100 fn name(self) -> &'static str {
101 match self {
102 Event::Started => "started",
103 Event::Page => "page",
104 Event::Completed => "completed",
105 Event::Failed => "failed",
106 }
107 }
108}
109
110#[derive(Clone)]
112pub struct WebhookSettings {
113 pub ssrf: SsrfPolicy,
115 pub dns: Arc<dyn DnsLookup>,
116 pub max_attempts: u32,
118 pub initial_backoff: Duration,
120 pub max_backoff: Duration,
121 pub timeout: Duration,
123 pub queue: usize,
126 pub secret: Option<String>,
128}
129
130impl Default for WebhookSettings {
131 fn default() -> Self {
132 Self {
133 ssrf: SsrfPolicy::default(),
134 dns: Arc::new(SystemLookup),
135 max_attempts: 5,
142 initial_backoff: Duration::from_secs(1),
143 max_backoff: Duration::from_secs(60),
144 timeout: Duration::from_secs(10),
145 queue: 1024,
146 secret: None,
147 }
148 }
149}
150
151impl std::fmt::Debug for WebhookSettings {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("WebhookSettings")
154 .field("ssrf", &self.ssrf)
155 .field("max_attempts", &self.max_attempts)
156 .field("initial_backoff", &self.initial_backoff)
157 .field("max_backoff", &self.max_backoff)
158 .field("timeout", &self.timeout)
159 .field("queue", &self.queue)
160 .field("secret", &self.secret.is_some())
161 .finish_non_exhaustive()
162 }
163}
164
165#[derive(Clone)]
167pub(crate) struct Webhooks {
168 client: reqwest::Client,
169 settings: Arc<WebhookSettings>,
170 metrics: Arc<Metrics>,
171 pub(crate) mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
173}
174
175impl Webhooks {
176 pub(crate) fn new(settings: WebhookSettings, metrics: Arc<Metrics>) -> Self {
177 Self {
178 client: deliver::client(&settings),
179 settings: Arc::new(settings),
180 metrics,
181 mirror: None,
182 }
183 }
184
185 pub(crate) fn ssrf(&self) -> &SsrfPolicy {
186 &self.settings.ssrf
187 }
188}