1use async_trait::async_trait;
16use http::{HeaderName, HeaderValue};
17use std::fmt::Display;
18
19#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
22pub enum NotificationLevel {
23 #[default]
24 Info,
25 Warn,
26 Error,
27}
28
29impl Display for NotificationLevel {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 let msg = match self {
32 NotificationLevel::Error => "error",
33 NotificationLevel::Warn => "warn",
34 _ => "info",
35 };
36 write!(f, "{msg}")
37 }
38}
39
40#[derive(Default, Clone, Debug)]
41pub struct NotificationData {
42 pub category: String,
44 pub level: NotificationLevel,
46 pub title: String,
48 pub message: String,
50}
51
52pub const NOTIFICATION_MAX_RETRIES: u32 = 10;
56
57pub fn parse_notification_level(
61 value: Option<&str>,
62) -> Result<NotificationLevel, String> {
63 match value.map(str::trim).unwrap_or_default() {
64 "" | "info" => Ok(NotificationLevel::Info),
65 "warn" => Ok(NotificationLevel::Warn),
66 "error" => Ok(NotificationLevel::Error),
67 other => Err(format!(
68 "webhook_min_level({other}) should be info, warn or error"
69 )),
70 }
71}
72
73pub fn parse_notification_headers(
79 headers: &[String],
80) -> Result<Vec<(HeaderName, HeaderValue)>, String> {
81 let mut parsed = Vec::with_capacity(headers.len());
82 for item in headers {
83 let invalid = || {
86 let name = item.split([':', ' ', '\t']).next().unwrap_or_default();
87 format!("webhook_headers: {name:?} should be `Name: value`")
88 };
89 let (name, value) = item.split_once(':').ok_or_else(invalid)?;
90 let name = HeaderName::from_bytes(name.trim().as_bytes())
91 .map_err(|_| invalid())?;
92 let value =
93 HeaderValue::from_str(value.trim()).map_err(|_| invalid())?;
94 parsed.push((name, value));
95 }
96 Ok(parsed)
97}
98
99pub fn parse_notification_retries(value: Option<u32>) -> Result<u32, String> {
103 let retries = value.unwrap_or_default();
104 if retries > NOTIFICATION_MAX_RETRIES {
105 return Err(format!(
106 "webhook_retries({retries}) should be at most {NOTIFICATION_MAX_RETRIES}"
107 ));
108 }
109 Ok(retries)
110}
111
112pub const NOTIFICATION_TYPE_NTFY: &str = "ntfy";
114
115pub fn is_ntfy_notification(webhook_type: &str) -> bool {
117 webhook_type
118 .trim()
119 .eq_ignore_ascii_case(NOTIFICATION_TYPE_NTFY)
120}
121
122#[derive(Debug, Clone, PartialEq)]
124pub struct NtfyTarget {
125 pub url: String,
129 pub topic: String,
131}
132
133const NTFY_RESERVED_TOPICS: [&str; 10] = [
135 "docs", "static", "file", "app", "metrics", "account", "settings",
136 "signup", "login", "v1",
137];
138
139const NTFY_TOPIC_SUFFIXES: [&str; 8] = [
143 "json", "sse", "raw", "ws", "auth", "publish", "send", "trigger",
144];
145
146pub fn parse_ntfy_url(url: &str) -> Result<NtfyTarget, String> {
156 let invalid = |what: &str| {
157 format!(
158 "webhook: the url of ntfy {what}, it should be the url of a topic like https://ntfy.sh/mytopic"
159 )
160 };
161 let mut parsed =
162 url::Url::parse(url.trim()).map_err(|_| invalid("is not a url"))?;
163 if !["http", "https"].contains(&parsed.scheme())
164 || parsed.host_str().is_none_or(str::is_empty)
165 {
166 return Err(invalid("is neither http nor https"));
167 }
168 if parsed.fragment().is_some() {
171 return Err(invalid("has a part after a #"));
172 }
173 let mut parts: Vec<String> = parsed
174 .path()
175 .split('/')
176 .filter(|part| !part.is_empty())
177 .map(str::to_string)
178 .collect();
179 let topic = parts.pop().ok_or_else(|| invalid("names no topic"))?;
180 let valid = topic.len() <= 64
182 && topic
183 .chars()
184 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
185 if !valid {
186 return Err(invalid(
187 "ends with what is no topic (letters, digits, - and _, at most 64)",
188 ));
189 }
190 if NTFY_RESERVED_TOPICS.contains(&topic.as_str()) {
191 return Err(invalid("ends with a name ntfy keeps for itself"));
192 }
193 if !parts.is_empty() && NTFY_TOPIC_SUFFIXES.contains(&topic.as_str()) {
194 return Err(invalid(
195 "is the one of a subscription or of a publish by GET, not of the topic itself",
196 ));
197 }
198 let mut path = String::from("/");
199 for part in parts {
200 path.push_str(&part);
201 path.push('/');
202 }
203 parsed.set_path(&path);
204 Ok(NtfyTarget {
205 url: parsed.to_string(),
206 topic,
207 })
208}
209
210#[async_trait]
215pub trait Notification {
216 async fn notify(&self, data: NotificationData);
217}
218
219pub type NotificationSender = Box<dyn Notification + Send + Sync>;
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use pretty_assertions::assert_eq;
226
227 #[test]
228 fn test_notification_level() {
229 let level = NotificationLevel::Error;
230 assert_eq!(level.to_string(), "error");
231 let level = NotificationLevel::Warn;
232 assert_eq!(level.to_string(), "warn");
233 let level = NotificationLevel::Info;
234 assert_eq!(level.to_string(), "info");
235 }
236
237 #[test]
238 fn test_parse_ntfy_url() {
239 let target = |url: &str| {
240 let target = parse_ntfy_url(url).unwrap();
241 (target.url, target.topic)
242 };
243 let of = |url: &str, topic: &str| (url.to_string(), topic.to_string());
244 assert_eq!(
245 of("https://ntfy.sh/", "my_topic-1"),
246 target("https://ntfy.sh/my_topic-1")
247 );
248 assert_eq!(
249 of("https://ntfy.sh/", "alerts"),
250 target(" HTTPS://ntfy.sh/alerts/ ")
251 );
252 assert_eq!(
255 of("http://127.0.0.1:2586/push/", "alerts"),
256 target("http://127.0.0.1:2586/push/alerts")
257 );
258 assert_eq!(
259 of("http://[::1]:2586/", "alerts"),
260 target("http://[::1]:2586/alerts")
261 );
262 assert_eq!(
263 of(
264 "https://me:s3%40cret@ntfy.example.com/?auth=QmFzaWM",
265 "alerts"
266 ),
267 target("https://me:s3%40cret@ntfy.example.com/alerts?auth=QmFzaWM")
268 );
269 assert_eq!(
271 of("https://ntfy.sh/", "json"),
272 target("https://ntfy.sh/json")
273 );
274
275 for (url, reason) in [
276 ("https://ntfy.sh", "names no topic"),
277 ("https://ntfy.sh/", "names no topic"),
278 ("https://ntfy.sh/my topic", "is no topic"),
279 ("https://ntfy.sh/my.topic", "is no topic"),
280 ("ntfy.sh/alerts", "is not a url"),
281 ("/alerts", "is not a url"),
282 ("https://ntfy.sh:port/alerts", "is not a url"),
283 ("https://me:pa/ss@ntfy.sh/alerts", "is not a url"),
284 ("ftp://ntfy.sh/alerts", "is neither http nor https"),
285 ("https://ntfy.sh/alerts#prod", "has a part after a #"),
286 ("https://ntfy.sh/app", "keeps for itself"),
287 ("https://ntfy.sh/v1", "keeps for itself"),
288 ("https://ntfy.sh/alerts/json", "of a subscription"),
289 ("https://ntfy.sh/alerts/publish", "of a subscription"),
290 ] {
291 let error = parse_ntfy_url(url).unwrap_err();
292 assert_eq!(true, error.contains(reason), "{url}: {error}");
293 assert_eq!(false, error.contains("alerts"), "{error}");
295 }
296 let long = format!("https://ntfy.sh/{}", "a".repeat(65));
297 assert_eq!(true, parse_ntfy_url(&long).is_err());
298
299 assert_eq!(true, is_ntfy_notification("ntfy"));
300 assert_eq!(true, is_ntfy_notification(" Ntfy "));
301 assert_eq!(false, is_ntfy_notification("normal"));
302 assert_eq!(false, is_ntfy_notification(""));
303 }
304}