atagen

malice

public
Star0
Watch0
d9be72fdb39386b8e5863ac7f41631066c55cb22
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-static/src/push.rsRust247 lines7.8 KB
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 &notification.devices {
153
        if deliver(&gateway.sender, &notification, 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: &notice.to_bytes(origin),
191
                urgency,
192
                topic: &notice.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
}