Skip to main content

pingap_core/
notification.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use async_trait::async_trait;
16use http::{HeaderName, HeaderValue};
17use std::fmt::Display;
18
19/// Variants are declared from least to most severe so the derived `Ord`
20/// ranks them: a merged batch reports the highest level in it.
21#[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    /// The category of the notification, used for grouping or filtering notifications
43    pub category: String,
44    /// The severity level of the notification (Info, Warn, Error)
45    pub level: NotificationLevel,
46    /// The title or subject of the notification
47    pub title: String,
48    /// The detailed message content of the notification
49    pub message: String,
50}
51
52/// The most a notification that could not be delivered is sent again.
53/// Each wait is twice the one before, from a second: ten of them are a
54/// quarter of an hour, by which time it is news no longer.
55pub const NOTIFICATION_MAX_RETRIES: u32 = 10;
56
57/// The level below which notifications are not sent, as the
58/// configuration names it (`webhook_min_level`): `info`, `warn` or
59/// `error`, and `info` where it names none.
60pub 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
73/// The headers of the request a notification is sent with, as the
74/// configuration has them (`webhook_headers`): each `Name: value`.
75///
76/// An error names the header and not its value, which is a token as
77/// often as not.
78pub 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        // Its first word: an entry without a colon is all value from
84        // there on, `Authorization Bearer ...`.
85        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
99/// How often a notification that could not be delivered is sent again,
100/// as the configuration has it (`webhook_retries`): not at all where it
101/// says nothing.
102pub 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
112/// The `webhook_type` of a notification that is published to ntfy.
113pub const NOTIFICATION_TYPE_NTFY: &str = "ntfy";
114
115/// Whether `webhook_type` names ntfy.
116pub fn is_ntfy_notification(webhook_type: &str) -> bool {
117    webhook_type
118        .trim()
119        .eq_ignore_ascii_case(NOTIFICATION_TYPE_NTFY)
120}
121
122/// Where a notification for ntfy goes.
123#[derive(Debug, Clone, PartialEq)]
124pub struct NtfyTarget {
125    /// The url the message is posted to as JSON, which ntfy takes at the
126    /// root of the server and nowhere else: the url of the topic without
127    /// its last part.
128    pub url: String,
129    /// The topic, which the JSON names.
130    pub topic: String,
131}
132
133/// The names ntfy keeps for itself, and takes no message for.
134const NTFY_RESERVED_TOPICS: [&str; 10] = [
135    "docs", "static", "file", "app", "metrics", "account", "settings",
136    "signup", "login", "v1",
137];
138
139/// What follows a topic in the urls ntfy has for reading one, and for
140/// publishing with a GET: the url of a subscription is not the one of
141/// its topic.
142const NTFY_TOPIC_SUFFIXES: [&str; 8] = [
143    "json", "sse", "raw", "ws", "auth", "publish", "send", "trigger",
144];
145
146/// The server and the topic of a notification for ntfy, as the
147/// configuration has them (`webhook`): the url of the topic,
148/// `https://ntfy.sh/mytopic`. A user and a password in it, and what
149/// follows a `?`, stay with the server.
150///
151/// Read with the parser the post is sent with, so that what is taken
152/// here is a url there as well. An error does not repeat the url: on a
153/// server that is open to everybody the name of a topic is all that
154/// keeps it to oneself.
155pub 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    // Sent nowhere, so what stood there as a part of the topic was not
169    // one.
170    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    // What ntfy takes for the name of a topic.
181    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/// Trait for sending notifications
211///
212/// Implementers of this trait can send notifications with different delivery methods
213/// (email, SMS, push notification, etc.)
214#[async_trait]
215pub trait Notification {
216    async fn notify(&self, data: NotificationData);
217}
218
219/// Type alias for a boxed Notification trait object that can be shared between threads
220pub 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        // A server of one's own: below a path, with a port, a user and a
253        // password, and a token in the query.
254        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        // A name that is one of a subscription only after a topic.
270        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            // The topic is as good as a password.
294            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}