1use async_trait::async_trait;
2#[cfg(feature = "reliable_report")]
3use autopush_common::reliability::PushReliability;
4use cadence::{Counted, StatsdClient, Timed};
5use reqwest::{Response, StatusCode};
6use serde_json::Value;
7use std::collections::{HashMap, hash_map::RandomState};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::time::Instant;
11use url::Url;
12use uuid::Uuid;
13
14use crate::error::{ApiError, ApiErrorKind, ApiResult};
15use crate::extractors::{notification::Notification, router_data_input::RouterDataInput};
16use crate::headers::vapid::VapidHeaderWithKey;
17use crate::routers::{Router, RouterError, RouterResponse};
18
19use autopush_common::db::{User, client::DbClient};
20use autopush_common::metric_name::MetricName;
21use autopush_common::metrics::StatsdClientExt;
22
23pub struct WebPushRouter {
29 pub db: Box<dyn DbClient>,
30 pub metrics: Arc<StatsdClient>,
31 pub http: reqwest::Client,
32 pub endpoint_url: Url,
33 pub in_flight_requests: Arc<AtomicUsize>,
34 #[cfg(feature = "reliable_report")]
35 pub reliability: Arc<PushReliability>,
36}
37
38#[async_trait(?Send)]
39impl Router for WebPushRouter {
40 fn register(
41 &self,
42 _router_input: &RouterDataInput,
43 _app_id: &str,
44 ) -> Result<HashMap<String, Value, RandomState>, RouterError> {
45 Ok(HashMap::new())
47 }
48
49 async fn route_notification(&self, notification: Notification) -> ApiResult<RouterResponse> {
50 let route_start = Instant::now();
51 let result = self.route_notification_inner(notification).await;
52 self.metrics
53 .time_with_tags(
54 MetricName::NotificationRouteTime.as_ref(),
55 route_start.elapsed().as_millis() as u64,
56 )
57 .with_tag("outcome", if result.is_ok() { "ok" } else { "error" })
58 .send();
59 result
60 }
61}
62
63impl WebPushRouter {
64 async fn route_notification_inner(
65 &self,
66 mut notification: Notification,
67 ) -> ApiResult<RouterResponse> {
68 let notif_user = ¬ification.subscription.user;
72 let uaid = notif_user.uaid;
73 let node_id = notif_user.node_id.clone();
74 debug!(
75 "✉ Routing WebPush notification to UAID {} :: {:?}",
76 uaid, notification.subscription.reliability_id,
77 );
78 trace!("✉ Notification = {:?}", notification);
79
80 if let Some(node_id) = node_id {
82 trace!(
83 "✉ User has a node ID, sending notification to node: {}",
84 &node_id
85 );
86
87 #[cfg(feature = "reliable_report")]
88 let revert_state = notification.reliable_state;
89 #[cfg(feature = "reliable_report")]
90 notification
91 .record_reliability(
92 &self.reliability,
93 autopush_common::reliability::ReliabilityState::IntTransmitted,
94 )
95 .await;
96 let send_start = Instant::now();
97 match self.send_notification(¬ification, &node_id).await {
98 Ok(response) => {
99 let elapsed = send_start.elapsed().as_millis() as u64;
100 let status = response.status().as_u16();
101 self.metrics
102 .time_with_tags(MetricName::DirectDeliveryTime.as_ref(), elapsed)
103 .with_tag("status", &status.to_string())
104 .send();
105 self.metrics
106 .incr_with_tags(MetricName::DirectDeliveryStatus)
107 .with_tag("status", &status.to_string())
108 .send();
109 if status == 200 {
111 trace!("✉ Node received notification");
113 return Ok(self.make_delivered_response(¬ification));
114 }
115 trace!(
116 "✉ Node did not receive the notification, response = {:?}",
117 response
118 );
119 }
120 Err(error) => {
121 let elapsed = send_start.elapsed().as_millis() as u64;
122 let status_tag = if let ApiErrorKind::ReqwestError(error) = &error.kind {
123 if error.is_timeout() {
124 self.metrics.incr(MetricName::ErrorNodeTimeout)?;
125 "timeout"
126 } else if error.is_connect() {
127 self.metrics.incr(MetricName::ErrorNodeConnect)?;
128 "connect_error"
129 } else {
130 "error"
131 }
132 } else {
133 "error"
134 };
135 self.metrics
136 .time_with_tags(MetricName::DirectDeliveryTime.as_ref(), elapsed)
137 .with_tag("status", status_tag)
138 .send();
139 self.metrics
140 .incr_with_tags(MetricName::DirectDeliveryStatus)
141 .with_tag("status", status_tag)
142 .send();
143 debug!(
144 "✉ Error while sending webpush notification to {}: {} ({})",
145 node_id, error, status_tag
146 );
147 self.remove_node_id(¬ification.subscription.user, &node_id)
148 .await?
149 }
150 }
151
152 #[cfg(feature = "reliable_report")]
153 if let Some(revert_state) = revert_state {
155 trace!(
156 "🔎⚠️ Revert {:?} from {:?} to {:?}",
157 ¬ification.reliability_id, ¬ification.reliable_state, revert_state
158 );
159 notification
160 .record_reliability(&self.reliability, revert_state)
161 .await;
162 }
163 }
164
165 if notification.headers.ttl == 0 {
166 let topic = notification.headers.topic.is_some().to_string();
167 trace!(
168 "✉ Notification has a TTL of zero and was not successfully \
169 delivered, dropping it"
170 );
171 self.metrics
172 .incr_with_tags(MetricName::NotificationMessageExpired)
173 .with_tag("topic", &topic)
175 .send();
176 #[cfg(feature = "reliable_report")]
177 notification
178 .record_reliability(
179 &self.reliability,
180 autopush_common::reliability::ReliabilityState::Expired,
181 )
182 .await;
183 return Ok(self.make_delivered_response(¬ification));
184 }
185
186 trace!("✉ Node is not present or busy, storing notification");
188 let store_start = Instant::now();
189 self.store_notification(&mut notification).await?;
190 self.metrics
191 .time_with_tags(
192 MetricName::StorageSaveTime.as_ref(),
193 store_start.elapsed().as_millis() as u64,
194 )
195 .send();
196
197 let user = match self.db.get_user(&uaid).await {
200 Ok(Some(user)) => user,
201 Ok(None) => {
202 trace!("✉ No user found, must have been deleted");
203 return Err(self.handle_error(
204 ApiErrorKind::Router(RouterError::UserWasDeleted),
205 notification.subscription.vapid.clone(),
206 ));
207 }
208 Err(e) => {
209 debug!("✉ Database error while re-fetching user: {}", e);
211 return Ok(self.make_stored_response(¬ification));
212 }
213 };
214
215 let node_id = match &user.node_id {
217 Some(id) => id,
218 None => {
220 trace!("✉ User is not connected to a node, returning stored response");
221 return Ok(self.make_stored_response(¬ification));
222 }
223 };
224
225 trace!("✉ Notifying node to check for messages");
227 match self.trigger_notification_check(&user.uaid, node_id).await {
228 Ok(response) => {
229 trace!("Response = {:?}", response);
230 if response.status() == 200 {
231 trace!("✉ Node has delivered the message");
232 self.metrics
233 .time_with_tags(
234 MetricName::NotificationTotalRequestTime.as_ref(),
235 (notification.timestamp - autopush_common::util::sec_since_epoch())
236 * 1000,
237 )
238 .with_tag("platform", "websocket")
239 .with_tag("app_id", "direct")
240 .send();
241
242 Ok(self.make_delivered_response(¬ification))
243 } else {
244 trace!("✉ Node has not delivered the message, returning stored response");
245 Ok(self.make_stored_response(¬ification))
246 }
247 }
248 Err(error) => {
249 debug!("✉ Error while triggering notification check: {}", error);
251 self.remove_node_id(&user, node_id).await?;
252 Ok(self.make_stored_response(¬ification))
253 }
254 }
255 }
256
257 fn handle_error(&self, error: ApiErrorKind, vapid: Option<VapidHeaderWithKey>) -> ApiError {
259 let mut err = ApiError::from(error);
260 if let Some(Ok(claims)) = vapid.map(|v| v.vapid.claims()) {
261 let mut extras = err.extras.unwrap_or_default();
262 if let Some(sub) = claims.sub {
263 extras.extend([("sub".to_owned(), sub)]);
264 }
265 err.extras = Some(extras);
266 };
267 err
268 }
269
270 async fn send_notification(
272 &self,
273 notification: &Notification,
274 node_id: &str,
275 ) -> ApiResult<Response> {
276 let url = format!("{}/push/{}", node_id, notification.subscription.user.uaid);
277
278 let notification_out = notification.serialize_for_delivery()?;
279
280 trace!(
281 "⏩ out: Notification: {}, channel_id: {} :: {:?}",
282 ¬ification.subscription.user.uaid,
283 ¬ification.subscription.channel_id,
284 ¬ification_out,
285 );
286 self.in_flight_requests.fetch_add(1, Ordering::Relaxed);
287 let result = self.http.put(&url).json(¬ification_out).send().await;
288 self.in_flight_requests.fetch_sub(1, Ordering::Relaxed);
289 Ok(result?)
290 }
291
292 async fn trigger_notification_check(
294 &self,
295 uaid: &Uuid,
296 node_id: &str,
297 ) -> Result<Response, reqwest::Error> {
298 let url = format!("{node_id}/notif/{uaid}");
299
300 self.in_flight_requests.fetch_add(1, Ordering::Relaxed);
301 let result = self.http.put(&url).send().await;
302 self.in_flight_requests.fetch_sub(1, Ordering::Relaxed);
303 result
304 }
305
306 async fn store_notification(&self, notification: &mut Notification) -> ApiResult<()> {
308 let result = self
309 .db
310 .save_message(
311 ¬ification.subscription.user.uaid,
312 autopush_common::notification::Notification::from(&*notification),
313 )
314 .await
315 .map_err(|e| {
316 self.handle_error(
317 ApiErrorKind::Router(RouterError::SaveDb(
318 e,
319 notification.subscription.vapid.as_ref().map(|vapid| {
321 vapid
322 .vapid
323 .claims()
324 .ok()
325 .and_then(|c| c.sub)
326 .unwrap_or_default()
327 }),
328 )),
329 notification.subscription.vapid.clone(),
330 )
331 });
332 #[cfg(feature = "reliable_report")]
333 notification
334 .record_reliability(
335 &self.reliability,
336 autopush_common::reliability::ReliabilityState::Stored,
337 )
338 .await;
339 result
340 }
341
342 async fn remove_node_id(&self, user: &User, node_id: &str) -> ApiResult<()> {
345 self.metrics.incr(MetricName::UpdatesClientHostGone).ok();
346 let removed = self
347 .db
348 .remove_node_id(&user.uaid, node_id, user.connected_at, &user.version)
349 .await?;
350 if !removed {
351 self.metrics.incr(MetricName::ErrorNodeStale).ok();
356 debug!("✉ The node id was not removed");
357 }
358 Ok(())
359 }
360
361 fn make_delivered_response(&self, notification: &Notification) -> RouterResponse {
364 self.make_response(notification, "Direct", StatusCode::CREATED)
365 }
366
367 fn make_stored_response(&self, notification: &Notification) -> RouterResponse {
370 self.make_response(notification, "Stored", StatusCode::CREATED)
371 }
372
373 fn make_response(
375 &self,
376 notification: &Notification,
377 destination_tag: &str,
378 status: StatusCode,
379 ) -> RouterResponse {
380 self.metrics
381 .count_with_tags(
382 MetricName::NotificationMessageData.as_ref(),
383 notification.data.as_ref().map(String::len).unwrap_or(0) as i64,
384 )
385 .with_tag("destination", destination_tag)
386 .send();
387
388 RouterResponse {
389 status: actix_http::StatusCode::from_u16(status.as_u16()).unwrap_or_default(),
390 headers: {
391 let mut map = HashMap::new();
392 map.insert(
393 "Location",
394 self.endpoint_url
395 .join(&format!("/m/{}", notification.message_id))
396 .expect("Message ID is not URL-safe")
397 .to_string(),
398 );
399 map.insert("TTL", notification.headers.ttl.to_string());
400 map
401 },
402 body: None,
403 }
404 }
405}
406
407#[cfg(test)]
408mod test {
409 use std::boxed::Box;
410 use std::sync::Arc;
411
412 use reqwest;
413
414 use crate::extractors::subscription::tests::{PUB_KEY, make_vapid};
415 use crate::headers::vapid::VapidClaims;
416 use autopush_common::errors::ReportableError;
417 #[cfg(feature = "reliable_report")]
418 use autopush_common::{redis_util::MAX_TRANSACTION_LOOP, reliability::PushReliability};
419
420 use super::*;
421 use autopush_common::db::mock::MockDbClient;
422
423 fn make_router(db: Box<dyn DbClient>) -> WebPushRouter {
424 let metrics = Arc::new(StatsdClient::builder("", cadence::NopMetricSink).build());
425 WebPushRouter {
426 db: db.clone(),
427 metrics: metrics.clone(),
428 http: reqwest::Client::new(),
429 endpoint_url: Url::parse("http://localhost:8080/").unwrap(),
430 in_flight_requests: Arc::new(AtomicUsize::new(0)),
431 #[cfg(feature = "reliable_report")]
432 reliability: Arc::new(
433 PushReliability::new(&None, db, &metrics, MAX_TRANSACTION_LOOP).unwrap(),
434 ),
435 }
436 }
437
438 #[tokio::test]
439 async fn pass_extras() {
440 let db = MockDbClient::new().into_boxed_arc();
441 let router = make_router(db);
442 let sub = "foo@example.com";
443 let vapid = make_vapid(
444 sub,
445 "https://push.services.mozilla.org",
446 VapidClaims::default_exp(),
447 PUB_KEY.to_owned(),
448 );
449
450 let err = router.handle_error(ApiErrorKind::LogCheck, Some(vapid));
451 assert!(err.extras().contains(&("sub", sub.to_owned())));
452 }
453}