use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use codoseo_core::crawl::AddressPolicy;
use codoseo_crawler::guard::{GuardError, GuardedResolver, Lookup, SystemLookup, check_url};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use url::{Host, Url};
use crate::email::{MailError, Mailer};
use crate::message::{self, AlertMessage};
use crate::{discord, slack, webhook};
const TIMEOUT: Duration = Duration::from_secs(10);
const BODY_EXCERPT: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelKind {
Email,
Slack,
Discord,
Webhook,
}
impl ChannelKind {
pub fn as_str(self) -> &'static str {
match self {
ChannelKind::Email => "email",
ChannelKind::Slack => "slack",
ChannelKind::Discord => "discord",
ChannelKind::Webhook => "webhook",
}
}
pub fn parse(slug: &str) -> Option<ChannelKind> {
match slug {
"email" => Some(ChannelKind::Email),
"slack" => Some(ChannelKind::Slack),
"discord" => Some(ChannelKind::Discord),
"webhook" => Some(ChannelKind::Webhook),
_ => None,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum ChannelTarget {
Email { to: String },
Slack { url: Url },
Discord { url: Url },
Webhook { url: Url, secret: String },
}
impl std::fmt::Debug for ChannelTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ChannelTarget::{:?}({})", self.kind(), self.display())
}
}
impl ChannelTarget {
pub fn kind(&self) -> ChannelKind {
match self {
ChannelTarget::Email { .. } => ChannelKind::Email,
ChannelTarget::Slack { .. } => ChannelKind::Slack,
ChannelTarget::Discord { .. } => ChannelKind::Discord,
ChannelTarget::Webhook { .. } => ChannelKind::Webhook,
}
}
pub fn display(&self) -> String {
match self {
ChannelTarget::Email { to } => to.clone(),
ChannelTarget::Slack { url }
| ChannelTarget::Discord { url }
| ChannelTarget::Webhook { url, .. } => url.host_str().unwrap_or("").to_owned(),
}
}
pub fn to_json(&self) -> Value {
match self {
ChannelTarget::Email { to } => json!({ "to": to }),
ChannelTarget::Slack { url } | ChannelTarget::Discord { url } => {
json!({ "url": url.as_str() })
}
ChannelTarget::Webhook { url, secret } => {
json!({ "url": url.as_str(), "secret": secret })
}
}
}
pub fn from_json(kind: ChannelKind, value: &Value) -> Result<ChannelTarget, TargetError> {
let field = |name: &str| {
value
.get(name)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| TargetError::Invalid(format!("the target has no `{name}`")))
};
let url = || {
Url::parse(field("url")?)
.map_err(|e| TargetError::Invalid(format!("the target URL is invalid: {e}")))
};
Ok(match kind {
ChannelKind::Email => ChannelTarget::Email {
to: field("to")?.to_owned(),
},
ChannelKind::Slack => ChannelTarget::Slack { url: url()? },
ChannelKind::Discord => ChannelTarget::Discord { url: url()? },
ChannelKind::Webhook => ChannelTarget::Webhook {
url: url()?,
secret: field("secret")?.to_owned(),
},
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum TargetError {
#[error("{0}")]
Invalid(String),
#[error("that address is not allowed: {0}")]
Blocked(#[from] GuardError),
}
fn check_shape(kind: ChannelKind, url: &str, policy: AddressPolicy) -> Result<Url, TargetError> {
let bad = |msg: &str| TargetError::Invalid(msg.to_owned());
let url = Url::parse(url.trim()).map_err(|_| bad("that doesn't look like a web address"))?;
let has_host = |hosts: &[&str]| url.host_str().is_some_and(|h| hosts.contains(&h));
match kind {
ChannelKind::Email => return Err(bad("email channels take an address, not a URL")),
ChannelKind::Slack => {
if url.scheme() != "https" || !has_host(&["hooks.slack.com"]) || url.path().len() < 2 {
return Err(bad(
"a Slack webhook looks like https://hooks.slack.com/services/...",
));
}
}
ChannelKind::Discord => {
if url.scheme() != "https"
|| !has_host(&["discord.com", "discordapp.com"])
|| !url.path().starts_with("/api/webhooks/")
{
return Err(bad(
"a Discord webhook looks like https://discord.com/api/webhooks/...",
));
}
}
ChannelKind::Webhook => {
let scheme_ok = url.scheme() == "https"
|| (url.scheme() == "http" && policy == AddressPolicy::AllowPrivate);
if !scheme_ok || url.host_str().is_none() {
return Err(bad("a webhook URL must start with https://"));
}
}
}
check_url(&url, policy)?;
Ok(url)
}
#[derive(Debug, thiserror::Error)]
pub enum DeliveryError {
#[error("refused: {0}")]
Blocked(#[from] GuardError),
#[error("request failed: {0}")]
Request(String),
#[error("the server answered {status}: {body}")]
Status { status: u16, body: String },
#[error(transparent)]
Mail(#[from] MailError),
}
type HostCheck =
dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<(), GuardError>> + Send>> + Send + Sync;
#[derive(Clone)]
pub struct GuardedHttp {
client: reqwest::Client,
policy: AddressPolicy,
host_check: Arc<HostCheck>,
}
impl GuardedHttp {
pub fn new(policy: AddressPolicy) -> Result<GuardedHttp, reqwest::Error> {
GuardedHttp::with_lookup(policy, SystemLookup)
}
pub fn with_lookup<L: Lookup>(
policy: AddressPolicy,
lookup: L,
) -> Result<GuardedHttp, reqwest::Error> {
let resolver = Arc::new(GuardedResolver::new(lookup));
let mut builder = reqwest::Client::builder()
.redirect(Policy::none())
.timeout(TIMEOUT)
.user_agent("CodoSEO-Notify")
.no_proxy();
if policy == AddressPolicy::Public {
builder = builder.dns_resolver(Arc::clone(&resolver));
}
let checker = Arc::clone(&resolver);
let host_check: Arc<HostCheck> = Arc::new(move |host: String| {
let checker = Arc::clone(&checker);
Box::pin(async move { checker.lookup_checked(&host).await.map(|_| ()) })
});
Ok(GuardedHttp {
client: builder.build()?,
policy,
host_check,
})
}
pub fn policy(&self) -> AddressPolicy {
self.policy
}
pub async fn validate_target(&self, kind: ChannelKind, url: &str) -> Result<Url, TargetError> {
let url = check_shape(kind, url, self.policy)?;
if self.policy == AddressPolicy::Public
&& let Some(Host::Domain(host)) = url.host()
{
(self.host_check)(host.to_owned()).await?;
}
Ok(url)
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
pub async fn deliver(
http: &GuardedHttp,
mailer: &Mailer,
target: &ChannelTarget,
msg: &AlertMessage,
) -> Result<(), DeliveryError> {
match target {
ChannelTarget::Email { to } => Ok(mailer.send(message::email(msg, to)).await?),
ChannelTarget::Slack { url } => post(http, url, &slack::payload(msg), None).await,
ChannelTarget::Discord { url } => post(http, url, &discord::payload(msg), None).await,
ChannelTarget::Webhook { url, secret } => {
post(http, url, &webhook::payload(msg), Some(secret)).await
}
}
}
async fn post(
http: &GuardedHttp,
url: &Url,
payload: &Value,
sign_with: Option<&str>,
) -> Result<(), DeliveryError> {
check_url(url, http.policy)?;
let body = serde_json::to_vec(payload).expect("a JSON value always serializes");
let mut request = http
.client
.post(url.clone())
.header(reqwest::header::CONTENT_TYPE, "application/json");
if let Some(secret) = sign_with {
let timestamp = unix_now();
request = request
.header(webhook::TIMESTAMP_HEADER, timestamp.to_string())
.header(
webhook::SIGNATURE_HEADER,
webhook::sign(secret, timestamp, &body),
);
}
let mut response = request
.body(body)
.send()
.await
.map_err(|e| DeliveryError::Request(e.without_url().to_string()))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let excerpt = match response.chunk().await {
Ok(Some(chunk)) => String::from_utf8_lossy(&chunk).into_owned(),
_ => String::new(),
};
Err(DeliveryError::Status {
status: status.as_u16(),
body: message::truncate(&message::one_line(&excerpt), BODY_EXCERPT),
})
}