| 1 | use std::{path::PathBuf, sync::Arc}; |
| 2 | |
| 3 | use axum::{ |
| 4 | Router, |
| 5 | body::Bytes, |
| 6 | extract::{DefaultBodyLimit, State}, |
| 7 | http::{HeaderMap, HeaderValue, StatusCode, header}, |
| 8 | response::{IntoResponse, Response}, |
| 9 | routing::{get, post}, |
| 10 | }; |
| 11 | use macltok_push_gateway::{GatewayDevice, GatewayNotification, notify_response, parse_notify}; |
| 12 | use malice_push::{Content, Message, Notice, Outcome, Sender, Subscription, Urgency}; |
| 13 | use serde_json::Value; |
| 14 | use tokio::sync::Semaphore; |
| 15 | |
| 16 | pub const DEVICE_APP_ID: &str = "dev.malice.device"; |
| 17 | pub const NOTIFY_PATH: &str = "/_matrix/push/v1/notify"; |
| 18 | const NOTIFY_BODY_LIMIT: usize = 64 * 1024; |
| 19 | const CONCURRENT_NOTIFIES: usize = 16; |
| 20 | |
| 21 | /// A push gateway that keeps nothing per user: each pusher's data carries its |
| 22 | /// browser subscription's endpoint and auth secret, and its pushkey is the |
| 23 | /// subscription's p256dh key, as Sygnal's webpush pushkin does it. |
| 24 | pub struct Gateway { |
| 25 | sender: Sender, |
| 26 | public_url: Option<String>, |
| 27 | permits: Semaphore, |
| 28 | } |
| 29 | |
| 30 | impl Gateway { |
| 31 | pub fn new(sender: Sender, public_url: Option<String>) -> Self { |
| 32 | Self { |
| 33 | sender, |
| 34 | public_url, |
| 35 | permits: Semaphore::new(CONCURRENT_NOTIFIES), |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | /// Keeps the VAPID key in `MALICE_DATA_DIR`, systemd's `STATE_DIRECTORY` |
| 40 | /// or the XDG state dir. Push stays off when none of them is usable. |
| 41 | pub fn from_env() -> Option<Self> { |
| 42 | let Some(dir) = state_dir() else { |
| 43 | tracing::warn!("no state directory for the VAPID key, push is off"); |
| 44 | return None; |
| 45 | }; |
| 46 | if let Err(error) = create_private_dir(&dir) { |
| 47 | tracing::warn!(%error, dir = %dir.display(), "creating the push state directory failed, push is off"); |
| 48 | return None; |
| 49 | } |
| 50 | let public_url = malice_push::env::public_url(); |
| 51 | match malice_push::env::sender(&dir, public_url.as_deref()) { |
| 52 | Ok(sender) => Some(Self::new(sender, public_url)), |
| 53 | Err(error) => { |
| 54 | tracing::warn!(%error, "push is off"); |
| 55 | None |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | fn origin(&self, headers: &HeaderMap) -> String { |
| 61 | if let Some(url) = &self.public_url { |
| 62 | return url.clone(); |
| 63 | } |
| 64 | headers |
| 65 | .get(header::HOST) |
| 66 | .and_then(|host| host.to_str().ok()) |
| 67 | .map(|host| format!("https://{host}")) |
| 68 | .unwrap_or_default() |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | fn state_dir() -> Option<PathBuf> { |
| 73 | let var = |name| std::env::var_os(name).filter(|value| !value.is_empty()); |
| 74 | if let Some(dir) = var("MALICE_DATA_DIR") { |
| 75 | return Some(dir.into()); |
| 76 | } |
| 77 | if let Some(dirs) = var("STATE_DIRECTORY") { |
| 78 | return std::env::split_paths(&dirs).next(); |
| 79 | } |
| 80 | var("XDG_STATE_HOME") |
| 81 | .map(PathBuf::from) |
| 82 | .or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".local/state"))) |
| 83 | .map(|dir| dir.join("malice")) |
| 84 | } |
| 85 | |
| 86 | fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> { |
| 87 | let mut builder = std::fs::DirBuilder::new(); |
| 88 | builder.recursive(true); |
| 89 | #[cfg(unix)] |
| 90 | { |
| 91 | use std::os::unix::fs::DirBuilderExt; |
| 92 | builder.mode(0o700); |
| 93 | } |
| 94 | builder.create(path) |
| 95 | } |
| 96 | |
| 97 | pub fn push_router(gateway: Arc<Gateway>) -> Router { |
| 98 | Router::new() |
| 99 | .route("/push/vapid", get(vapid)) |
| 100 | .route(NOTIFY_PATH, post(notify)) |
| 101 | .layer(DefaultBodyLimit::max(NOTIFY_BODY_LIMIT)) |
| 102 | .with_state(gateway) |
| 103 | } |
| 104 | |
| 105 | /// Keeps the push paths from falling through to the app shell. |
| 106 | pub fn push_off_router() -> Router { |
| 107 | let off = || async { StatusCode::NOT_FOUND }; |
| 108 | Router::new() |
| 109 | .route("/push/vapid", get(off)) |
| 110 | .route(NOTIFY_PATH, post(off)) |
| 111 | } |
| 112 | |
| 113 | async fn vapid(State(gateway): State<Arc<Gateway>>) -> Response { |
| 114 | json_response( |
| 115 | StatusCode::OK, |
| 116 | serde_json::to_vec(&serde_json::json!({ "public_key": gateway.sender.vapid_public_key() })) |
| 117 | .unwrap_or_default(), |
| 118 | ) |
| 119 | } |
| 120 | |
| 121 | pub fn json_response(status: StatusCode, body: Vec<u8>) -> Response { |
| 122 | ( |
| 123 | status, |
| 124 | [( |
| 125 | header::CONTENT_TYPE, |
| 126 | HeaderValue::from_static("application/json"), |
| 127 | )], |
| 128 | body, |
| 129 | ) |
| 130 | .into_response() |
| 131 | } |
| 132 | |
| 133 | async fn notify(State(gateway): State<Arc<Gateway>>, headers: HeaderMap, body: Bytes) -> Response { |
| 134 | let notification = match parse_notify(&body) { |
| 135 | Ok(notification) => notification, |
| 136 | Err(error) => { |
| 137 | return json_response( |
| 138 | StatusCode::BAD_REQUEST, |
| 139 | serde_json::to_vec(&serde_json::json!({ "error": error.to_string() })) |
| 140 | .unwrap_or_default(), |
| 141 | ); |
| 142 | } |
| 143 | }; |
| 144 | if !notification.is_event() { |
| 145 | return json_response(StatusCode::OK, notify_response(&[])); |
| 146 | } |
| 147 | let Ok(_permit) = gateway.permits.acquire().await else { |
| 148 | return StatusCode::SERVICE_UNAVAILABLE.into_response(); |
| 149 | }; |
| 150 | let origin = gateway.origin(&headers); |
| 151 | let mut rejected = Vec::new(); |
| 152 | for device in ¬ification.devices { |
| 153 | if deliver(&gateway.sender, ¬ification, device, &origin) |
| 154 | .await |
| 155 | .drops_subscription() |
| 156 | { |
| 157 | rejected.push(device.pushkey.clone()); |
| 158 | } |
| 159 | } |
| 160 | json_response(StatusCode::OK, notify_response(&rejected)) |
| 161 | } |
| 162 | |
| 163 | async fn deliver( |
| 164 | sender: &Sender, |
| 165 | notification: &GatewayNotification, |
| 166 | device: &GatewayDevice, |
| 167 | origin: &str, |
| 168 | ) -> Outcome { |
| 169 | let data = |key: &str| device.data.get(key).and_then(Value::as_str); |
| 170 | let (DEVICE_APP_ID, Some(endpoint), Some(auth)) = |
| 171 | (device.app_id.as_str(), data("endpoint"), data("auth")) |
| 172 | else { |
| 173 | return Outcome::Refused; |
| 174 | }; |
| 175 | let subscription = Subscription { |
| 176 | endpoint: endpoint.to_owned(), |
| 177 | p256dh: device.pushkey.clone(), |
| 178 | auth: auth.to_owned(), |
| 179 | }; |
| 180 | let notice = device_notice(notification, device); |
| 181 | let urgency = if notice.mention || notification.room_name.is_none() { |
| 182 | Urgency::High |
| 183 | } else { |
| 184 | Urgency::Normal |
| 185 | }; |
| 186 | let outcome = sender |
| 187 | .send( |
| 188 | &subscription, |
| 189 | &Message { |
| 190 | payload: ¬ice.to_bytes(origin), |
| 191 | urgency, |
| 192 | topic: ¬ice.room_id, |
| 193 | }, |
| 194 | ) |
| 195 | .await; |
| 196 | if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) { |
| 197 | tracing::warn!(?outcome, "web push delivery failed"); |
| 198 | } |
| 199 | outcome |
| 200 | } |
| 201 | |
| 202 | /// Room and sender as the homeserver named them. A room without a name is |
| 203 | /// taken for a DM, so its title is the sender. |
| 204 | pub fn device_notice(notification: &GatewayNotification, device: &GatewayDevice) -> Notice { |
| 205 | let sender = notification |
| 206 | .sender_display_name |
| 207 | .clone() |
| 208 | .filter(|name| !name.trim().is_empty()) |
| 209 | .or_else(|| { |
| 210 | notification |
| 211 | .sender |
| 212 | .as_ref() |
| 213 | .map(|user| user.localpart().to_owned()) |
| 214 | }) |
| 215 | .unwrap_or_default(); |
| 216 | let content: Option<Value> = notification |
| 217 | .content |
| 218 | .as_ref() |
| 219 | .and_then(|raw| serde_json::from_str(raw.get()).ok()); |
| 220 | let content = Content::of( |
| 221 | notification.event_type.as_deref(), |
| 222 | content.as_ref(), |
| 223 | notification.user_is_target, |
| 224 | ); |
| 225 | let direct = notification.room_name.is_none(); |
| 226 | Notice { |
| 227 | title: notification |
| 228 | .room_name |
| 229 | .clone() |
| 230 | .unwrap_or_else(|| sender.clone()), |
| 231 | body: content.body(&sender, direct), |
| 232 | room_id: notification |
| 233 | .room_id |
| 234 | .as_ref() |
| 235 | .map(ToString::to_string) |
| 236 | .unwrap_or_default(), |
| 237 | event_id: notification |
| 238 | .event_id |
| 239 | .as_ref() |
| 240 | .map(ToString::to_string) |
| 241 | .unwrap_or_default(), |
| 242 | encrypted: content == Content::Encrypted, |
| 243 | mention: device.tweaks.get("highlight").and_then(Value::as_bool) == Some(true), |
| 244 | silent: !device.tweaks.contains_key("sound"), |
| 245 | unread: Some(notification.counts.unread), |
| 246 | } |
| 247 | } |