feat(push): Add MSC4174 web push

This commit is contained in:
Erwan Leboucher authored and Ellis Git committed 2026-09-21 23:35:05 +00:00
1 parent dbcffe8af0
commit ed1e634579
23 files changed
+1864 -281

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ unstable-msc4203 = []
[dependencies]
assign.workspace = true
http.workspace = true
ruma.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
+1
View File
@@ -5,3 +5,4 @@ pub mod draupnir_antispam;
pub mod invite_permission_config;
pub mod meowlnir_antispam;
pub mod policy;
pub mod pushers;
+36
View File
@@ -0,0 +1,36 @@
pub mod unstable {
use ruma::{
api::{auth_scheme::AccessToken, error::Error as MatrixError, request, response},
metadata,
};
metadata! {
method: POST,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4174/pushers/ack",
}
}
#[request(error = MatrixError)]
pub struct Request {
pub app_id: String,
pub ack_token: String,
}
#[response(error = MatrixError)]
#[derive(Default)]
#[allow(clippy::empty_structs_with_brackets, reason = "required by #[response]")]
pub struct Response {}
impl Request {
#[must_use]
pub fn new(app_id: String, ack_token: String) -> Self { Self { app_id, ack_token } }
}
impl Response {
#[must_use]
pub fn new() -> Self { Self {} }
}
}
+69
View File
@@ -0,0 +1,69 @@
pub mod v3 {
use ruma::{
api::{auth_scheme::AccessToken, error::Error as MatrixError, request, response},
metadata,
serde::from_raw_json_value,
};
use serde::{Deserialize, Serialize, de};
use serde_json::value::RawValue as RawJsonValue;
use crate::pushers::Pusher;
metadata! {
method: GET,
rate_limited: false,
authentication: AccessToken,
history: {
1.0 => "/_matrix/client/r0/pushers",
1.1 => "/_matrix/client/v3/pushers",
}
}
#[request(error = MatrixError)]
#[derive(Default)]
#[allow(clippy::empty_structs_with_brackets, reason = "required by #[request]")]
pub struct Request {}
#[response(error = MatrixError)]
pub struct Response {
pub pushers: Vec<PusherWithActivation>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PusherWithActivation {
#[serde(flatten)]
pub pusher: Pusher,
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
#[derive(Deserialize)]
struct ActivationDeHelper {
activated: Option<bool>,
}
impl<'de> Deserialize<'de> for PusherWithActivation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let ActivationDeHelper { activated } = from_raw_json_value(&json)?;
let pusher = from_raw_json_value(&json)?;
Ok(Self { pusher, activated })
}
}
impl Request {
#[must_use]
pub fn new() -> Self { Self {} }
}
impl Response {
#[must_use]
pub fn new(pushers: Vec<PusherWithActivation>) -> Self { Self { pushers } }
}
}
+87
View File
@@ -0,0 +1,87 @@
pub mod ack;
pub mod get_pushers;
mod pusher_serde;
pub mod set_pusher;
use std::fmt;
pub use ruma::api::client::push::{EmailPusherData, PusherIds};
use ruma::{
push::{HttpPusherData, PushFormat},
serde::JsonObject,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
pub struct Pusher {
#[serde(flatten)]
pub ids: PusherIds,
#[serde(flatten)]
pub kind: PusherKind,
pub app_display_name: String,
pub device_display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_tag: Option<String>,
pub lang: String,
}
#[derive(Clone, Debug)]
pub enum PusherKind {
Http(HttpPusherData),
Email(EmailPusherData),
WebPush(WebPushPusherData),
Custom {
kind: String,
data: JsonObject,
},
}
#[derive(Clone, Serialize, Deserialize)]
pub struct WebPushPusherData {
pub url: String,
pub auth: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<PushFormat>,
#[serde(flatten, default, skip_serializing_if = "JsonObject::is_empty")]
pub data: JsonObject,
}
impl fmt::Debug for WebPushPusherData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebPushPusherData")
.field("url", &self.url)
.field("auth", &"<redacted>")
.field("format", &self.format)
.field("data", &self.data)
.finish()
}
}
impl PusherKind {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
| Self::Http(_) => "http",
| Self::Email(_) => "email",
| Self::WebPush(_) => "org.matrix.msc4174.webpush",
| Self::Custom { kind, .. } => kind,
}
}
#[must_use]
pub fn as_webpush(&self) -> Option<&WebPushPusherData> {
match self {
| Self::WebPush(data) => Some(data),
| _ => None,
}
}
}
+196
View File
@@ -0,0 +1,196 @@
use ruma::serde::from_raw_json_value;
use serde::{Deserialize, Serialize, de, ser::SerializeStruct};
use serde_json::value::RawValue as RawJsonValue;
use super::{Pusher, PusherIds, PusherKind};
#[derive(Debug, Deserialize)]
struct PusherDeHelper {
#[serde(flatten)]
ids: PusherIds,
app_display_name: String,
device_display_name: String,
profile_tag: Option<String>,
lang: String,
}
impl<'de> Deserialize<'de> for Pusher {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let PusherDeHelper {
ids,
app_display_name,
device_display_name,
profile_tag,
lang,
} = from_raw_json_value(&json)?;
let kind = from_raw_json_value(&json)?;
Ok(Self {
ids,
kind,
app_display_name,
device_display_name,
profile_tag,
lang,
})
}
}
impl Serialize for PusherKind {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut st = serializer.serialize_struct("PusherKind", 2)?;
st.serialize_field("kind", self.as_str())?;
match self {
| Self::Http(data) => st.serialize_field("data", data)?,
| Self::Email(data) => st.serialize_field("data", data)?,
| Self::WebPush(data) => st.serialize_field("data", data)?,
| Self::Custom { data, .. } => st.serialize_field("data", data)?,
}
st.end()
}
}
#[derive(Debug, Deserialize)]
struct PusherKindDeHelper {
kind: String,
data: Box<RawJsonValue>,
}
impl<'de> Deserialize<'de> for PusherKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let PusherKindDeHelper { kind, data } = from_raw_json_value(&json)?;
match kind.as_str() {
| "http" => from_raw_json_value(&data).map(Self::Http),
| "email" => from_raw_json_value(&data).map(Self::Email),
| "org.matrix.msc4174.webpush" => from_raw_json_value(&data).map(Self::WebPush),
| _ => Ok(Self::Custom { kind, data: from_raw_json_value(&data)? }),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
use super::{Pusher, PusherKind};
#[test]
fn roundtrip_ruma_http_pusher() {
let stored = json!({
"pushkey": "abcdef",
"app_id": "my.matrix.app",
"kind": "http",
"data": {
"url": "https://push.example.org/_matrix/push/v1/notify",
"format": "event_id_only",
"default_payload": { "session_id": "abc" },
},
"app_display_name": "My Matrix App",
"device_display_name": "My Phone",
"profile_tag": "tag",
"lang": "en",
});
let pusher: Pusher = from_json_value(stored.clone()).unwrap();
assert!(matches!(pusher.kind, PusherKind::Http(_)));
assert_eq!(to_json_value(&pusher).unwrap(), stored);
}
#[test]
fn deserialize_legacy_append_field() {
let stored = json!({
"pushkey": "abcdef",
"app_id": "my.matrix.app",
"kind": "email",
"data": {},
"append": true,
"app_display_name": "My Matrix App",
"device_display_name": "My Phone",
"lang": "en",
});
let pusher: Pusher = from_json_value(stored).unwrap();
assert!(matches!(pusher.kind, PusherKind::Email(_)));
assert_eq!(pusher.profile_tag, None);
}
#[test]
fn roundtrip_webpush_pusher() {
let stored = json!({
"pushkey": "BLn9b-VR0ca83knDNZ32dCHGyjJp",
"app_id": "m.webpush",
"kind": "org.matrix.msc4174.webpush",
"data": {
"url": "https://updates.push.services.mozilla.com/wpush/v2/abc",
"auth": "_ordMnz7uTCmrpBTeUV4Bw",
"format": "event_id_only",
"default_payload": { "session_id": "abc" },
},
"app_display_name": "WebPush",
"device_display_name": "Firefox",
"lang": "en",
});
let pusher: Pusher = from_json_value(stored.clone()).unwrap();
let data = pusher.kind.as_webpush().unwrap();
assert_eq!(data.auth, "_ordMnz7uTCmrpBTeUV4Bw");
assert_eq!(data.data.len(), 1);
assert_eq!(to_json_value(&pusher).unwrap(), stored);
}
#[test]
fn roundtrip_pusher_with_activation() {
use crate::pushers::get_pushers::v3::PusherWithActivation;
let wire = json!({
"pushkey": "BLn9b-VR0ca83knDNZ32dCHGyjJp",
"app_id": "m.webpush",
"kind": "org.matrix.msc4174.webpush",
"data": {
"url": "https://updates.push.services.mozilla.com/wpush/v2/abc",
"auth": "_ordMnz7uTCmrpBTeUV4Bw",
},
"app_display_name": "WebPush",
"device_display_name": "Firefox",
"lang": "en",
"activated": true,
});
let pusher: PusherWithActivation = from_json_value(wire.clone()).unwrap();
assert_eq!(pusher.activated, Some(true));
assert_eq!(pusher.pusher.kind.as_str(), "org.matrix.msc4174.webpush");
assert_eq!(to_json_value(&pusher).unwrap(), wire);
}
#[test]
fn roundtrip_unknown_kind() {
let stored = json!({
"pushkey": "abcdef",
"app_id": "my.matrix.app",
"kind": "my.custom.kind",
"data": { "whatever": 1 },
"app_display_name": "App",
"device_display_name": "Device",
"lang": "en",
});
let pusher: Pusher = from_json_value(stored.clone()).unwrap();
assert_eq!(pusher.kind.as_str(), "my.custom.kind");
assert_eq!(to_json_value(&pusher).unwrap(), stored);
}
}
+129
View File
@@ -0,0 +1,129 @@
pub mod v3 {
use ruma::{
JsOption,
api::{
EmptyBody, IncomingResponse, OutgoingResponse,
auth_scheme::AccessToken,
error::{DeserializationError, Error as MatrixError, IntoHttpError},
request,
},
metadata,
serde::from_raw_json_value,
};
use serde::{Deserialize, Serialize, de};
use serde_json::value::RawValue as RawJsonValue;
use crate::pushers::{Pusher, PusherIds};
metadata! {
method: POST,
rate_limited: true,
authentication: AccessToken,
history: {
1.0 => "/_matrix/client/r0/pushers/set",
1.1 => "/_matrix/client/v3/pushers/set",
}
}
#[request(error = MatrixError)]
pub struct Request {
#[ruma_api(body)]
pub action: PusherAction,
}
#[derive(Clone, Debug, Default)]
pub struct Response {
pub needs_activation: bool,
}
impl Request {
#[must_use]
pub fn new(action: PusherAction) -> Self { Self { action } }
}
impl Response {
#[must_use]
pub fn new(needs_activation: bool) -> Self { Self { needs_activation } }
}
#[cfg(feature = "server")]
impl OutgoingResponse for Response {
type Body = EmptyBody<false>;
fn try_into_http_response_inner(
self,
) -> Result<http::Response<Self::Body>, IntoHttpError> {
let status = if self.needs_activation {
http::StatusCode::CREATED
} else {
http::StatusCode::OK
};
http::Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, ruma::http_headers::APPLICATION_JSON)
.body(EmptyBody)
.map_err(Into::into)
}
}
#[cfg(feature = "client")]
impl IncomingResponse for Response {
type EndpointError = MatrixError;
fn try_from_http_response_inner(
response: http::Response<&[u8]>,
) -> Result<Self, DeserializationError> {
Ok(Self {
needs_activation: response.status() == http::StatusCode::CREATED,
})
}
}
#[derive(Clone, Debug)]
pub enum PusherAction {
Post(Box<Pusher>),
Delete(PusherIds),
}
#[derive(Debug, Deserialize)]
struct PusherActionDeHelper {
kind: JsOption<String>,
}
impl<'de> Deserialize<'de> for PusherAction {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let json = Box::<RawJsonValue>::deserialize(deserializer)?;
let PusherActionDeHelper { kind } = from_raw_json_value(&json)?;
match kind {
| JsOption::Some(_) => Ok(Self::Post(from_raw_json_value(&json)?)),
| JsOption::Null => Ok(Self::Delete(from_raw_json_value(&json)?)),
| JsOption::Undefined => Err(de::Error::missing_field("kind")),
}
}
}
impl Serialize for PusherAction {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
| Self::Post(pusher) => pusher.serialize(serializer),
| Self::Delete(ids) => {
use serde::ser::SerializeStruct;
let mut st = serializer.serialize_struct("PusherAction", 3)?;
st.serialize_field("pushkey", &ids.pushkey)?;
st.serialize_field("app_id", &ids.app_id)?;
st.serialize_field("kind", &None::<&str>)?;
st.end()
},
}
}
}
}