atagen

malice

public
Star0
Watch0
b39ecae834a0cd6670068d28a7b4afd0498f7964
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: crates/malice-static/src/push.rs

aa3dce5114d0atagen2026-10-26 06:09 UTC1
use std::{path::PathBuf, sync::Arc};
aa3dce5114d0atagen2026-10-26 06:09 UTC2
aa3dce5114d0atagen2026-10-26 06:09 UTC3
use axum::{
aa3dce5114d0atagen2026-10-26 06:09 UTC4
    Router,
aa3dce5114d0atagen2026-10-26 06:09 UTC5
    body::Bytes,
aa3dce5114d0atagen2026-10-26 06:09 UTC6
    extract::{DefaultBodyLimit, State},
aa3dce5114d0atagen2026-10-26 06:09 UTC7
    http::{HeaderMap, HeaderValue, StatusCode, header},
aa3dce5114d0atagen2026-10-26 06:09 UTC8
    response::{IntoResponse, Response},
aa3dce5114d0atagen2026-10-26 06:09 UTC9
    routing::{get, post},
aa3dce5114d0atagen2026-10-26 06:09 UTC10
};
aa3dce5114d0atagen2026-10-26 06:09 UTC11
use macltok_push_gateway::{GatewayDevice, GatewayNotification, notify_response, parse_notify};
aa3dce5114d0atagen2026-10-26 06:09 UTC12
use malice_push::{Content, Message, Notice, Outcome, Sender, Subscription, Urgency};
aa3dce5114d0atagen2026-10-26 06:09 UTC13
use serde_json::Value;
aa3dce5114d0atagen2026-10-26 06:09 UTC14
use tokio::sync::Semaphore;
aa3dce5114d0atagen2026-10-26 06:09 UTC15
aa3dce5114d0atagen2026-10-26 06:09 UTC16
pub const DEVICE_APP_ID: &str = "dev.malice.device";
aa3dce5114d0atagen2026-10-26 06:09 UTC17
pub const NOTIFY_PATH: &str = "/_matrix/push/v1/notify";
aa3dce5114d0atagen2026-10-26 06:09 UTC18
const NOTIFY_BODY_LIMIT: usize = 64 * 1024;
aa3dce5114d0atagen2026-10-26 06:09 UTC19
const CONCURRENT_NOTIFIES: usize = 16;
aa3dce5114d0atagen2026-10-26 06:09 UTC20
aa3dce5114d0atagen2026-10-26 06:09 UTC21
/// A push gateway that keeps nothing per user: each pusher's data carries its
aa3dce5114d0atagen2026-10-26 06:09 UTC22
/// browser subscription's endpoint and auth secret, and its pushkey is the
aa3dce5114d0atagen2026-10-26 06:09 UTC23
/// subscription's p256dh key, as Sygnal's webpush pushkin does it.
aa3dce5114d0atagen2026-10-26 06:09 UTC24
pub struct Gateway {
aa3dce5114d0atagen2026-10-26 06:09 UTC25
    sender: Sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC26
    public_url: Option<String>,
aa3dce5114d0atagen2026-10-26 06:09 UTC27
    permits: Semaphore,
aa3dce5114d0atagen2026-10-26 06:09 UTC28
}
aa3dce5114d0atagen2026-10-26 06:09 UTC29
aa3dce5114d0atagen2026-10-26 06:09 UTC30
impl Gateway {
aa3dce5114d0atagen2026-10-26 06:09 UTC31
    pub fn new(sender: Sender, public_url: Option<String>) -> Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC32
        Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC33
            sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC34
            public_url,
aa3dce5114d0atagen2026-10-26 06:09 UTC35
            permits: Semaphore::new(CONCURRENT_NOTIFIES),
aa3dce5114d0atagen2026-10-26 06:09 UTC36
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC37
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC38
aa3dce5114d0atagen2026-10-26 06:09 UTC39
    /// Keeps the VAPID key in `MALICE_DATA_DIR`, systemd's `STATE_DIRECTORY`
aa3dce5114d0atagen2026-10-26 06:09 UTC40
    /// or the XDG state dir. Push stays off when none of them is usable.
aa3dce5114d0atagen2026-10-26 06:09 UTC41
    pub fn from_env() -> Option<Self> {
aa3dce5114d0atagen2026-10-26 06:09 UTC42
        let Some(dir) = state_dir() else {
aa3dce5114d0atagen2026-10-26 06:09 UTC43
            tracing::warn!("no state directory for the VAPID key, push is off");
aa3dce5114d0atagen2026-10-26 06:09 UTC44
            return None;
aa3dce5114d0atagen2026-10-26 06:09 UTC45
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC46
        if let Err(error) = create_private_dir(&dir) {
aa3dce5114d0atagen2026-10-26 06:09 UTC47
            tracing::warn!(%error, dir = %dir.display(), "creating the push state directory failed, push is off");
aa3dce5114d0atagen2026-10-26 06:09 UTC48
            return None;
aa3dce5114d0atagen2026-10-26 06:09 UTC49
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC50
        let public_url = malice_push::env::public_url();
aa3dce5114d0atagen2026-10-26 06:09 UTC51
        match malice_push::env::sender(&dir, public_url.as_deref()) {
aa3dce5114d0atagen2026-10-26 06:09 UTC52
            Ok(sender) => Some(Self::new(sender, public_url)),
aa3dce5114d0atagen2026-10-26 06:09 UTC53
            Err(error) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC54
                tracing::warn!(%error, "push is off");
aa3dce5114d0atagen2026-10-26 06:09 UTC55
                None
aa3dce5114d0atagen2026-10-26 06:09 UTC56
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC57
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC58
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC59
aa3dce5114d0atagen2026-10-26 06:09 UTC60
    fn origin(&self, headers: &HeaderMap) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC61
        if let Some(url) = &self.public_url {
aa3dce5114d0atagen2026-10-26 06:09 UTC62
            return url.clone();
aa3dce5114d0atagen2026-10-26 06:09 UTC63
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC64
        headers
aa3dce5114d0atagen2026-10-26 06:09 UTC65
            .get(header::HOST)
aa3dce5114d0atagen2026-10-26 06:09 UTC66
            .and_then(|host| host.to_str().ok())
aa3dce5114d0atagen2026-10-26 06:09 UTC67
            .map(|host| format!("https://{host}"))
aa3dce5114d0atagen2026-10-26 06:09 UTC68
            .unwrap_or_default()
aa3dce5114d0atagen2026-10-26 06:09 UTC69
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC70
}
aa3dce5114d0atagen2026-10-26 06:09 UTC71
aa3dce5114d0atagen2026-10-26 06:09 UTC72
fn state_dir() -> Option<PathBuf> {
aa3dce5114d0atagen2026-10-26 06:09 UTC73
    let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
aa3dce5114d0atagen2026-10-26 06:09 UTC74
    if let Some(dir) = var("MALICE_DATA_DIR") {
aa3dce5114d0atagen2026-10-26 06:09 UTC75
        return Some(dir.into());
aa3dce5114d0atagen2026-10-26 06:09 UTC76
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC77
    if let Some(dirs) = var("STATE_DIRECTORY") {
aa3dce5114d0atagen2026-10-26 06:09 UTC78
        return std::env::split_paths(&dirs).next();
aa3dce5114d0atagen2026-10-26 06:09 UTC79
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC80
    var("XDG_STATE_HOME")
aa3dce5114d0atagen2026-10-26 06:09 UTC81
        .map(PathBuf::from)
aa3dce5114d0atagen2026-10-26 06:09 UTC82
        .or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".local/state")))
aa3dce5114d0atagen2026-10-26 06:09 UTC83
        .map(|dir| dir.join("malice"))
aa3dce5114d0atagen2026-10-26 06:09 UTC84
}
aa3dce5114d0atagen2026-10-26 06:09 UTC85
aa3dce5114d0atagen2026-10-26 06:09 UTC86
fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
aa3dce5114d0atagen2026-10-26 06:09 UTC87
    let mut builder = std::fs::DirBuilder::new();
aa3dce5114d0atagen2026-10-26 06:09 UTC88
    builder.recursive(true);
aa3dce5114d0atagen2026-10-26 06:09 UTC89
    #[cfg(unix)]
aa3dce5114d0atagen2026-10-26 06:09 UTC90
    {
aa3dce5114d0atagen2026-10-26 06:09 UTC91
        use std::os::unix::fs::DirBuilderExt;
aa3dce5114d0atagen2026-10-26 06:09 UTC92
        builder.mode(0o700);
aa3dce5114d0atagen2026-10-26 06:09 UTC93
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC94
    builder.create(path)
aa3dce5114d0atagen2026-10-26 06:09 UTC95
}
aa3dce5114d0atagen2026-10-26 06:09 UTC96
aa3dce5114d0atagen2026-10-26 06:09 UTC97
pub fn push_router(gateway: Arc<Gateway>) -> Router {
aa3dce5114d0atagen2026-10-26 06:09 UTC98
    Router::new()
aa3dce5114d0atagen2026-10-26 06:09 UTC99
        .route("/push/vapid", get(vapid))
aa3dce5114d0atagen2026-10-26 06:09 UTC100
        .route(NOTIFY_PATH, post(notify))
aa3dce5114d0atagen2026-10-26 06:09 UTC101
        .layer(DefaultBodyLimit::max(NOTIFY_BODY_LIMIT))
aa3dce5114d0atagen2026-10-26 06:09 UTC102
        .with_state(gateway)
aa3dce5114d0atagen2026-10-26 06:09 UTC103
}
aa3dce5114d0atagen2026-10-26 06:09 UTC104
aa3dce5114d0atagen2026-10-26 06:09 UTC105
/// Keeps the push paths from falling through to the app shell.
aa3dce5114d0atagen2026-10-26 06:09 UTC106
pub fn push_off_router() -> Router {
aa3dce5114d0atagen2026-10-26 06:09 UTC107
    let off = || async { StatusCode::NOT_FOUND };
aa3dce5114d0atagen2026-10-26 06:09 UTC108
    Router::new()
aa3dce5114d0atagen2026-10-26 06:09 UTC109
        .route("/push/vapid", get(off))
aa3dce5114d0atagen2026-10-26 06:09 UTC110
        .route(NOTIFY_PATH, post(off))
aa3dce5114d0atagen2026-10-26 06:09 UTC111
}
aa3dce5114d0atagen2026-10-26 06:09 UTC112
aa3dce5114d0atagen2026-10-26 06:09 UTC113
async fn vapid(State(gateway): State<Arc<Gateway>>) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC114
    json_response(
aa3dce5114d0atagen2026-10-26 06:09 UTC115
        StatusCode::OK,
aa3dce5114d0atagen2026-10-26 06:09 UTC116
        serde_json::to_vec(&serde_json::json!({ "public_key": gateway.sender.vapid_public_key() }))
aa3dce5114d0atagen2026-10-26 06:09 UTC117
            .unwrap_or_default(),
aa3dce5114d0atagen2026-10-26 06:09 UTC118
    )
aa3dce5114d0atagen2026-10-26 06:09 UTC119
}
aa3dce5114d0atagen2026-10-26 06:09 UTC120
aa3dce5114d0atagen2026-10-26 06:09 UTC121
pub fn json_response(status: StatusCode, body: Vec<u8>) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC122
    (
aa3dce5114d0atagen2026-10-26 06:09 UTC123
        status,
aa3dce5114d0atagen2026-10-26 06:09 UTC124
        [(
aa3dce5114d0atagen2026-10-26 06:09 UTC125
            header::CONTENT_TYPE,
aa3dce5114d0atagen2026-10-26 06:09 UTC126
            HeaderValue::from_static("application/json"),
aa3dce5114d0atagen2026-10-26 06:09 UTC127
        )],
aa3dce5114d0atagen2026-10-26 06:09 UTC128
        body,
aa3dce5114d0atagen2026-10-26 06:09 UTC129
    )
aa3dce5114d0atagen2026-10-26 06:09 UTC130
        .into_response()
aa3dce5114d0atagen2026-10-26 06:09 UTC131
}
aa3dce5114d0atagen2026-10-26 06:09 UTC132
aa3dce5114d0atagen2026-10-26 06:09 UTC133
async fn notify(State(gateway): State<Arc<Gateway>>, headers: HeaderMap, body: Bytes) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC134
    let notification = match parse_notify(&body) {
aa3dce5114d0atagen2026-10-26 06:09 UTC135
        Ok(notification) => notification,
aa3dce5114d0atagen2026-10-26 06:09 UTC136
        Err(error) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC137
            return json_response(
aa3dce5114d0atagen2026-10-26 06:09 UTC138
                StatusCode::BAD_REQUEST,
aa3dce5114d0atagen2026-10-26 06:09 UTC139
                serde_json::to_vec(&serde_json::json!({ "error": error.to_string() }))
aa3dce5114d0atagen2026-10-26 06:09 UTC140
                    .unwrap_or_default(),
aa3dce5114d0atagen2026-10-26 06:09 UTC141
            );
aa3dce5114d0atagen2026-10-26 06:09 UTC142
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC143
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC144
    if !notification.is_event() {
aa3dce5114d0atagen2026-10-26 06:09 UTC145
        return json_response(StatusCode::OK, notify_response(&[]));
aa3dce5114d0atagen2026-10-26 06:09 UTC146
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC147
    let Ok(_permit) = gateway.permits.acquire().await else {
aa3dce5114d0atagen2026-10-26 06:09 UTC148
        return StatusCode::SERVICE_UNAVAILABLE.into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC149
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC150
    let origin = gateway.origin(&headers);
aa3dce5114d0atagen2026-10-26 06:09 UTC151
    let mut rejected = Vec::new();
aa3dce5114d0atagen2026-10-26 06:09 UTC152
    for device in &notification.devices {
aa3dce5114d0atagen2026-10-26 06:09 UTC153
        if deliver(&gateway.sender, &notification, device, &origin)
aa3dce5114d0atagen2026-10-26 06:09 UTC154
            .await
aa3dce5114d0atagen2026-10-26 06:09 UTC155
            .drops_subscription()
aa3dce5114d0atagen2026-10-26 06:09 UTC156
        {
aa3dce5114d0atagen2026-10-26 06:09 UTC157
            rejected.push(device.pushkey.clone());
aa3dce5114d0atagen2026-10-26 06:09 UTC158
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC159
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC160
    json_response(StatusCode::OK, notify_response(&rejected))
aa3dce5114d0atagen2026-10-26 06:09 UTC161
}
aa3dce5114d0atagen2026-10-26 06:09 UTC162
aa3dce5114d0atagen2026-10-26 06:09 UTC163
async fn deliver(
aa3dce5114d0atagen2026-10-26 06:09 UTC164
    sender: &Sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC165
    notification: &GatewayNotification,
aa3dce5114d0atagen2026-10-26 06:09 UTC166
    device: &GatewayDevice,
aa3dce5114d0atagen2026-10-26 06:09 UTC167
    origin: &str,
aa3dce5114d0atagen2026-10-26 06:09 UTC168
) -> Outcome {
aa3dce5114d0atagen2026-10-26 06:09 UTC169
    let data = |key: &str| device.data.get(key).and_then(Value::as_str);
aa3dce5114d0atagen2026-10-26 06:09 UTC170
    let (DEVICE_APP_ID, Some(endpoint), Some(auth)) =
aa3dce5114d0atagen2026-10-26 06:09 UTC171
        (device.app_id.as_str(), data("endpoint"), data("auth"))
aa3dce5114d0atagen2026-10-26 06:09 UTC172
    else {
aa3dce5114d0atagen2026-10-26 06:09 UTC173
        return Outcome::Refused;
aa3dce5114d0atagen2026-10-26 06:09 UTC174
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC175
    let subscription = Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC176
        endpoint: endpoint.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC177
        p256dh: device.pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC178
        auth: auth.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC179
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC180
    let notice = device_notice(notification, device);
aa3dce5114d0atagen2026-10-26 06:09 UTC181
    let urgency = if notice.mention || notification.room_name.is_none() {
aa3dce5114d0atagen2026-10-26 06:09 UTC182
        Urgency::High
aa3dce5114d0atagen2026-10-26 06:09 UTC183
    } else {
aa3dce5114d0atagen2026-10-26 06:09 UTC184
        Urgency::Normal
aa3dce5114d0atagen2026-10-26 06:09 UTC185
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC186
    let outcome = sender
aa3dce5114d0atagen2026-10-26 06:09 UTC187
        .send(
aa3dce5114d0atagen2026-10-26 06:09 UTC188
            &subscription,
aa3dce5114d0atagen2026-10-26 06:09 UTC189
            &Message {
aa3dce5114d0atagen2026-10-26 06:09 UTC190
                payload: &notice.to_bytes(origin),
aa3dce5114d0atagen2026-10-26 06:09 UTC191
                urgency,
aa3dce5114d0atagen2026-10-26 06:09 UTC192
                topic: &notice.room_id,
aa3dce5114d0atagen2026-10-26 06:09 UTC193
            },
aa3dce5114d0atagen2026-10-26 06:09 UTC194
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC195
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC196
    if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) {
aa3dce5114d0atagen2026-10-26 06:09 UTC197
        tracing::warn!(?outcome, "web push delivery failed");
aa3dce5114d0atagen2026-10-26 06:09 UTC198
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC199
    outcome
aa3dce5114d0atagen2026-10-26 06:09 UTC200
}
aa3dce5114d0atagen2026-10-26 06:09 UTC201
aa3dce5114d0atagen2026-10-26 06:09 UTC202
/// Room and sender as the homeserver named them. A room without a name is
aa3dce5114d0atagen2026-10-26 06:09 UTC203
/// taken for a DM, so its title is the sender.
aa3dce5114d0atagen2026-10-26 06:09 UTC204
pub fn device_notice(notification: &GatewayNotification, device: &GatewayDevice) -> Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC205
    let sender = notification
aa3dce5114d0atagen2026-10-26 06:09 UTC206
        .sender_display_name
aa3dce5114d0atagen2026-10-26 06:09 UTC207
        .clone()
aa3dce5114d0atagen2026-10-26 06:09 UTC208
        .filter(|name| !name.trim().is_empty())
aa3dce5114d0atagen2026-10-26 06:09 UTC209
        .or_else(|| {
aa3dce5114d0atagen2026-10-26 06:09 UTC210
            notification
aa3dce5114d0atagen2026-10-26 06:09 UTC211
                .sender
aa3dce5114d0atagen2026-10-26 06:09 UTC212
                .as_ref()
aa3dce5114d0atagen2026-10-26 06:09 UTC213
                .map(|user| user.localpart().to_owned())
aa3dce5114d0atagen2026-10-26 06:09 UTC214
        })
aa3dce5114d0atagen2026-10-26 06:09 UTC215
        .unwrap_or_default();
aa3dce5114d0atagen2026-10-26 06:09 UTC216
    let content: Option<Value> = notification
aa3dce5114d0atagen2026-10-26 06:09 UTC217
        .content
aa3dce5114d0atagen2026-10-26 06:09 UTC218
        .as_ref()
aa3dce5114d0atagen2026-10-26 06:09 UTC219
        .and_then(|raw| serde_json::from_str(raw.get()).ok());
aa3dce5114d0atagen2026-10-26 06:09 UTC220
    let content = Content::of(
aa3dce5114d0atagen2026-10-26 06:09 UTC221
        notification.event_type.as_deref(),
aa3dce5114d0atagen2026-10-26 06:09 UTC222
        content.as_ref(),
aa3dce5114d0atagen2026-10-26 06:09 UTC223
        notification.user_is_target,
aa3dce5114d0atagen2026-10-26 06:09 UTC224
    );
aa3dce5114d0atagen2026-10-26 06:09 UTC225
    let direct = notification.room_name.is_none();
aa3dce5114d0atagen2026-10-26 06:09 UTC226
    Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC227
        title: notification
aa3dce5114d0atagen2026-10-26 06:09 UTC228
            .room_name
aa3dce5114d0atagen2026-10-26 06:09 UTC229
            .clone()
aa3dce5114d0atagen2026-10-26 06:09 UTC230
            .unwrap_or_else(|| sender.clone()),
aa3dce5114d0atagen2026-10-26 06:09 UTC231
        body: content.body(&sender, direct),
aa3dce5114d0atagen2026-10-26 06:09 UTC232
        room_id: notification
aa3dce5114d0atagen2026-10-26 06:09 UTC233
            .room_id
aa3dce5114d0atagen2026-10-26 06:09 UTC234
            .as_ref()
aa3dce5114d0atagen2026-10-26 06:09 UTC235
            .map(ToString::to_string)
aa3dce5114d0atagen2026-10-26 06:09 UTC236
            .unwrap_or_default(),
aa3dce5114d0atagen2026-10-26 06:09 UTC237
        event_id: notification
aa3dce5114d0atagen2026-10-26 06:09 UTC238
            .event_id
aa3dce5114d0atagen2026-10-26 06:09 UTC239
            .as_ref()
aa3dce5114d0atagen2026-10-26 06:09 UTC240
            .map(ToString::to_string)
aa3dce5114d0atagen2026-10-26 06:09 UTC241
            .unwrap_or_default(),
aa3dce5114d0atagen2026-10-26 06:09 UTC242
        encrypted: content == Content::Encrypted,
aa3dce5114d0atagen2026-10-26 06:09 UTC243
        mention: device.tweaks.get("highlight").and_then(Value::as_bool) == Some(true),
aa3dce5114d0atagen2026-10-26 06:09 UTC244
        silent: !device.tweaks.contains_key("sound"),
aa3dce5114d0atagen2026-10-26 06:09 UTC245
        unread: Some(notification.counts.unread),
aa3dce5114d0atagen2026-10-26 06:09 UTC246
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC247
}