atagen

malice

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

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

aa3dce5114d0atagen2026-10-26 06:09 UTC1
use std::{
aa3dce5114d0atagen2026-10-26 06:09 UTC2
    collections::HashMap,
aa3dce5114d0atagen2026-10-26 06:09 UTC3
    path::{Path, PathBuf},
aa3dce5114d0atagen2026-10-26 06:09 UTC4
    sync::{Mutex, MutexGuard, PoisonError},
aa3dce5114d0atagen2026-10-26 06:09 UTC5
    time::Duration,
aa3dce5114d0atagen2026-10-26 06:09 UTC6
};
aa3dce5114d0atagen2026-10-26 06:09 UTC7
aa3dce5114d0atagen2026-10-26 06:09 UTC8
use axum::{
aa3dce5114d0atagen2026-10-26 06:09 UTC9
    Router,
aa3dce5114d0atagen2026-10-26 06:09 UTC10
    body::Bytes,
aa3dce5114d0atagen2026-10-26 06:09 UTC11
    extract::{DefaultBodyLimit, State},
aa3dce5114d0atagen2026-10-26 06:09 UTC12
    http::{HeaderMap, StatusCode},
aa3dce5114d0atagen2026-10-26 06:09 UTC13
    response::{IntoResponse, Response},
aa3dce5114d0atagen2026-10-26 06:09 UTC14
    routing::{get, post},
aa3dce5114d0atagen2026-10-26 06:09 UTC15
};
aa3dce5114d0atagen2026-10-26 06:09 UTC16
use macltok::{HttpPusher, PushFormat, PushNotificationStatus, PusherKey};
aa3dce5114d0atagen2026-10-26 06:09 UTC17
use macltok_push_gateway::{GatewayDevice, GatewayNotification, notify_response, parse_notify};
aa3dce5114d0atagen2026-10-26 06:09 UTC18
use malice_core::push::{notice, unread_total};
aa3dce5114d0atagen2026-10-26 06:09 UTC19
use malice_push::{Message, Outcome, Sender, Subscription, Urgency};
aa3dce5114d0atagen2026-10-26 06:09 UTC20
use malice_static::push::{NOTIFY_PATH, json_response};
aa3dce5114d0atagen2026-10-26 06:09 UTC21
use rand_core::{OsRng, RngCore};
aa3dce5114d0atagen2026-10-26 06:09 UTC22
use serde::{Deserialize, Serialize};
aa3dce5114d0atagen2026-10-26 06:09 UTC23
use tokio::{sync::Semaphore, time::timeout};
aa3dce5114d0atagen2026-10-26 06:09 UTC24
aa3dce5114d0atagen2026-10-26 06:09 UTC25
use crate::{
aa3dce5114d0atagen2026-10-26 06:09 UTC26
    error::{ApiError, json_ok, no_content},
aa3dce5114d0atagen2026-10-26 06:09 UTC27
    routes::auth,
aa3dce5114d0atagen2026-10-26 06:09 UTC28
    state::{AppState, RestoreError},
aa3dce5114d0atagen2026-10-26 06:09 UTC29
};
aa3dce5114d0atagen2026-10-26 06:09 UTC30
aa3dce5114d0atagen2026-10-26 06:09 UTC31
pub const HOSTED_APP_ID: &str = "dev.malice.hosted";
aa3dce5114d0atagen2026-10-26 06:09 UTC32
const CONCURRENT_NOTIFIES: usize = 8;
aa3dce5114d0atagen2026-10-26 06:09 UTC33
const RESOLVE_TIMEOUT: Duration = Duration::from_secs(20);
aa3dce5114d0atagen2026-10-26 06:09 UTC34
aa3dce5114d0atagen2026-10-26 06:09 UTC35
#[derive(Debug, Clone, Serialize, Deserialize)]
aa3dce5114d0atagen2026-10-26 06:09 UTC36
struct Entry {
aa3dce5114d0atagen2026-10-26 06:09 UTC37
    sid: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC38
    subscription: Subscription,
aa3dce5114d0atagen2026-10-26 06:09 UTC39
}
aa3dce5114d0atagen2026-10-26 06:09 UTC40
aa3dce5114d0atagen2026-10-26 06:09 UTC41
/// Browser subscriptions by the random pushkey each one's pusher carries,
aa3dce5114d0atagen2026-10-26 06:09 UTC42
/// kept in `push.json` beside the sessions.
aa3dce5114d0atagen2026-10-26 06:09 UTC43
pub struct HostedPush {
aa3dce5114d0atagen2026-10-26 06:09 UTC44
    sender: Sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC45
    public_url: Option<String>,
aa3dce5114d0atagen2026-10-26 06:09 UTC46
    path: PathBuf,
aa3dce5114d0atagen2026-10-26 06:09 UTC47
    entries: Mutex<HashMap<String, Entry>>,
aa3dce5114d0atagen2026-10-26 06:09 UTC48
    permits: Semaphore,
aa3dce5114d0atagen2026-10-26 06:09 UTC49
}
aa3dce5114d0atagen2026-10-26 06:09 UTC50
aa3dce5114d0atagen2026-10-26 06:09 UTC51
impl HostedPush {
aa3dce5114d0atagen2026-10-26 06:09 UTC52
    pub fn new(data_dir: &Path, public_url: Option<String>, sender: Sender) -> Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC53
        let path = data_dir.join("push.json");
aa3dce5114d0atagen2026-10-26 06:09 UTC54
        let entries = match std::fs::read(&path) {
aa3dce5114d0atagen2026-10-26 06:09 UTC55
            Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
aa3dce5114d0atagen2026-10-26 06:09 UTC56
                tracing::warn!(%error, "push subscriptions are unreadable, starting empty");
aa3dce5114d0atagen2026-10-26 06:09 UTC57
                HashMap::new()
aa3dce5114d0atagen2026-10-26 06:09 UTC58
            }),
aa3dce5114d0atagen2026-10-26 06:09 UTC59
            Err(_) => HashMap::new(),
aa3dce5114d0atagen2026-10-26 06:09 UTC60
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC61
        Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC62
            sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC63
            public_url,
aa3dce5114d0atagen2026-10-26 06:09 UTC64
            path,
aa3dce5114d0atagen2026-10-26 06:09 UTC65
            entries: Mutex::new(entries),
aa3dce5114d0atagen2026-10-26 06:09 UTC66
            permits: Semaphore::new(CONCURRENT_NOTIFIES),
aa3dce5114d0atagen2026-10-26 06:09 UTC67
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC68
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC69
aa3dce5114d0atagen2026-10-26 06:09 UTC70
    /// The VAPID key lives in `data_dir`. Subscribing needs `MALICE_PUBLIC_URL`,
aa3dce5114d0atagen2026-10-26 06:09 UTC71
    /// since the homeserver must reach the notify endpoint.
aa3dce5114d0atagen2026-10-26 06:09 UTC72
    pub fn from_env(data_dir: &Path) -> Result<Self, String> {
aa3dce5114d0atagen2026-10-26 06:09 UTC73
        let public_url = malice_push::env::public_url();
aa3dce5114d0atagen2026-10-26 06:09 UTC74
        if public_url.is_none() {
aa3dce5114d0atagen2026-10-26 06:09 UTC75
            tracing::info!("web push is off until MALICE_PUBLIC_URL is set");
aa3dce5114d0atagen2026-10-26 06:09 UTC76
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC77
        let sender = malice_push::env::sender(data_dir, public_url.as_deref())?;
aa3dce5114d0atagen2026-10-26 06:09 UTC78
        Ok(Self::new(data_dir, public_url, sender))
aa3dce5114d0atagen2026-10-26 06:09 UTC79
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC80
aa3dce5114d0atagen2026-10-26 06:09 UTC81
    fn entries(&self) -> MutexGuard<'_, HashMap<String, Entry>> {
aa3dce5114d0atagen2026-10-26 06:09 UTC82
        self.entries.lock().unwrap_or_else(PoisonError::into_inner)
aa3dce5114d0atagen2026-10-26 06:09 UTC83
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC84
aa3dce5114d0atagen2026-10-26 06:09 UTC85
    fn save(&self, entries: &HashMap<String, Entry>) {
aa3dce5114d0atagen2026-10-26 06:09 UTC86
        let result = serde_json::to_vec(entries)
aa3dce5114d0atagen2026-10-26 06:09 UTC87
            .map_err(std::io::Error::other)
aa3dce5114d0atagen2026-10-26 06:09 UTC88
            .and_then(|bytes| {
aa3dce5114d0atagen2026-10-26 06:09 UTC89
                let temp = self.path.with_extension("json.tmp");
aa3dce5114d0atagen2026-10-26 06:09 UTC90
                let mut options = std::fs::OpenOptions::new();
aa3dce5114d0atagen2026-10-26 06:09 UTC91
                options.write(true).create(true).truncate(true);
aa3dce5114d0atagen2026-10-26 06:09 UTC92
                #[cfg(unix)]
aa3dce5114d0atagen2026-10-26 06:09 UTC93
                {
aa3dce5114d0atagen2026-10-26 06:09 UTC94
                    use std::os::unix::fs::OpenOptionsExt;
aa3dce5114d0atagen2026-10-26 06:09 UTC95
                    options.mode(0o600);
aa3dce5114d0atagen2026-10-26 06:09 UTC96
                }
aa3dce5114d0atagen2026-10-26 06:09 UTC97
                let mut file = options.open(&temp)?;
aa3dce5114d0atagen2026-10-26 06:09 UTC98
                std::io::Write::write_all(&mut file, &bytes)?;
aa3dce5114d0atagen2026-10-26 06:09 UTC99
                file.sync_all()?;
aa3dce5114d0atagen2026-10-26 06:09 UTC100
                std::fs::rename(&temp, &self.path)
aa3dce5114d0atagen2026-10-26 06:09 UTC101
            });
aa3dce5114d0atagen2026-10-26 06:09 UTC102
        if let Err(error) = result {
aa3dce5114d0atagen2026-10-26 06:09 UTC103
            tracing::warn!(%error, "saving push subscriptions failed");
aa3dce5114d0atagen2026-10-26 06:09 UTC104
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC105
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC106
aa3dce5114d0atagen2026-10-26 06:09 UTC107
    /// Files `subscription` under `sid`, keeping the pushkey if this session
aa3dce5114d0atagen2026-10-26 06:09 UTC108
    /// already had it. Returns the pushkey, and the session and pushkey that
aa3dce5114d0atagen2026-10-26 06:09 UTC109
    /// held the endpoint before when another session did.
aa3dce5114d0atagen2026-10-26 06:09 UTC110
    fn claim(&self, sid: &str, subscription: Subscription) -> (String, Option<(String, String)>) {
aa3dce5114d0atagen2026-10-26 06:09 UTC111
        let mut entries = self.entries();
aa3dce5114d0atagen2026-10-26 06:09 UTC112
        let held = entries
aa3dce5114d0atagen2026-10-26 06:09 UTC113
            .iter()
aa3dce5114d0atagen2026-10-26 06:09 UTC114
            .find(|(_, entry)| entry.subscription.endpoint == subscription.endpoint)
aa3dce5114d0atagen2026-10-26 06:09 UTC115
            .map(|(pushkey, entry)| (pushkey.clone(), entry.sid.clone()));
aa3dce5114d0atagen2026-10-26 06:09 UTC116
        let (pushkey, displaced) = match held {
aa3dce5114d0atagen2026-10-26 06:09 UTC117
            Some((pushkey, owner)) if owner == sid => (pushkey, None),
aa3dce5114d0atagen2026-10-26 06:09 UTC118
            Some((pushkey, owner)) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC119
                entries.remove(&pushkey);
aa3dce5114d0atagen2026-10-26 06:09 UTC120
                (new_pushkey(), Some((owner, pushkey)))
aa3dce5114d0atagen2026-10-26 06:09 UTC121
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC122
            None => (new_pushkey(), None),
aa3dce5114d0atagen2026-10-26 06:09 UTC123
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC124
        entries.insert(
aa3dce5114d0atagen2026-10-26 06:09 UTC125
            pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC126
            Entry {
aa3dce5114d0atagen2026-10-26 06:09 UTC127
                sid: sid.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC128
                subscription,
aa3dce5114d0atagen2026-10-26 06:09 UTC129
            },
aa3dce5114d0atagen2026-10-26 06:09 UTC130
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC131
        self.save(&entries);
aa3dce5114d0atagen2026-10-26 06:09 UTC132
        (pushkey, displaced)
aa3dce5114d0atagen2026-10-26 06:09 UTC133
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC134
aa3dce5114d0atagen2026-10-26 06:09 UTC135
    fn get(&self, pushkey: &str) -> Option<Entry> {
aa3dce5114d0atagen2026-10-26 06:09 UTC136
        self.entries().get(pushkey).cloned()
aa3dce5114d0atagen2026-10-26 06:09 UTC137
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC138
aa3dce5114d0atagen2026-10-26 06:09 UTC139
    fn find(&self, sid: &str, endpoint: &str) -> Option<String> {
aa3dce5114d0atagen2026-10-26 06:09 UTC140
        self.entries()
aa3dce5114d0atagen2026-10-26 06:09 UTC141
            .iter()
aa3dce5114d0atagen2026-10-26 06:09 UTC142
            .find(|(_, entry)| entry.sid == sid && entry.subscription.endpoint == endpoint)
aa3dce5114d0atagen2026-10-26 06:09 UTC143
            .map(|(pushkey, _)| pushkey.clone())
aa3dce5114d0atagen2026-10-26 06:09 UTC144
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC145
aa3dce5114d0atagen2026-10-26 06:09 UTC146
    fn forget(&self, pushkey: &str) {
aa3dce5114d0atagen2026-10-26 06:09 UTC147
        let mut entries = self.entries();
aa3dce5114d0atagen2026-10-26 06:09 UTC148
        if entries.remove(pushkey).is_some() {
aa3dce5114d0atagen2026-10-26 06:09 UTC149
            self.save(&entries);
aa3dce5114d0atagen2026-10-26 06:09 UTC150
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC151
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC152
aa3dce5114d0atagen2026-10-26 06:09 UTC153
    /// The pushers a logout of `sid` should take down with it.
aa3dce5114d0atagen2026-10-26 06:09 UTC154
    pub fn pusher_keys(&self, sid: &str) -> Vec<PusherKey> {
aa3dce5114d0atagen2026-10-26 06:09 UTC155
        self.entries()
aa3dce5114d0atagen2026-10-26 06:09 UTC156
            .iter()
aa3dce5114d0atagen2026-10-26 06:09 UTC157
            .filter(|(_, entry)| entry.sid == sid)
aa3dce5114d0atagen2026-10-26 06:09 UTC158
            .map(|(pushkey, _)| PusherKey {
aa3dce5114d0atagen2026-10-26 06:09 UTC159
                app_id: HOSTED_APP_ID.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC160
                pushkey: pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC161
            })
aa3dce5114d0atagen2026-10-26 06:09 UTC162
            .collect()
aa3dce5114d0atagen2026-10-26 06:09 UTC163
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC164
aa3dce5114d0atagen2026-10-26 06:09 UTC165
    pub fn forget_session(&self, sid: &str) {
aa3dce5114d0atagen2026-10-26 06:09 UTC166
        let mut entries = self.entries();
aa3dce5114d0atagen2026-10-26 06:09 UTC167
        let before = entries.len();
aa3dce5114d0atagen2026-10-26 06:09 UTC168
        entries.retain(|_, entry| entry.sid != sid);
aa3dce5114d0atagen2026-10-26 06:09 UTC169
        if entries.len() != before {
aa3dce5114d0atagen2026-10-26 06:09 UTC170
            self.save(&entries);
aa3dce5114d0atagen2026-10-26 06:09 UTC171
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC172
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC173
}
aa3dce5114d0atagen2026-10-26 06:09 UTC174
aa3dce5114d0atagen2026-10-26 06:09 UTC175
fn new_pushkey() -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC176
    let mut bytes = [0u8; 16];
aa3dce5114d0atagen2026-10-26 06:09 UTC177
    OsRng.fill_bytes(&mut bytes);
aa3dce5114d0atagen2026-10-26 06:09 UTC178
    hex::encode(bytes)
aa3dce5114d0atagen2026-10-26 06:09 UTC179
}
aa3dce5114d0atagen2026-10-26 06:09 UTC180
aa3dce5114d0atagen2026-10-26 06:09 UTC181
#[derive(Debug, Deserialize)]
aa3dce5114d0atagen2026-10-26 06:09 UTC182
struct SubscribeKeys {
aa3dce5114d0atagen2026-10-26 06:09 UTC183
    p256dh: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC184
    auth: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC185
}
aa3dce5114d0atagen2026-10-26 06:09 UTC186
aa3dce5114d0atagen2026-10-26 06:09 UTC187
#[derive(Debug, Deserialize)]
aa3dce5114d0atagen2026-10-26 06:09 UTC188
struct SubscribeBody {
aa3dce5114d0atagen2026-10-26 06:09 UTC189
    endpoint: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC190
    keys: SubscribeKeys,
aa3dce5114d0atagen2026-10-26 06:09 UTC191
}
aa3dce5114d0atagen2026-10-26 06:09 UTC192
aa3dce5114d0atagen2026-10-26 06:09 UTC193
#[derive(Debug, Deserialize)]
aa3dce5114d0atagen2026-10-26 06:09 UTC194
struct UnsubscribeBody {
aa3dce5114d0atagen2026-10-26 06:09 UTC195
    endpoint: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC196
}
aa3dce5114d0atagen2026-10-26 06:09 UTC197
aa3dce5114d0atagen2026-10-26 06:09 UTC198
pub fn api_routes() -> Router<AppState> {
aa3dce5114d0atagen2026-10-26 06:09 UTC199
    Router::new()
aa3dce5114d0atagen2026-10-26 06:09 UTC200
        .route("/push/vapid", get(vapid))
aa3dce5114d0atagen2026-10-26 06:09 UTC201
        .route("/push/subscribe", post(subscribe))
aa3dce5114d0atagen2026-10-26 06:09 UTC202
        .route("/push/unsubscribe", post(unsubscribe))
aa3dce5114d0atagen2026-10-26 06:09 UTC203
}
aa3dce5114d0atagen2026-10-26 06:09 UTC204
aa3dce5114d0atagen2026-10-26 06:09 UTC205
pub fn notify_route() -> Router<AppState> {
aa3dce5114d0atagen2026-10-26 06:09 UTC206
    Router::new()
aa3dce5114d0atagen2026-10-26 06:09 UTC207
        .route(NOTIFY_PATH, post(notify))
aa3dce5114d0atagen2026-10-26 06:09 UTC208
        .layer(DefaultBodyLimit::max(64 * 1024))
aa3dce5114d0atagen2026-10-26 06:09 UTC209
}
aa3dce5114d0atagen2026-10-26 06:09 UTC210
aa3dce5114d0atagen2026-10-26 06:09 UTC211
fn push_off() -> ApiError {
aa3dce5114d0atagen2026-10-26 06:09 UTC212
    ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "web push is not set up")
aa3dce5114d0atagen2026-10-26 06:09 UTC213
}
aa3dce5114d0atagen2026-10-26 06:09 UTC214
aa3dce5114d0atagen2026-10-26 06:09 UTC215
/// The push service, the public URL to point pushers at, or why push is off.
aa3dce5114d0atagen2026-10-26 06:09 UTC216
fn configured(state: &AppState) -> Result<(&HostedPush, &str), ApiError> {
aa3dce5114d0atagen2026-10-26 06:09 UTC217
    let push = state.push().ok_or_else(push_off)?;
aa3dce5114d0atagen2026-10-26 06:09 UTC218
    let url = push.public_url.as_deref().ok_or_else(push_off)?;
aa3dce5114d0atagen2026-10-26 06:09 UTC219
    Ok((push, url))
aa3dce5114d0atagen2026-10-26 06:09 UTC220
}
aa3dce5114d0atagen2026-10-26 06:09 UTC221
aa3dce5114d0atagen2026-10-26 06:09 UTC222
async fn vapid(State(state): State<AppState>) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC223
    match configured(&state) {
aa3dce5114d0atagen2026-10-26 06:09 UTC224
        Ok((push, _)) => json_ok(
aa3dce5114d0atagen2026-10-26 06:09 UTC225
            &serde_json::json!({ "public_key": push.sender.vapid_public_key() }),
aa3dce5114d0atagen2026-10-26 06:09 UTC226
            None,
aa3dce5114d0atagen2026-10-26 06:09 UTC227
        ),
aa3dce5114d0atagen2026-10-26 06:09 UTC228
        Err(error) => error.into_response(),
aa3dce5114d0atagen2026-10-26 06:09 UTC229
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC230
}
aa3dce5114d0atagen2026-10-26 06:09 UTC231
aa3dce5114d0atagen2026-10-26 06:09 UTC232
async fn subscribe(
aa3dce5114d0atagen2026-10-26 06:09 UTC233
    State(state): State<AppState>,
aa3dce5114d0atagen2026-10-26 06:09 UTC234
    headers: HeaderMap,
aa3dce5114d0atagen2026-10-26 06:09 UTC235
    body: axum::Json<SubscribeBody>,
aa3dce5114d0atagen2026-10-26 06:09 UTC236
) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC237
    let (push, public_url) = match configured(&state) {
aa3dce5114d0atagen2026-10-26 06:09 UTC238
        Ok(configured) => configured,
aa3dce5114d0atagen2026-10-26 06:09 UTC239
        Err(error) => return error.into_response(),
aa3dce5114d0atagen2026-10-26 06:09 UTC240
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC241
    let (user, sid) = match auth(&state, &headers).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC242
        Ok(pair) => pair,
aa3dce5114d0atagen2026-10-26 06:09 UTC243
        Err(error) => return error.into_response(),
aa3dce5114d0atagen2026-10-26 06:09 UTC244
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC245
    let subscription = Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC246
        endpoint: body.0.endpoint,
aa3dce5114d0atagen2026-10-26 06:09 UTC247
        p256dh: body.0.keys.p256dh,
aa3dce5114d0atagen2026-10-26 06:09 UTC248
        auth: body.0.keys.auth,
aa3dce5114d0atagen2026-10-26 06:09 UTC249
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC250
    if !push.sender.allows(&subscription.endpoint) {
aa3dce5114d0atagen2026-10-26 06:09 UTC251
        return ApiError::bad_request("endpoint is not a known push service").into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC252
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC253
    if !subscription.keys_valid() {
aa3dce5114d0atagen2026-10-26 06:09 UTC254
        return ApiError::bad_request("subscription keys are not valid").into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC255
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC256
    let (pushkey, displaced) = push.claim(&sid, subscription);
aa3dce5114d0atagen2026-10-26 06:09 UTC257
    if let Some((owner, pushkey)) = displaced {
aa3dce5114d0atagen2026-10-26 06:09 UTC258
        let state = state.clone();
aa3dce5114d0atagen2026-10-26 06:09 UTC259
        tokio::spawn(async move {
aa3dce5114d0atagen2026-10-26 06:09 UTC260
            if let Ok(owner) = state.get_or_restore(&owner).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC261
                let _ = owner
aa3dce5114d0atagen2026-10-26 06:09 UTC262
                    .handle
aa3dce5114d0atagen2026-10-26 06:09 UTC263
                    .remove_pusher(PusherKey {
aa3dce5114d0atagen2026-10-26 06:09 UTC264
                        app_id: HOSTED_APP_ID.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC265
                        pushkey,
aa3dce5114d0atagen2026-10-26 06:09 UTC266
                    })
aa3dce5114d0atagen2026-10-26 06:09 UTC267
                    .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC268
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC269
        });
aa3dce5114d0atagen2026-10-26 06:09 UTC270
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC271
    let pusher = HttpPusher {
aa3dce5114d0atagen2026-10-26 06:09 UTC272
        app_id: HOSTED_APP_ID.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC273
        pushkey: pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC274
        app_display_name: "malice".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC275
        device_display_name: "Browser".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC276
        lang: "en".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC277
        url: format!("{public_url}{NOTIFY_PATH}"),
aa3dce5114d0atagen2026-10-26 06:09 UTC278
        format: PushFormat::EventIdOnly,
aa3dce5114d0atagen2026-10-26 06:09 UTC279
        data: serde_json::Map::new(),
aa3dce5114d0atagen2026-10-26 06:09 UTC280
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC281
    match user.handle.set_pusher(pusher).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC282
        Ok(()) => no_content(None),
aa3dce5114d0atagen2026-10-26 06:09 UTC283
        Err(error) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC284
            push.forget(&pushkey);
aa3dce5114d0atagen2026-10-26 06:09 UTC285
            ApiError::from(malice_core::error::CoreError::from(error)).into_response()
aa3dce5114d0atagen2026-10-26 06:09 UTC286
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC287
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC288
}
aa3dce5114d0atagen2026-10-26 06:09 UTC289
aa3dce5114d0atagen2026-10-26 06:09 UTC290
async fn unsubscribe(
aa3dce5114d0atagen2026-10-26 06:09 UTC291
    State(state): State<AppState>,
aa3dce5114d0atagen2026-10-26 06:09 UTC292
    headers: HeaderMap,
aa3dce5114d0atagen2026-10-26 06:09 UTC293
    body: axum::Json<UnsubscribeBody>,
aa3dce5114d0atagen2026-10-26 06:09 UTC294
) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC295
    let Some(push) = state.push() else {
aa3dce5114d0atagen2026-10-26 06:09 UTC296
        return push_off().into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC297
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC298
    let (user, sid) = match auth(&state, &headers).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC299
        Ok(pair) => pair,
aa3dce5114d0atagen2026-10-26 06:09 UTC300
        Err(error) => return error.into_response(),
aa3dce5114d0atagen2026-10-26 06:09 UTC301
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC302
    let Some(pushkey) = push.find(&sid, &body.0.endpoint) else {
aa3dce5114d0atagen2026-10-26 06:09 UTC303
        return no_content(None);
aa3dce5114d0atagen2026-10-26 06:09 UTC304
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC305
    let removed = user
aa3dce5114d0atagen2026-10-26 06:09 UTC306
        .handle
aa3dce5114d0atagen2026-10-26 06:09 UTC307
        .remove_pusher(PusherKey {
aa3dce5114d0atagen2026-10-26 06:09 UTC308
            app_id: HOSTED_APP_ID.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC309
            pushkey: pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC310
        })
aa3dce5114d0atagen2026-10-26 06:09 UTC311
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC312
    match removed {
aa3dce5114d0atagen2026-10-26 06:09 UTC313
        Ok(()) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC314
            push.forget(&pushkey);
aa3dce5114d0atagen2026-10-26 06:09 UTC315
            no_content(None)
aa3dce5114d0atagen2026-10-26 06:09 UTC316
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC317
        Err(error) => ApiError::from(malice_core::error::CoreError::from(error)).into_response(),
aa3dce5114d0atagen2026-10-26 06:09 UTC318
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC319
}
aa3dce5114d0atagen2026-10-26 06:09 UTC320
aa3dce5114d0atagen2026-10-26 06:09 UTC321
async fn notify(State(state): State<AppState>, body: Bytes) -> Response {
aa3dce5114d0atagen2026-10-26 06:09 UTC322
    let Some(push) = state.push() else {
aa3dce5114d0atagen2026-10-26 06:09 UTC323
        return StatusCode::NOT_FOUND.into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC324
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC325
    let notification = match parse_notify(&body) {
aa3dce5114d0atagen2026-10-26 06:09 UTC326
        Ok(notification) => notification,
aa3dce5114d0atagen2026-10-26 06:09 UTC327
        Err(error) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC328
            return ApiError::bad_request(error.to_string()).into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC329
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC330
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC331
    if !notification.is_event() {
aa3dce5114d0atagen2026-10-26 06:09 UTC332
        return json_response(StatusCode::OK, notify_response(&[]));
aa3dce5114d0atagen2026-10-26 06:09 UTC333
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC334
    let Ok(_permit) = push.permits.acquire().await else {
aa3dce5114d0atagen2026-10-26 06:09 UTC335
        return StatusCode::SERVICE_UNAVAILABLE.into_response();
aa3dce5114d0atagen2026-10-26 06:09 UTC336
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC337
    let mut rejected = Vec::new();
aa3dce5114d0atagen2026-10-26 06:09 UTC338
    for device in &notification.devices {
aa3dce5114d0atagen2026-10-26 06:09 UTC339
        if deliver(&state, push, &notification, device).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC340
            push.forget(&device.pushkey);
aa3dce5114d0atagen2026-10-26 06:09 UTC341
            rejected.push(device.pushkey.clone());
aa3dce5114d0atagen2026-10-26 06:09 UTC342
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC343
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC344
    json_response(StatusCode::OK, notify_response(&rejected))
aa3dce5114d0atagen2026-10-26 06:09 UTC345
}
aa3dce5114d0atagen2026-10-26 06:09 UTC346
aa3dce5114d0atagen2026-10-26 06:09 UTC347
/// Resolves and sends one device's push. True when its pushkey should be rejected.
aa3dce5114d0atagen2026-10-26 06:09 UTC348
async fn deliver(
aa3dce5114d0atagen2026-10-26 06:09 UTC349
    state: &AppState,
aa3dce5114d0atagen2026-10-26 06:09 UTC350
    push: &HostedPush,
aa3dce5114d0atagen2026-10-26 06:09 UTC351
    notification: &GatewayNotification,
aa3dce5114d0atagen2026-10-26 06:09 UTC352
    device: &GatewayDevice,
aa3dce5114d0atagen2026-10-26 06:09 UTC353
) -> bool {
aa3dce5114d0atagen2026-10-26 06:09 UTC354
    let (Some(room_id), Some(event_id)) = (&notification.room_id, &notification.event_id) else {
aa3dce5114d0atagen2026-10-26 06:09 UTC355
        return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC356
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC357
    if device.app_id != HOSTED_APP_ID {
aa3dce5114d0atagen2026-10-26 06:09 UTC358
        return true;
aa3dce5114d0atagen2026-10-26 06:09 UTC359
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC360
    let Some(entry) = push.get(&device.pushkey) else {
aa3dce5114d0atagen2026-10-26 06:09 UTC361
        return true;
aa3dce5114d0atagen2026-10-26 06:09 UTC362
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC363
    let user = match state.get_or_restore(&entry.sid).await {
aa3dce5114d0atagen2026-10-26 06:09 UTC364
        Ok(user) => user,
aa3dce5114d0atagen2026-10-26 06:09 UTC365
        Err(RestoreError::Unavailable(error)) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC366
            tracing::warn!(%error, "push for a session that would not restore");
aa3dce5114d0atagen2026-10-26 06:09 UTC367
            return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC368
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC369
        Err(
aa3dce5114d0atagen2026-10-26 06:09 UTC370
            RestoreError::NotFound | RestoreError::Stale(_) | RestoreError::SoftLoggedOut { .. },
aa3dce5114d0atagen2026-10-26 06:09 UTC371
        ) => return true,
aa3dce5114d0atagen2026-10-26 06:09 UTC372
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC373
    let status = match timeout(
aa3dce5114d0atagen2026-10-26 06:09 UTC374
        RESOLVE_TIMEOUT,
aa3dce5114d0atagen2026-10-26 06:09 UTC375
        user.handle.resolve_push_notification(room_id, event_id),
aa3dce5114d0atagen2026-10-26 06:09 UTC376
    )
aa3dce5114d0atagen2026-10-26 06:09 UTC377
    .await
aa3dce5114d0atagen2026-10-26 06:09 UTC378
    {
aa3dce5114d0atagen2026-10-26 06:09 UTC379
        Ok(Ok(status)) => status,
aa3dce5114d0atagen2026-10-26 06:09 UTC380
        Ok(Err(error)) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC381
            tracing::warn!(%error, "resolving a push failed");
aa3dce5114d0atagen2026-10-26 06:09 UTC382
            return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC383
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC384
        Err(_) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC385
            tracing::warn!("resolving a push timed out");
aa3dce5114d0atagen2026-10-26 06:09 UTC386
            return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC387
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC388
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC389
    let PushNotificationStatus::Event(resolved) = status else {
aa3dce5114d0atagen2026-10-26 06:09 UTC390
        return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC391
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC392
    let unread = unread_total(&user.mirror.snapshot(), &resolved.room);
aa3dce5114d0atagen2026-10-26 06:09 UTC393
    let notice = notice(&resolved, unread);
aa3dce5114d0atagen2026-10-26 06:09 UTC394
    let urgency = if notice.mention || resolved.room.direct {
aa3dce5114d0atagen2026-10-26 06:09 UTC395
        Urgency::High
aa3dce5114d0atagen2026-10-26 06:09 UTC396
    } else {
aa3dce5114d0atagen2026-10-26 06:09 UTC397
        Urgency::Normal
aa3dce5114d0atagen2026-10-26 06:09 UTC398
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC399
    let outcome = push
aa3dce5114d0atagen2026-10-26 06:09 UTC400
        .sender
aa3dce5114d0atagen2026-10-26 06:09 UTC401
        .send(
aa3dce5114d0atagen2026-10-26 06:09 UTC402
            &entry.subscription,
aa3dce5114d0atagen2026-10-26 06:09 UTC403
            &Message {
aa3dce5114d0atagen2026-10-26 06:09 UTC404
                payload: &notice.to_bytes(push.public_url.as_deref().unwrap_or_default()),
aa3dce5114d0atagen2026-10-26 06:09 UTC405
                urgency,
aa3dce5114d0atagen2026-10-26 06:09 UTC406
                topic: &notice.room_id,
aa3dce5114d0atagen2026-10-26 06:09 UTC407
            },
aa3dce5114d0atagen2026-10-26 06:09 UTC408
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC409
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC410
    if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) {
aa3dce5114d0atagen2026-10-26 06:09 UTC411
        tracing::warn!(?outcome, "web push delivery failed");
aa3dce5114d0atagen2026-10-26 06:09 UTC412
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC413
    if !outcome.drops_subscription() {
aa3dce5114d0atagen2026-10-26 06:09 UTC414
        return false;
aa3dce5114d0atagen2026-10-26 06:09 UTC415
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC416
    // continuwuity 26.9 ignores a notify response's rejected pushkeys
aa3dce5114d0atagen2026-10-26 06:09 UTC417
    let _ = user
aa3dce5114d0atagen2026-10-26 06:09 UTC418
        .handle
aa3dce5114d0atagen2026-10-26 06:09 UTC419
        .remove_pusher(PusherKey {
aa3dce5114d0atagen2026-10-26 06:09 UTC420
            app_id: HOSTED_APP_ID.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC421
            pushkey: device.pushkey.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC422
        })
aa3dce5114d0atagen2026-10-26 06:09 UTC423
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC424
    true
aa3dce5114d0atagen2026-10-26 06:09 UTC425
}
aa3dce5114d0atagen2026-10-26 06:09 UTC426
aa3dce5114d0atagen2026-10-26 06:09 UTC427
#[cfg(test)]
aa3dce5114d0atagen2026-10-26 06:09 UTC428
mod tests {
aa3dce5114d0atagen2026-10-26 06:09 UTC429
    use axum::{body::Body, http::Request};
aa3dce5114d0atagen2026-10-26 06:09 UTC430
    use http_body_util::BodyExt;
aa3dce5114d0atagen2026-10-26 06:09 UTC431
    use malice_push::{Allowlist, Vapid};
aa3dce5114d0atagen2026-10-26 06:09 UTC432
    use serde_json::{Value, json};
aa3dce5114d0atagen2026-10-26 06:09 UTC433
    use tower::ServiceExt;
aa3dce5114d0atagen2026-10-26 06:09 UTC434
aa3dce5114d0atagen2026-10-26 06:09 UTC435
    use super::*;
aa3dce5114d0atagen2026-10-26 06:09 UTC436
aa3dce5114d0atagen2026-10-26 06:09 UTC437
    fn hosted(dir: &tempfile::TempDir) -> HostedPush {
aa3dce5114d0atagen2026-10-26 06:09 UTC438
        let sender = Sender::new(
aa3dce5114d0atagen2026-10-26 06:09 UTC439
            Vapid::load_or_create(&dir.path().join("vapid.pk8")).unwrap(),
aa3dce5114d0atagen2026-10-26 06:09 UTC440
            "mailto:test@example.org".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC441
            Allowlist::default(),
aa3dce5114d0atagen2026-10-26 06:09 UTC442
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC443
        .unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC444
        HostedPush::new(
aa3dce5114d0atagen2026-10-26 06:09 UTC445
            dir.path(),
aa3dce5114d0atagen2026-10-26 06:09 UTC446
            Some("https://malice.example".to_owned()),
aa3dce5114d0atagen2026-10-26 06:09 UTC447
            sender,
aa3dce5114d0atagen2026-10-26 06:09 UTC448
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC449
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC450
aa3dce5114d0atagen2026-10-26 06:09 UTC451
    fn subscription(endpoint: &str) -> Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC452
        Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC453
            endpoint: endpoint.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC454
            p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC455
            auth: "BTBZMqHH6r4Tts7J_aSIgg".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC456
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC457
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC458
aa3dce5114d0atagen2026-10-26 06:09 UTC459
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC460
    fn registry_persists_and_moves_endpoints_between_sessions() {
aa3dce5114d0atagen2026-10-26 06:09 UTC461
        let dir = tempfile::tempdir().unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC462
        let push = hosted(&dir);
aa3dce5114d0atagen2026-10-26 06:09 UTC463
        let (first, displaced) = push.claim("alpha", subscription("https://fcm.googleapis.com/a"));
aa3dce5114d0atagen2026-10-26 06:09 UTC464
        assert!(displaced.is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC465
        let (again, _) = push.claim("alpha", subscription("https://fcm.googleapis.com/a"));
aa3dce5114d0atagen2026-10-26 06:09 UTC466
        assert_eq!(first, again);
aa3dce5114d0atagen2026-10-26 06:09 UTC467
        let (moved, displaced) = push.claim("beta", subscription("https://fcm.googleapis.com/a"));
aa3dce5114d0atagen2026-10-26 06:09 UTC468
        assert_ne!(moved, first);
aa3dce5114d0atagen2026-10-26 06:09 UTC469
        assert_eq!(displaced, Some(("alpha".to_owned(), first.clone())));
aa3dce5114d0atagen2026-10-26 06:09 UTC470
        push.claim("beta", subscription("https://fcm.googleapis.com/b"));
aa3dce5114d0atagen2026-10-26 06:09 UTC471
aa3dce5114d0atagen2026-10-26 06:09 UTC472
        let reloaded = hosted(&dir);
aa3dce5114d0atagen2026-10-26 06:09 UTC473
        assert_eq!(reloaded.pusher_keys("beta").len(), 2);
aa3dce5114d0atagen2026-10-26 06:09 UTC474
        assert!(reloaded.pusher_keys("alpha").is_empty());
aa3dce5114d0atagen2026-10-26 06:09 UTC475
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC476
            reloaded.find("beta", "https://fcm.googleapis.com/a"),
aa3dce5114d0atagen2026-10-26 06:09 UTC477
            Some(moved)
aa3dce5114d0atagen2026-10-26 06:09 UTC478
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC479
        reloaded.forget_session("beta");
aa3dce5114d0atagen2026-10-26 06:09 UTC480
        assert!(hosted(&dir).pusher_keys("beta").is_empty());
aa3dce5114d0atagen2026-10-26 06:09 UTC481
        #[cfg(unix)]
aa3dce5114d0atagen2026-10-26 06:09 UTC482
        {
aa3dce5114d0atagen2026-10-26 06:09 UTC483
            use std::os::unix::fs::PermissionsExt;
aa3dce5114d0atagen2026-10-26 06:09 UTC484
            let mode = std::fs::metadata(dir.path().join("push.json"))
aa3dce5114d0atagen2026-10-26 06:09 UTC485
                .unwrap()
aa3dce5114d0atagen2026-10-26 06:09 UTC486
                .permissions()
aa3dce5114d0atagen2026-10-26 06:09 UTC487
                .mode();
aa3dce5114d0atagen2026-10-26 06:09 UTC488
            assert_eq!(mode & 0o777, 0o600);
aa3dce5114d0atagen2026-10-26 06:09 UTC489
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC490
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC491
aa3dce5114d0atagen2026-10-26 06:09 UTC492
    async fn notify(state: &AppState, notification: Value) -> (StatusCode, Value) {
aa3dce5114d0atagen2026-10-26 06:09 UTC493
        let response = crate::routes::router(state.clone(), None)
aa3dce5114d0atagen2026-10-26 06:09 UTC494
            .oneshot(
aa3dce5114d0atagen2026-10-26 06:09 UTC495
                Request::post(NOTIFY_PATH)
aa3dce5114d0atagen2026-10-26 06:09 UTC496
                    .body(Body::from(
aa3dce5114d0atagen2026-10-26 06:09 UTC497
                        json!({ "notification": notification }).to_string(),
aa3dce5114d0atagen2026-10-26 06:09 UTC498
                    ))
aa3dce5114d0atagen2026-10-26 06:09 UTC499
                    .unwrap(),
aa3dce5114d0atagen2026-10-26 06:09 UTC500
            )
aa3dce5114d0atagen2026-10-26 06:09 UTC501
            .await
aa3dce5114d0atagen2026-10-26 06:09 UTC502
            .unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC503
        let status = response.status();
aa3dce5114d0atagen2026-10-26 06:09 UTC504
        let body = response.into_body().collect().await.unwrap().to_bytes();
aa3dce5114d0atagen2026-10-26 06:09 UTC505
        (status, serde_json::from_slice(&body).unwrap_or(Value::Null))
aa3dce5114d0atagen2026-10-26 06:09 UTC506
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC507
aa3dce5114d0atagen2026-10-26 06:09 UTC508
    #[tokio::test]
aa3dce5114d0atagen2026-10-26 06:09 UTC509
    async fn notify_drops_count_only_pushes_and_rejects_unknown_pushers() {
aa3dce5114d0atagen2026-10-26 06:09 UTC510
        let dir = tempfile::tempdir().unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC511
        let push = hosted(&dir);
aa3dce5114d0atagen2026-10-26 06:09 UTC512
        let (orphan, _) = push.claim(
aa3dce5114d0atagen2026-10-26 06:09 UTC513
            "nosuchsession",
aa3dce5114d0atagen2026-10-26 06:09 UTC514
            subscription("https://fcm.googleapis.com/o"),
aa3dce5114d0atagen2026-10-26 06:09 UTC515
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC516
        let state = AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(push);
aa3dce5114d0atagen2026-10-26 06:09 UTC517
        let device = |app_id: &str, pushkey: &str| json!({"app_id": app_id, "pushkey": pushkey});
aa3dce5114d0atagen2026-10-26 06:09 UTC518
aa3dce5114d0atagen2026-10-26 06:09 UTC519
        let (status, body) = notify(
aa3dce5114d0atagen2026-10-26 06:09 UTC520
            &state,
aa3dce5114d0atagen2026-10-26 06:09 UTC521
            json!({"counts": {"unread": 1}, "devices": [device(HOSTED_APP_ID, &orphan)]}),
aa3dce5114d0atagen2026-10-26 06:09 UTC522
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC523
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC524
        assert_eq!(status, StatusCode::OK);
aa3dce5114d0atagen2026-10-26 06:09 UTC525
        assert_eq!(body, json!({"rejected": []}));
aa3dce5114d0atagen2026-10-26 06:09 UTC526
        assert_eq!(state.push().unwrap().pusher_keys("nosuchsession").len(), 1);
aa3dce5114d0atagen2026-10-26 06:09 UTC527
aa3dce5114d0atagen2026-10-26 06:09 UTC528
        let (status, body) = notify(
aa3dce5114d0atagen2026-10-26 06:09 UTC529
            &state,
aa3dce5114d0atagen2026-10-26 06:09 UTC530
            json!({
aa3dce5114d0atagen2026-10-26 06:09 UTC531
                "event_id": "$e:x",
aa3dce5114d0atagen2026-10-26 06:09 UTC532
                "room_id": "!r:x",
aa3dce5114d0atagen2026-10-26 06:09 UTC533
                "devices": [
aa3dce5114d0atagen2026-10-26 06:09 UTC534
                    device(HOSTED_APP_ID, "unknown"),
aa3dce5114d0atagen2026-10-26 06:09 UTC535
                    device("dev.malice.device", "foreign"),
aa3dce5114d0atagen2026-10-26 06:09 UTC536
                    device(HOSTED_APP_ID, &orphan),
aa3dce5114d0atagen2026-10-26 06:09 UTC537
                ],
aa3dce5114d0atagen2026-10-26 06:09 UTC538
            }),
aa3dce5114d0atagen2026-10-26 06:09 UTC539
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC540
        .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC541
        assert_eq!(status, StatusCode::OK);
aa3dce5114d0atagen2026-10-26 06:09 UTC542
        assert_eq!(body, json!({"rejected": ["unknown", "foreign", orphan]}));
aa3dce5114d0atagen2026-10-26 06:09 UTC543
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC544
            state
aa3dce5114d0atagen2026-10-26 06:09 UTC545
                .push()
aa3dce5114d0atagen2026-10-26 06:09 UTC546
                .unwrap()
aa3dce5114d0atagen2026-10-26 06:09 UTC547
                .pusher_keys("nosuchsession")
aa3dce5114d0atagen2026-10-26 06:09 UTC548
                .is_empty()
aa3dce5114d0atagen2026-10-26 06:09 UTC549
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC550
aa3dce5114d0atagen2026-10-26 06:09 UTC551
        let (status, _) = notify(&state, json!("not a notification")).await;
aa3dce5114d0atagen2026-10-26 06:09 UTC552
        assert_eq!(status, StatusCode::BAD_REQUEST);
aa3dce5114d0atagen2026-10-26 06:09 UTC553
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC554
aa3dce5114d0atagen2026-10-26 06:09 UTC555
    #[tokio::test]
aa3dce5114d0atagen2026-10-26 06:09 UTC556
    async fn vapid_needs_a_public_url() {
aa3dce5114d0atagen2026-10-26 06:09 UTC557
        let dir = tempfile::tempdir().unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC558
        let mut push = hosted(&dir);
aa3dce5114d0atagen2026-10-26 06:09 UTC559
        let key = push.sender.vapid_public_key().to_owned();
aa3dce5114d0atagen2026-10-26 06:09 UTC560
        let state =
aa3dce5114d0atagen2026-10-26 06:09 UTC561
            AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(hosted(&dir));
aa3dce5114d0atagen2026-10-26 06:09 UTC562
        let get = |state: &AppState| {
aa3dce5114d0atagen2026-10-26 06:09 UTC563
            crate::routes::router(state.clone(), None)
aa3dce5114d0atagen2026-10-26 06:09 UTC564
                .oneshot(Request::get("/api/push/vapid").body(Body::empty()).unwrap())
aa3dce5114d0atagen2026-10-26 06:09 UTC565
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC566
        let response = get(&state).await.unwrap();
aa3dce5114d0atagen2026-10-26 06:09 UTC567
        assert_eq!(response.status(), StatusCode::OK);
aa3dce5114d0atagen2026-10-26 06:09 UTC568
        let body = response.into_body().collect().await.unwrap().to_bytes();
aa3dce5114d0atagen2026-10-26 06:09 UTC569
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC570
            serde_json::from_slice::<Value>(&body).unwrap()["public_key"],
aa3dce5114d0atagen2026-10-26 06:09 UTC571
            key
aa3dce5114d0atagen2026-10-26 06:09 UTC572
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC573
        push.public_url = None;
aa3dce5114d0atagen2026-10-26 06:09 UTC574
        let state = AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(push);
aa3dce5114d0atagen2026-10-26 06:09 UTC575
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC576
            get(&state).await.unwrap().status(),
aa3dce5114d0atagen2026-10-26 06:09 UTC577
            StatusCode::SERVICE_UNAVAILABLE
aa3dce5114d0atagen2026-10-26 06:09 UTC578
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC579
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC580
}