1#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
2#![allow(clippy::missing_errors_doc)]
3mod extension;
4mod prop;
5pub mod register;
6use base64::Engine;
7use chrono::Utc;
8use derive_more::Constructor;
9pub use extension::*;
10use http::{HeaderValue, Method, header};
11pub use prop::*;
12use reqwest::{Body, Url};
13use rustical_store::{CollectionOperation, CollectionOperationInfo};
14use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
15use std::{collections::HashMap, sync::Arc, time::Duration};
16use tokio::sync::mpsc::Receiver;
17use tracing::{error, info, warn};
18
19mod endpoints;
20pub use endpoints::subscription_service;
21
22mod store;
23pub use store::*;
24
25mod subscription;
26pub use subscription::*;
27
28#[derive(XmlSerialize, Debug)]
29pub struct ContentUpdate {
30 #[xml(ns = "rustical_dav::namespace::NS_DAV")]
31 sync_token: Option<String>,
32}
33
34#[derive(XmlSerialize, XmlRootTag, Debug)]
35#[xml(root = "push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
36#[xml(ns_prefix(
37 rustical_dav::namespace::NS_DAVPUSH = "",
38 rustical_dav::namespace::NS_DAV = "D",
39))]
40struct PushMessage {
41 #[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
42 topic: String,
43 #[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
44 content_update: Option<ContentUpdate>,
45}
46
47#[derive(Debug, Constructor)]
48pub struct DavPushController<S: SubscriptionStore> {
49 allowed_push_servers: Option<Vec<String>>,
50 sub_store: Arc<S>,
51}
52
53impl<S: SubscriptionStore> DavPushController<S> {
54 pub async fn notifier(&self, mut recv: Receiver<CollectionOperation>) {
55 loop {
56 tokio::time::sleep(Duration::from_secs(10)).await;
58 let mut messages = vec![];
59 recv.recv_many(&mut messages, 100).await;
60
61 let mut latest_messages = HashMap::new();
65 for message in messages {
66 if matches!(message.data, CollectionOperationInfo::Content { .. }) {
67 latest_messages.insert(message.topic.clone(), message);
68 }
69 }
70 let messages = latest_messages.into_values();
71
72 for message in messages {
73 self.send_message(message).await;
74 }
75 }
76 }
77
78 #[allow(clippy::cognitive_complexity)]
79 async fn send_message(&self, message: CollectionOperation) {
80 let subscriptions = match self.sub_store.get_subscriptions(&message.topic).await {
81 Ok(subs) => subs,
82 Err(err) => {
83 error!("{err}");
84 return;
85 }
86 };
87
88 if subscriptions.is_empty() {
89 return;
90 }
91
92 if matches!(message.data, CollectionOperationInfo::Delete) {
93 return;
95 }
96
97 let content_update = if let CollectionOperationInfo::Content { sync_token } = message.data {
98 Some(ContentUpdate {
99 sync_token: Some(sync_token),
100 })
101 } else {
102 None
103 };
104
105 let push_message = PushMessage {
106 topic: message.topic,
107 content_update,
108 };
109
110 let payload = match push_message.serialize_to_string() {
111 Ok(payload) => payload,
112 Err(err) => {
113 error!("Could not serialize push message: {}", err);
114 return;
115 }
116 };
117
118 for subsciption in subscriptions {
119 if subsciption.is_expired(&Utc::now()) {
120 info!(
121 "Deleting subscription {} on topic {} because it is expired",
122 subsciption.id, subsciption.topic
123 );
124 self.try_delete_subscription(&subsciption.id).await;
125 continue;
126 }
127
128 if let Some(allowed_push_servers) = &self.allowed_push_servers {
129 if let Ok(url) = Url::parse(&subsciption.push_resource) {
130 let origin = url.origin().unicode_serialization();
131 if !allowed_push_servers.contains(&origin) {
132 warn!(
133 "Deleting subscription {} on topic {} because the endpoint is not in the list of allowed push servers",
134 subsciption.id, subsciption.topic
135 );
136 self.try_delete_subscription(&subsciption.id).await;
137 continue;
138 }
139 } else {
140 warn!(
141 "Deleting subscription {} on topic {} because of invalid URL",
142 subsciption.id, subsciption.topic
143 );
144 self.try_delete_subscription(&subsciption.id).await;
145 continue;
146 }
147 }
148
149 if let Err(err) = send_payload(&payload, &subsciption).await {
150 error!("An error occured sending out a push notification: {err}");
151 if err.is_permament_error() {
152 warn!(
153 "Deleting subscription {} on topic {}",
154 subsciption.id, subsciption.topic
155 );
156 self.try_delete_subscription(&subsciption.id).await;
157 }
158 }
159 }
160 }
161
162 async fn try_delete_subscription(&self, sub_id: &str) {
163 if let Err(err) = self.sub_store.delete_subscription(sub_id).await {
164 error!("Error deleting subsciption: {err}");
165 }
166 }
167}
168
169async fn send_payload(payload: &str, subsciption: &Subscription) -> Result<(), NotifierError> {
170 if subsciption.public_key_type != "p256dh" {
171 return Err(NotifierError::InvalidPublicKeyType(
172 subsciption.public_key_type.clone(),
173 ));
174 }
175 let endpoint = subsciption
176 .push_resource
177 .parse()
178 .map_err(|_| NotifierError::InvalidEndpointUrl(subsciption.push_resource.clone()))?;
179 let ua_public = base64::engine::general_purpose::URL_SAFE_NO_PAD
180 .decode(&subsciption.public_key)
181 .map_err(|_| NotifierError::InvalidKeyEncoding)?;
182 let auth_secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
183 .decode(&subsciption.auth_secret)
184 .map_err(|_| NotifierError::InvalidKeyEncoding)?;
185
186 let client = reqwest::ClientBuilder::new()
187 .build()
188 .map_err(NotifierError::from)?;
189
190 let payload = ece::encrypt(&ua_public, &auth_secret, payload.as_bytes())?;
191
192 let mut request = reqwest::Request::new(Method::POST, endpoint);
193 *request.body_mut() = Some(Body::from(payload));
194 let hdrs = request.headers_mut();
195 hdrs.insert(
196 header::CONTENT_ENCODING,
197 HeaderValue::from_static("aes128gcm"),
198 );
199 hdrs.insert(
200 header::CONTENT_TYPE,
201 HeaderValue::from_static("application/octet-stream"),
202 );
203 hdrs.insert("TTL", HeaderValue::from(60));
204 client.execute(request).await?;
205
206 Ok(())
207}
208
209#[derive(Debug, thiserror::Error)]
210enum NotifierError {
211 #[error("Invalid public key type: {0}")]
212 InvalidPublicKeyType(String),
213 #[error("Invalid endpoint URL: {0}")]
214 InvalidEndpointUrl(String),
215 #[error("Invalid key encoding")]
216 InvalidKeyEncoding,
217 #[error(transparent)]
218 EceError(#[from] ece::Error),
219 #[error(transparent)]
220 ReqwestError(#[from] reqwest::Error),
221}
222
223impl NotifierError {
224 pub const fn is_permament_error(&self) -> bool {
226 match self {
227 Self::InvalidPublicKeyType(_)
228 | Self::InvalidEndpointUrl(_)
229 | Self::InvalidKeyEncoding => true,
230 Self::EceError(err) => matches!(
231 err,
232 ece::Error::InvalidAuthSecret | ece::Error::InvalidKeyLength
233 ),
234 Self::ReqwestError(_) => false,
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use crate::{Subscription, send_payload};
242 use base64::Engine;
243 use chrono::NaiveDateTime;
244 use ece::generate_keypair_and_auth_secret;
245
246 #[tokio::test]
247 async fn test_ntfy_request() {
248 let (keypair, auth_secret) = generate_keypair_and_auth_secret().unwrap();
249 let auth_secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(auth_secret);
250 let public_key =
251 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(keypair.pub_as_raw().unwrap());
252
253 send_payload(
254 "hello",
255 &Subscription {
256 id: "asd".to_string(),
257 topic: "asd".to_string(),
258 expiration: NaiveDateTime::MAX,
259 push_resource: "https://ntfy.sh/upL00-v4L3SGM2".to_string(),
260 public_key,
261 public_key_type: "p256dh".to_string(),
262 auth_secret,
263 },
264 )
265 .await
266 .unwrap();
267 }
268}