1use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use codoseo_core::crawl::AddressPolicy;
14use codoseo_crawler::guard::{GuardError, GuardedResolver, Lookup, SystemLookup, check_url};
15use reqwest::redirect::Policy;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18use url::{Host, Url};
19
20use crate::email::{MailError, Mailer};
21use crate::message::{self, AlertMessage};
22use crate::{discord, slack, webhook};
23
24const TIMEOUT: Duration = Duration::from_secs(10);
25const BODY_EXCERPT: usize = 200;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ChannelKind {
31 Email,
32 Slack,
33 Discord,
34 Webhook,
35}
36
37impl ChannelKind {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 ChannelKind::Email => "email",
42 ChannelKind::Slack => "slack",
43 ChannelKind::Discord => "discord",
44 ChannelKind::Webhook => "webhook",
45 }
46 }
47
48 pub fn parse(slug: &str) -> Option<ChannelKind> {
49 match slug {
50 "email" => Some(ChannelKind::Email),
51 "slack" => Some(ChannelKind::Slack),
52 "discord" => Some(ChannelKind::Discord),
53 "webhook" => Some(ChannelKind::Webhook),
54 _ => None,
55 }
56 }
57}
58
59#[derive(Clone, PartialEq, Eq)]
62pub enum ChannelTarget {
63 Email { to: String },
64 Slack { url: Url },
65 Discord { url: Url },
66 Webhook { url: Url, secret: String },
67}
68
69impl std::fmt::Debug for ChannelTarget {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "ChannelTarget::{:?}({})", self.kind(), self.display())
72 }
73}
74
75impl ChannelTarget {
76 pub fn kind(&self) -> ChannelKind {
77 match self {
78 ChannelTarget::Email { .. } => ChannelKind::Email,
79 ChannelTarget::Slack { .. } => ChannelKind::Slack,
80 ChannelTarget::Discord { .. } => ChannelKind::Discord,
81 ChannelTarget::Webhook { .. } => ChannelKind::Webhook,
82 }
83 }
84
85 pub fn display(&self) -> String {
88 match self {
89 ChannelTarget::Email { to } => to.clone(),
90 ChannelTarget::Slack { url }
91 | ChannelTarget::Discord { url }
92 | ChannelTarget::Webhook { url, .. } => url.host_str().unwrap_or("").to_owned(),
93 }
94 }
95
96 pub fn to_json(&self) -> Value {
98 match self {
99 ChannelTarget::Email { to } => json!({ "to": to }),
100 ChannelTarget::Slack { url } | ChannelTarget::Discord { url } => {
101 json!({ "url": url.as_str() })
102 }
103 ChannelTarget::Webhook { url, secret } => {
104 json!({ "url": url.as_str(), "secret": secret })
105 }
106 }
107 }
108
109 pub fn from_json(kind: ChannelKind, value: &Value) -> Result<ChannelTarget, TargetError> {
111 let field = |name: &str| {
112 value
113 .get(name)
114 .and_then(Value::as_str)
115 .filter(|s| !s.is_empty())
116 .ok_or_else(|| TargetError::Invalid(format!("the target has no `{name}`")))
117 };
118 let url = || {
119 Url::parse(field("url")?)
120 .map_err(|e| TargetError::Invalid(format!("the target URL is invalid: {e}")))
121 };
122 Ok(match kind {
123 ChannelKind::Email => ChannelTarget::Email {
124 to: field("to")?.to_owned(),
125 },
126 ChannelKind::Slack => ChannelTarget::Slack { url: url()? },
127 ChannelKind::Discord => ChannelTarget::Discord { url: url()? },
128 ChannelKind::Webhook => ChannelTarget::Webhook {
129 url: url()?,
130 secret: field("secret")?.to_owned(),
131 },
132 })
133 }
134}
135
136#[derive(Debug, thiserror::Error)]
137pub enum TargetError {
138 #[error("{0}")]
139 Invalid(String),
140 #[error("that address is not allowed: {0}")]
141 Blocked(#[from] GuardError),
142}
143
144fn check_shape(kind: ChannelKind, url: &str, policy: AddressPolicy) -> Result<Url, TargetError> {
147 let bad = |msg: &str| TargetError::Invalid(msg.to_owned());
148 let url = Url::parse(url.trim()).map_err(|_| bad("that doesn't look like a web address"))?;
149 let has_host = |hosts: &[&str]| url.host_str().is_some_and(|h| hosts.contains(&h));
150 match kind {
151 ChannelKind::Email => return Err(bad("email channels take an address, not a URL")),
152 ChannelKind::Slack => {
153 if url.scheme() != "https" || !has_host(&["hooks.slack.com"]) || url.path().len() < 2 {
154 return Err(bad(
155 "a Slack webhook looks like https://hooks.slack.com/services/...",
156 ));
157 }
158 }
159 ChannelKind::Discord => {
160 if url.scheme() != "https"
161 || !has_host(&["discord.com", "discordapp.com"])
162 || !url.path().starts_with("/api/webhooks/")
163 {
164 return Err(bad(
165 "a Discord webhook looks like https://discord.com/api/webhooks/...",
166 ));
167 }
168 }
169 ChannelKind::Webhook => {
170 let scheme_ok = url.scheme() == "https"
171 || (url.scheme() == "http" && policy == AddressPolicy::AllowPrivate);
172 if !scheme_ok || url.host_str().is_none() {
173 return Err(bad("a webhook URL must start with https://"));
174 }
175 }
176 }
177 check_url(&url, policy)?;
178 Ok(url)
179}
180
181#[derive(Debug, thiserror::Error)]
182pub enum DeliveryError {
183 #[error("refused: {0}")]
185 Blocked(#[from] GuardError),
186 #[error("request failed: {0}")]
188 Request(String),
189 #[error("the server answered {status}: {body}")]
191 Status { status: u16, body: String },
192 #[error(transparent)]
193 Mail(#[from] MailError),
194}
195
196type HostCheck =
197 dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<(), GuardError>> + Send>> + Send + Sync;
198
199#[derive(Clone)]
206pub struct GuardedHttp {
207 client: reqwest::Client,
208 policy: AddressPolicy,
209 host_check: Arc<HostCheck>,
210}
211
212impl GuardedHttp {
213 pub fn new(policy: AddressPolicy) -> Result<GuardedHttp, reqwest::Error> {
215 GuardedHttp::with_lookup(policy, SystemLookup)
216 }
217
218 pub fn with_lookup<L: Lookup>(
220 policy: AddressPolicy,
221 lookup: L,
222 ) -> Result<GuardedHttp, reqwest::Error> {
223 let resolver = Arc::new(GuardedResolver::new(lookup));
224 let mut builder = reqwest::Client::builder()
225 .redirect(Policy::none())
226 .timeout(TIMEOUT)
227 .user_agent("CodoSEO-Notify")
228 .no_proxy();
229 if policy == AddressPolicy::Public {
230 builder = builder.dns_resolver(Arc::clone(&resolver));
231 }
232 let checker = Arc::clone(&resolver);
233 let host_check: Arc<HostCheck> = Arc::new(move |host: String| {
234 let checker = Arc::clone(&checker);
235 Box::pin(async move { checker.lookup_checked(&host).await.map(|_| ()) })
236 });
237 Ok(GuardedHttp {
238 client: builder.build()?,
239 policy,
240 host_check,
241 })
242 }
243
244 pub fn policy(&self) -> AddressPolicy {
245 self.policy
246 }
247
248 pub async fn validate_target(&self, kind: ChannelKind, url: &str) -> Result<Url, TargetError> {
255 let url = check_shape(kind, url, self.policy)?;
256 if self.policy == AddressPolicy::Public
257 && let Some(Host::Domain(host)) = url.host()
258 {
259 (self.host_check)(host.to_owned()).await?;
260 }
261 Ok(url)
262 }
263}
264
265fn unix_now() -> u64 {
266 SystemTime::now()
267 .duration_since(UNIX_EPOCH)
268 .map_or(0, |d| d.as_secs())
269}
270
271pub async fn deliver(
274 http: &GuardedHttp,
275 mailer: &Mailer,
276 target: &ChannelTarget,
277 msg: &AlertMessage,
278) -> Result<(), DeliveryError> {
279 match target {
280 ChannelTarget::Email { to } => Ok(mailer.send(message::email(msg, to)).await?),
281 ChannelTarget::Slack { url } => post(http, url, &slack::payload(msg), None).await,
282 ChannelTarget::Discord { url } => post(http, url, &discord::payload(msg), None).await,
283 ChannelTarget::Webhook { url, secret } => {
284 post(http, url, &webhook::payload(msg), Some(secret)).await
285 }
286 }
287}
288
289async fn post(
290 http: &GuardedHttp,
291 url: &Url,
292 payload: &Value,
293 sign_with: Option<&str>,
294) -> Result<(), DeliveryError> {
295 check_url(url, http.policy)?;
296 let body = serde_json::to_vec(payload).expect("a JSON value always serializes");
297 let mut request = http
298 .client
299 .post(url.clone())
300 .header(reqwest::header::CONTENT_TYPE, "application/json");
301 if let Some(secret) = sign_with {
302 let timestamp = unix_now();
303 request = request
304 .header(webhook::TIMESTAMP_HEADER, timestamp.to_string())
305 .header(
306 webhook::SIGNATURE_HEADER,
307 webhook::sign(secret, timestamp, &body),
308 );
309 }
310 let mut response = request
312 .body(body)
313 .send()
314 .await
315 .map_err(|e| DeliveryError::Request(e.without_url().to_string()))?;
316 let status = response.status();
317 if status.is_success() {
318 return Ok(());
319 }
320 let excerpt = match response.chunk().await {
321 Ok(Some(chunk)) => String::from_utf8_lossy(&chunk).into_owned(),
322 _ => String::new(),
323 };
324 Err(DeliveryError::Status {
325 status: status.as_u16(),
326 body: message::truncate(&message::one_line(&excerpt), BODY_EXCERPT),
327 })
328}