Feat: Push notification server (#387)

* feat: create base of push daemon

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* Add outbound senders

* Make web_push send to rabbit instead (temp stuff)

* feat: stability and friend requests

* make vapid fr stuff not suck

* swap naming of queue

* move pushd into daemons folder

* fix cargo file for move into daemons folder

* feat: probably working fcm push notifs

* comment out fcm webpush stuff since the config keys dont exist

* fix fcm, name queues according to their prod status and configure routing keys

* add pushd to docker

* mix: Remove old code, add stuff to pushd

* fix: lockfile

* feat: update rocket to 5.0.1

* fix: fix queues and ack bugs

* Move rabbit messsage processing into ack queue

* chore: update readme

* chore: optimizations for ack database hits

* pushd flowchart

* misc: update flowchart

* exit dependancy hell

* add rocket_impl flag to authifier

* make the tests file of delta actually compile

* fix: don't silence every push message

* fix: don't silence all messages

* add debug logging for sending data to rabbit from message events

* validate mentions at a server membership level

* put back that import that was actually important

* minor fix to lockfile

* update delta authifier

* feat: proper permissions for push notifications

* add unit test for mention sanitization

* remove local file dependancy on authifier

* update ports to proper defaults

* fixTM the node bindings

* Theoretically configure docker releases for pushd

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* declare exchange in pushd and delta

* fix createbuckets script

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: reference db implementation

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: remove finally redundant code

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: changes

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: other changes

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: make channel name return result

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
This commit is contained in:
Tom
2024-11-28 13:34:17 -08:00
committed by GitHub
parent ed5ded5e45
commit b55765d7c7
71 changed files with 3463 additions and 1059 deletions

View File

@@ -0,0 +1,338 @@
use std::{borrow::Cow, collections::BTreeMap, io::Cursor};
use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use async_trait::async_trait;
use base64::{
engine::{self},
Engine as _,
};
use revolt_a2::{
request::{
notification::{DefaultAlert, NotificationOptions},
payload::{APSAlert, APSSound, Payload, PayloadLike, APS},
},
Client, ClientConfig, Endpoint, Error, ErrorBody, ErrorReason, Priority, PushType, Response,
};
use revolt_database::{events::rabbit::*, Database};
use revolt_models::v0::{Channel, Message, PushNotification};
use serde::Serialize;
// region: payload
#[derive(Serialize, Debug)]
struct MessagePayload<'a> {
aps: APS<'a>,
#[serde(skip_serializing)]
options: NotificationOptions<'a>,
#[serde(skip_serializing)]
device_token: &'a str,
message: &'a Message,
url: &'a str,
#[serde(rename = "camelCase")]
author_avatar: &'a str,
#[serde(rename = "camelCase")]
author_display_name: &'a str,
#[serde(rename = "camelCase")]
channel_name: &'a str,
}
impl<'a> PayloadLike for MessagePayload<'a> {
fn get_device_token(&self) -> &'a str {
self.device_token
}
fn get_options(&self) -> &NotificationOptions {
&self.options
}
}
// region: consumer
pub struct ApnsOutboundConsumer {
#[allow(dead_code)]
db: Database,
client: Client,
}
impl ApnsOutboundConsumer {
fn format_title(&self, notification: &PushNotification) -> String {
// ideally this changes depending on context
// in a server, it would look like "Sendername, #channelname in servername"
// in a group, it would look like "Sendername in groupname"
// in a dm it should just be "Sendername".
// not sure how feasible all those are given the PushNotification object as it currently stands.
match &notification.channel {
Channel::DirectMessage { .. } => notification.author.clone(),
Channel::Group { name, .. } => format!("{}, #{}", notification.author, name),
Channel::TextChannel { name, .. } | Channel::VoiceChannel { name, .. } => {
format!("{} in #{}", notification.author, name)
}
_ => "Unknown".to_string(),
}
}
async fn get_badge_count(&self, user: &str) -> Option<u32> {
if let Ok(unreads) = self.db.fetch_unread_mentions(user).await {
let mut mention_count = 0;
for channel in unreads {
if let Some(mentions) = channel.mentions {
mention_count += mentions.len() as u32
}
}
println!("Got badge count for APN: {}", mention_count);
return Some(mention_count);
}
None
}
}
impl ApnsOutboundConsumer {
pub async fn new(db: Database) -> Result<ApnsOutboundConsumer, &'static str> {
let config = revolt_config::config().await;
if config.pushd.apn.pkcs8.is_empty()
|| config.pushd.apn.key_id.is_empty()
|| config.pushd.apn.team_id.is_empty()
{
return Err("Missing APN keys.");
}
let endpoint = if config.pushd.apn.sandbox {
Endpoint::Sandbox
} else {
Endpoint::Production
};
let pkcs8 = engine::general_purpose::STANDARD
.decode(config.pushd.apn.pkcs8.clone())
.expect("valid `pcks8`");
let client_config = ClientConfig::new(endpoint);
let client = Client::token(
&mut Cursor::new(pkcs8),
config.pushd.apn.key_id.clone(),
config.pushd.apn.team_id.clone(),
client_config,
)
.expect("could not create APN client");
Ok(ApnsOutboundConsumer { db, client })
}
}
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for ApnsOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
let content = String::from_utf8(content).unwrap();
let payload: PayloadToService = serde_json::from_str(content.as_str()).unwrap();
let payload_options = NotificationOptions {
apns_id: None,
apns_push_type: Some(PushType::Alert),
apns_expiration: None,
apns_priority: Some(Priority::High),
apns_topic: Some("chat.revolt.app"),
apns_collapse_id: None,
};
let resp: Result<Response, Error>;
match payload.notification {
PayloadKind::FRReceived(alert) => {
let loc_args = vec![Cow::from(
alert
.from_user
.display_name
.or(Some(format!(
"{}#{}",
alert.from_user.username, alert.from_user.discriminator
)))
.clone()
.unwrap(),
)];
let apn_payload = Payload {
aps: APS {
alert: Some(APSAlert::Default(DefaultAlert {
title: None,
subtitle: None,
body: None,
title_loc_key: None,
title_loc_args: None,
action_loc_key: None,
loc_key: Some("push.fr.received"),
loc_args: Some(loc_args),
launch_image: None,
})),
badge: self.get_badge_count(&payload.user_id).await,
sound: Some(APSSound::Sound("default")),
thread_id: None,
content_available: None,
category: None,
mutable_content: Some(1),
url_args: None,
},
device_token: &payload.token,
options: payload_options.clone(),
data: BTreeMap::new(),
};
resp = self.client.send(apn_payload).await;
}
PayloadKind::FRAccepted(alert) => {
let loc_args = vec![Cow::from(
alert
.accepted_user
.display_name
.or(Some(format!(
"{}#{}",
alert.accepted_user.username, alert.accepted_user.discriminator
)))
.clone()
.unwrap(),
)];
let apn_payload = Payload {
aps: APS {
alert: Some(APSAlert::Default(DefaultAlert {
title: None,
subtitle: None,
body: None,
title_loc_key: None,
title_loc_args: None,
action_loc_key: None,
loc_key: Some("push.fr.accepted"),
loc_args: Some(loc_args),
launch_image: None,
})),
badge: self.get_badge_count(&payload.user_id).await,
sound: Some(APSSound::Sound("default")),
thread_id: None,
content_available: None,
category: None,
mutable_content: Some(1),
url_args: None,
},
device_token: &payload.token,
options: payload_options.clone(),
data: BTreeMap::new(),
};
resp = self.client.send(apn_payload).await;
}
PayloadKind::Generic(alert) => {
let apn_payload = Payload {
aps: APS {
alert: Some(APSAlert::Default(DefaultAlert {
title: Some(&alert.title),
subtitle: None,
body: Some(&alert.body),
title_loc_key: None,
title_loc_args: None,
action_loc_key: None,
loc_key: None,
loc_args: None,
launch_image: None,
})),
badge: self.get_badge_count(&payload.user_id).await,
sound: Some(APSSound::Sound("default")),
thread_id: None,
content_available: None,
category: None,
mutable_content: Some(1),
url_args: None,
},
device_token: &payload.token,
options: payload_options.clone(),
data: BTreeMap::new(),
};
resp = self.client.send(apn_payload).await;
}
PayloadKind::MessageNotification(alert) => {
let title = self.format_title(&alert);
let apn_payload = MessagePayload {
aps: APS {
alert: Some(APSAlert::Default(DefaultAlert {
title: Some(&title),
subtitle: None,
body: Some(&alert.body),
title_loc_key: None,
title_loc_args: None,
action_loc_key: None,
loc_key: None,
loc_args: None,
launch_image: None,
})),
badge: self.get_badge_count(&payload.user_id).await,
sound: Some(APSSound::Sound("default")),
thread_id: Some(alert.channel.id()),
content_available: None,
category: None,
mutable_content: Some(1),
url_args: None,
},
device_token: &payload.token,
options: payload_options.clone(),
message: &alert.message,
url: &alert.url,
author_avatar: &alert.icon,
author_display_name: &alert.author,
channel_name: alert.channel.name().unwrap_or(&title),
};
resp = self.client.send(apn_payload).await;
}
PayloadKind::BadgeUpdate(badge) => {
let apn_payload = Payload {
aps: APS {
badge: Some(badge as u32),
..Default::default()
},
device_token: &payload.token,
options: payload_options.clone(),
data: BTreeMap::new(),
};
resp = self.client.send(apn_payload).await;
}
}
if let Err(err) = resp {
match err {
Error::ResponseError(Response {
error:
Some(ErrorBody {
reason: ErrorReason::BadDeviceToken | ErrorReason::Unregistered,
..
}),
..
}) => {
if let Err(err) = self
.db
.remove_push_subscription_by_session_id(&payload.session_id)
.await
{
revolt_config::capture_error(&err);
}
}
err => {
revolt_config::capture_error(&err);
}
}
}
}
}

View File

@@ -0,0 +1,199 @@
use std::{collections::HashMap, time::Duration};
use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use async_trait::async_trait;
use fcm_v1::{
android::AndroidConfig,
auth::{Authenticator, ServiceAccountKey},
message::{Message, Notification},
Client, Error as FcmError,
};
use revolt_database::{events::rabbit::*, Database};
use revolt_models::v0::{Channel, PushNotification};
use serde_json::Value;
pub struct FcmOutboundConsumer {
db: Database,
client: Client,
}
impl FcmOutboundConsumer {
fn format_title(&self, notification: &PushNotification) -> String {
// ideally this changes depending on context
// in a server, it would look like "Sendername, #channelname in servername"
// in a group, it would look like "Sendername in groupname"
// in a dm it should just be "Sendername".
// not sure how feasible all those are given the PushNotification object as it currently stands.
match &notification.channel {
Channel::DirectMessage { .. } => notification.author.clone(),
Channel::Group { name, .. } => format!("{}, #{}", notification.author, name),
Channel::TextChannel { name, .. } | Channel::VoiceChannel { name, .. } => {
format!("{} in #{}", notification.author, name)
}
_ => "Unknown".to_string(),
}
}
}
impl FcmOutboundConsumer {
pub async fn new(db: Database) -> Result<FcmOutboundConsumer, &'static str> {
let config = revolt_config::config().await;
Ok(FcmOutboundConsumer {
db,
client: Client::new(
Authenticator::service_account::<&str>(ServiceAccountKey {
key_type: Some(config.pushd.fcm.key_type),
project_id: Some(config.pushd.fcm.project_id.clone()),
private_key_id: Some(config.pushd.fcm.private_key_id),
private_key: config.pushd.fcm.private_key,
client_email: config.pushd.fcm.client_email,
client_id: Some(config.pushd.fcm.client_id),
auth_uri: Some(config.pushd.fcm.auth_uri),
token_uri: config.pushd.fcm.token_uri,
auth_provider_x509_cert_url: Some(config.pushd.fcm.auth_provider_x509_cert_url),
client_x509_cert_url: Some(config.pushd.fcm.client_x509_cert_url),
})
.await
.unwrap(),
config.pushd.fcm.project_id,
false,
Duration::from_secs(5),
),
})
}
}
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for FcmOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
let content = String::from_utf8(content).unwrap();
let payload: PayloadToService = serde_json::from_str(content.as_str()).unwrap();
let config = revolt_config::config().await;
#[allow(clippy::needless_late_init)]
let resp: Result<Message, FcmError>;
match payload.notification {
PayloadKind::FRReceived(alert) => {
let name = alert
.from_user
.display_name
.or(Some(format!(
"{}#{}",
alert.from_user.username, alert.from_user.discriminator
)))
.clone()
.unwrap();
let mut data = HashMap::new();
data.insert(
"type".to_string(),
Value::String("push.fr.receive".to_string()),
);
data.insert("id".to_string(), Value::String(alert.from_user.id));
data.insert("username".to_string(), Value::String(name));
let msg = Message {
token: Some(payload.token),
data: Some(data),
..Default::default()
};
resp = self.client.send(&msg).await;
}
PayloadKind::FRAccepted(alert) => {
let name = alert
.accepted_user
.display_name
.or(Some(format!(
"{}#{}",
alert.accepted_user.username, alert.accepted_user.discriminator
)))
.clone()
.unwrap();
let mut data: HashMap<String, Value> = HashMap::new();
data.insert(
"type".to_string(),
Value::String("push.fr.accept".to_string()),
);
data.insert("id".to_string(), Value::String(alert.accepted_user.id));
data.insert("username".to_string(), Value::String(name));
let msg = Message {
token: Some(payload.token),
data: Some(data),
..Default::default()
};
resp = self.client.send(&msg).await;
}
PayloadKind::Generic(alert) => {
let msg = Message {
token: Some(payload.token),
notification: Some(Notification {
title: Some(alert.title),
body: Some(alert.body),
image: alert.icon,
}),
..Default::default()
};
resp = self.client.send(&msg).await;
}
PayloadKind::MessageNotification(alert) => {
let title = self.format_title(&alert);
let msg = Message {
token: Some(payload.token),
notification: Some(Notification {
title: Some(title),
body: Some(alert.body),
image: Some(alert.icon),
}),
android: Some(AndroidConfig {
collapse_key: Some(alert.tag),
..Default::default()
}),
..Default::default()
};
resp = self.client.send(&msg).await;
}
PayloadKind::BadgeUpdate(_) => {
panic!("FCM cannot handle badge updates, and they should not be sent here.")
}
}
if let Err(err) = resp {
match err {
FcmError::Auth => {
if let Err(err) = self
.db
.remove_push_subscription_by_session_id(&payload.session_id)
.await
{
revolt_config::capture_error(&err);
}
}
err => {
revolt_config::capture_error(&err);
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod apn;
pub mod fcm;
pub mod vapid;

View File

@@ -0,0 +1,149 @@
use std::collections::HashMap;
use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use async_trait::async_trait;
use base64::{
engine::{self},
Engine as _,
};
use revolt_database::{events::rabbit::*, Database};
// use revolt_models::v0::{Channel, PushNotification};
use web_push::{
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder,
WebPushClient, WebPushError, WebPushMessageBuilder,
};
pub struct VapidOutboundConsumer {
db: Database,
client: IsahcWebPushClient,
pkey: Vec<u8>,
}
impl VapidOutboundConsumer {
pub async fn new(db: Database) -> Result<VapidOutboundConsumer, &'static str> {
let config = revolt_config::config().await;
if config.pushd.vapid.private_key.is_empty() | config.pushd.vapid.public_key.is_empty() {
return Err("No Vapid keys present");
}
let web_push_private_key = engine::general_purpose::URL_SAFE_NO_PAD
.decode(config.pushd.vapid.private_key)
.expect("valid `VAPID_PRIVATE_KEY`");
Ok(VapidOutboundConsumer {
db,
client: IsahcWebPushClient::new().unwrap(),
pkey: web_push_private_key,
})
}
}
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for VapidOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
let content = String::from_utf8(content).unwrap();
let payload: PayloadToService = serde_json::from_str(content.as_str()).unwrap();
let config = revolt_config::config().await;
let subscription = SubscriptionInfo {
endpoint: payload.extras.get("endpoint").unwrap().clone(),
keys: SubscriptionKeys {
auth: payload.token,
p256dh: payload.extras.get("p256dh").unwrap().clone(),
},
};
#[allow(clippy::needless_late_init)]
let payload_body: String;
match payload.notification {
PayloadKind::FRReceived(alert) => {
let name = alert
.from_user
.display_name
.or(Some(format!(
"{}#{}",
alert.from_user.username, alert.from_user.discriminator
)))
.clone()
.unwrap();
let mut body = HashMap::new();
body.insert("body", format!("{} sent you a friend request", name));
payload_body = serde_json::to_string(&body).unwrap();
}
PayloadKind::FRAccepted(alert) => {
let name = alert
.accepted_user
.display_name
.or(Some(format!(
"{}#{}",
alert.accepted_user.username, alert.accepted_user.discriminator
)))
.clone()
.unwrap();
let mut body = HashMap::new();
body.insert("body", format!("{} accepted your friend request", name));
payload_body = serde_json::to_string(&body).unwrap();
}
PayloadKind::Generic(alert) => {
payload_body = serde_json::to_string(&alert).unwrap();
}
PayloadKind::MessageNotification(alert) => {
payload_body = serde_json::to_string(&alert).unwrap();
}
PayloadKind::BadgeUpdate(_) => {
panic!("Vapid cannot handle badge updates, and they should not be sent here.")
}
}
match VapidSignatureBuilder::from_pem(std::io::Cursor::new(&self.pkey), &subscription) {
Ok(sig_builder) => match sig_builder.build() {
Ok(signature) => {
let mut builder = WebPushMessageBuilder::new(&subscription);
builder.set_vapid_signature(signature);
builder.set_payload(ContentEncoding::AesGcm, payload_body.as_bytes());
match builder.build() {
Ok(msg) => {
if let Err(err) = self.client.send(msg).await {
if err == WebPushError::Unauthorized {
if let Err(err) = self
.db
.remove_push_subscription_by_session_id(&payload.session_id)
.await
{
revolt_config::capture_error(&err);
}
}
}
}
Err(err) => {
revolt_config::capture_error(&err);
}
}
}
Err(err) => {
revolt_config::capture_error(&err);
}
},
Err(err) => {
revolt_config::capture_error(&err);
}
}
}
}