mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-14 05:26:59 +00:00
Merge branch 'main' into feat/admin-api
# Conflicts: # crates/core/database/src/models/users/model.rs
This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -2113,9 +2113,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "decancer"
|
||||
version = "1.6.5"
|
||||
version = "3.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "080b09f6adad25c23d8c47c54e52e59b0dc09d079c4b23e0f147dac440359d0d"
|
||||
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
|
||||
@@ -171,7 +171,7 @@ config = "0.13.3"
|
||||
cached = "0.44.0"
|
||||
rand = "0.8.5"
|
||||
base64 = "0.21.3"
|
||||
decancer = "1.6.2"
|
||||
decancer = "3.3.3"
|
||||
linkify = "0.8.1"
|
||||
url-escape = "0.1.1"
|
||||
revolt_optional_struct = "0.2.0"
|
||||
@@ -198,4 +198,4 @@ revolt-parser = { version = "0.12.0", path = "crates/core/parser" }
|
||||
revolt-permissions = { version = "0.12.0", path = "crates/core/permissions" }
|
||||
revolt-presence = { version = "0.12.0", path = "crates/core/presence" }
|
||||
revolt-ratelimits = { version = "0.12.0", path = "crates/core/ratelimits" }
|
||||
revolt-result = { version = "0.12.0", path = "crates/core/result" }
|
||||
revolt-result = { version = "0.12.0", path = "crates/core/result" }
|
||||
|
||||
@@ -81,6 +81,8 @@ call_ring_duration = 30
|
||||
[api.livekit.nodes]
|
||||
|
||||
[api.users]
|
||||
# Minimum allowed length of usernames
|
||||
min_username_length = 2
|
||||
|
||||
[pushd]
|
||||
# this changes the names of the queues to not overlap
|
||||
|
||||
@@ -232,6 +232,7 @@ pub struct LiveKitNode {
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ApiUsers {
|
||||
pub early_adopter_cutoff: Option<u64>,
|
||||
pub min_username_length: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
|
||||
},
|
||||
doc! {
|
||||
"$set": {
|
||||
"roles.".to_owned() + &role.id: to_document(role)
|
||||
"roles.".to_owned() + role.id.as_str(): to_document(role)
|
||||
.map_err(|_| create_database_error!("to_document", "role"))?
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ use futures::future::join_all;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::seq::SliceRandom;
|
||||
use regex::{Regex, RegexBuilder};
|
||||
use revolt_config::{config, FeaturesLimits};
|
||||
use revolt_models::v0::{self, UserBadges, UserFlags};
|
||||
use revolt_presence::filter_online;
|
||||
@@ -163,6 +164,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
|
||||
set.into_iter().collect()
|
||||
});
|
||||
|
||||
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
|
||||
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
#[allow(clippy::derivable_impls)]
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
@@ -198,11 +206,13 @@ impl User {
|
||||
I: Into<Option<String>>,
|
||||
D: Into<Option<PartialUser>>,
|
||||
{
|
||||
let username = User::validate_username(username)?;
|
||||
let new_username = User::sanitise_username(&username).await?;
|
||||
User::validate_username(&new_username)?;
|
||||
|
||||
let mut user = User {
|
||||
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
|
||||
discriminator: User::find_discriminator(db, &username, None).await?,
|
||||
username,
|
||||
discriminator: User::find_discriminator(db, &new_username, None).await?,
|
||||
username: new_username.clone(),
|
||||
last_acknowledged_policy_change: Timestamp::now_utc(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -278,39 +288,40 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitise and validate a username can be used
|
||||
pub fn validate_username(username: String) -> Result<String> {
|
||||
// Copy the username for validation
|
||||
/// Validate a username
|
||||
///
|
||||
/// This will check if the username is a blocked name or contains a blocked pattern.
|
||||
fn validate_username(username: &str) -> Result<()> {
|
||||
let username_lowercase = username.to_lowercase();
|
||||
|
||||
// Block homoglyphs
|
||||
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
|
||||
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
|
||||
|
||||
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|
||||
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
|
||||
{
|
||||
return Err(create_error!(InvalidUsername));
|
||||
}
|
||||
|
||||
// Ensure the username itself isn't blocked
|
||||
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"];
|
||||
Ok(())
|
||||
}
|
||||
|
||||
for username in BLOCKED_USERNAMES {
|
||||
if username_lowercase == *username {
|
||||
return Err(create_error!(InvalidUsername));
|
||||
}
|
||||
}
|
||||
/// Sanitise a username
|
||||
///
|
||||
/// This will clean up Unicode homoglyphs and pad to the min username length with underscores.
|
||||
async fn sanitise_username(username: &str) -> Result<String> {
|
||||
let options = decancer::Options::default().retain_capitalization();
|
||||
let mut username = decancer::cure(username, options)
|
||||
.map_err(|_| create_error!(InvalidUsername))?
|
||||
.to_string();
|
||||
|
||||
// Ensure none of the following substrings show up in the username
|
||||
const BLOCKED_SUBSTRINGS: &[&str] = &[
|
||||
"```",
|
||||
"discord.gg",
|
||||
"rvlt.gg",
|
||||
"guilded.gg",
|
||||
"https://",
|
||||
"http://",
|
||||
];
|
||||
|
||||
for substr in BLOCKED_SUBSTRINGS {
|
||||
if username_lowercase.contains(substr) {
|
||||
return Err(create_error!(InvalidUsername));
|
||||
}
|
||||
let config = revolt_config::config().await;
|
||||
let username_length_diff = config
|
||||
.api
|
||||
.users
|
||||
.min_username_length
|
||||
.saturating_sub(username.len());
|
||||
if username_length_diff > 0 {
|
||||
username.push_str(&"_".repeat(username_length_diff))
|
||||
}
|
||||
|
||||
Ok(username)
|
||||
@@ -416,12 +427,14 @@ impl User {
|
||||
|
||||
/// Update a user's username
|
||||
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
|
||||
let username = User::validate_username(username)?;
|
||||
if self.username.to_lowercase() == username.to_lowercase() {
|
||||
let new_username = User::sanitise_username(&username).await?;
|
||||
User::validate_username(&new_username)?;
|
||||
|
||||
if self.username.to_lowercase() == new_username.to_lowercase() {
|
||||
self.update(
|
||||
db,
|
||||
PartialUser {
|
||||
username: Some(username),
|
||||
username: Some(new_username),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
@@ -434,12 +447,12 @@ impl User {
|
||||
discriminator: Some(
|
||||
User::find_discriminator(
|
||||
db,
|
||||
&username,
|
||||
&new_username,
|
||||
Some((self.discriminator.to_string(), self.id.clone())),
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
username: Some(username),
|
||||
username: Some(new_username),
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
@@ -849,3 +862,109 @@ impl UserFlagsValue {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::User;
|
||||
|
||||
#[test]
|
||||
fn username_validation_blocked_names() {
|
||||
let username_admin = "Admin";
|
||||
let username_revolt = "Revolt";
|
||||
let username_stoat = "Stoat";
|
||||
let username_allowed = "Allowed";
|
||||
|
||||
assert!(User::validate_username(username_admin).is_err());
|
||||
assert!(User::validate_username(username_revolt).is_err());
|
||||
assert!(User::validate_username(username_stoat).is_err());
|
||||
assert!(User::validate_username(username_allowed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_validation_blocked_patterns() {
|
||||
let username_grave = "```_test";
|
||||
let username_discord = "discord.gg_test";
|
||||
let username_rvlt = "rvlt.gg_test";
|
||||
let username_guilded = "guilded.gg_test";
|
||||
let username_stt = "stt.gg_test";
|
||||
let username_revolt = "revolt.chat_test";
|
||||
let username_stoat = "stoat.chat_test";
|
||||
let username_http = "http://_test";
|
||||
let username_https = "https://_test";
|
||||
|
||||
assert!(User::validate_username(username_grave).is_err());
|
||||
assert!(User::validate_username(username_discord).is_err());
|
||||
assert!(User::validate_username(username_rvlt).is_err());
|
||||
assert!(User::validate_username(username_guilded).is_err());
|
||||
assert!(User::validate_username(username_stt).is_err());
|
||||
assert!(User::validate_username(username_revolt).is_err());
|
||||
assert!(User::validate_username(username_stoat).is_err());
|
||||
assert!(User::validate_username(username_http).is_err());
|
||||
assert!(User::validate_username(username_https).is_err());
|
||||
}
|
||||
|
||||
#[async_std::test]
|
||||
async fn username_sanitisation_clean() {
|
||||
let username_clean = "Test";
|
||||
|
||||
let username_clean_sanitised = User::sanitise_username(username_clean).await;
|
||||
|
||||
assert!(username_clean_sanitised.is_ok());
|
||||
assert_eq!(username_clean, username_clean_sanitised.unwrap());
|
||||
}
|
||||
|
||||
#[async_std::test]
|
||||
async fn username_sanitisation_homoglyphs() {
|
||||
let username_homoglyphs = "𝔽𝕌Ňℕy";
|
||||
|
||||
let username_homoglyphs_sanitised =
|
||||
User::sanitise_username(username_homoglyphs).await.unwrap();
|
||||
|
||||
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
|
||||
assert_eq!("funny", username_homoglyphs_sanitised);
|
||||
}
|
||||
|
||||
#[async_std::test]
|
||||
async fn username_sanitisation_padding() {
|
||||
let username_padding = "a";
|
||||
|
||||
let username = User::sanitise_username(username_padding).await.unwrap();
|
||||
|
||||
assert_eq!("a_", username);
|
||||
}
|
||||
|
||||
#[async_std::test]
|
||||
async fn create_user() {
|
||||
use revolt_result::Result;
|
||||
|
||||
database_test!(|db| async move {
|
||||
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("Test", created_clean.username);
|
||||
|
||||
created_clean
|
||||
.update_username(&db, "Test2".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("Test2", created_clean.username);
|
||||
|
||||
let created_invalid_result: Result<_> =
|
||||
User::create(&db, "stoat.chat".to_string(), None, None).await;
|
||||
|
||||
assert!(created_invalid_result.is_err());
|
||||
|
||||
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated_invalid_update_result = updated_invalid
|
||||
.update_username(&db, "http://test".to_string())
|
||||
.await;
|
||||
|
||||
assert!(updated_invalid_update_result.is_err());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ auto_derived!(
|
||||
pub nonce: Option<String>,
|
||||
|
||||
/// Message content to send
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 0)))]
|
||||
pub content: Option<String>,
|
||||
/// Attachments to include in message
|
||||
pub attachments: Option<Vec<String>>,
|
||||
@@ -345,7 +345,7 @@ auto_derived!(
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataEditMessage {
|
||||
/// New message content
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
|
||||
pub content: Option<String>,
|
||||
/// Embeds to include in the message
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]
|
||||
|
||||
@@ -5,9 +5,8 @@ use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProp
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use fcm_v1::{
|
||||
android::{AndroidConfig, AndroidMessagePriority},
|
||||
auth::{Authenticator, ServiceAccountKey},
|
||||
message::{Message, Notification},
|
||||
message::Message,
|
||||
Client, Error as FcmError,
|
||||
};
|
||||
use revolt_config::config;
|
||||
@@ -15,6 +14,102 @@ use revolt_database::{events::rabbit::*, Database};
|
||||
use revolt_models::v0::{Channel, PushNotification};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Custom notification data
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NotificationData {
|
||||
FRReceived {
|
||||
id: String,
|
||||
username: String,
|
||||
},
|
||||
FRAccepted {
|
||||
id: String,
|
||||
username: String,
|
||||
},
|
||||
Generic {
|
||||
title: String,
|
||||
body: String,
|
||||
image: Option<String>,
|
||||
},
|
||||
Message {
|
||||
title: String,
|
||||
body: String,
|
||||
image: String,
|
||||
tag: String,
|
||||
},
|
||||
DmCallStartEnd {
|
||||
initiator_id: String,
|
||||
channel_id: String,
|
||||
started_at: String,
|
||||
ended: bool,
|
||||
duration: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl NotificationData {
|
||||
pub fn get_type(&self) -> &str {
|
||||
match self {
|
||||
NotificationData::FRReceived { .. } => "push.fr.receive",
|
||||
NotificationData::FRAccepted { .. } => "push.fr.accept",
|
||||
NotificationData::Generic { .. } => "push.generic",
|
||||
NotificationData::Message { .. } => "push.message",
|
||||
NotificationData::DmCallStartEnd { .. } => "push.dm.call",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_payload(self) -> HashMap<String, Value> {
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"type".to_string(),
|
||||
Value::String(self.get_type().to_string()),
|
||||
);
|
||||
|
||||
match self {
|
||||
NotificationData::FRReceived { id, username } => {
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("username".to_string(), Value::String(username));
|
||||
}
|
||||
NotificationData::FRAccepted { id, username } => {
|
||||
data.insert("id".to_string(), Value::String(id));
|
||||
data.insert("username".to_string(), Value::String(username));
|
||||
}
|
||||
NotificationData::Generic { title, body, image } => {
|
||||
data.insert("title".to_string(), Value::String(title));
|
||||
data.insert("body".to_string(), Value::String(body));
|
||||
|
||||
if let Some(image) = image {
|
||||
data.insert("image".to_string(), Value::String(image));
|
||||
}
|
||||
}
|
||||
NotificationData::Message {
|
||||
title,
|
||||
body,
|
||||
image,
|
||||
tag,
|
||||
} => {
|
||||
data.insert("title".to_string(), Value::String(title));
|
||||
data.insert("body".to_string(), Value::String(body));
|
||||
data.insert("image".to_string(), Value::String(image));
|
||||
data.insert("tag".to_string(), Value::String(tag));
|
||||
}
|
||||
NotificationData::DmCallStartEnd {
|
||||
initiator_id,
|
||||
channel_id,
|
||||
started_at,
|
||||
ended,
|
||||
duration,
|
||||
} => {
|
||||
data.insert("initiator_id".to_string(), Value::String(initiator_id));
|
||||
data.insert("channel_id".to_string(), Value::String(channel_id));
|
||||
data.insert("started_at".to_string(), Value::String(started_at));
|
||||
data.insert("ended".to_string(), Value::Bool(ended));
|
||||
data.insert("duration".to_string(), Value::Number(duration.into()));
|
||||
}
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FcmOutboundConsumer {
|
||||
db: Database,
|
||||
client: Client,
|
||||
@@ -93,17 +188,14 @@ impl FcmOutboundConsumer {
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("missing name"))?;
|
||||
|
||||
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 data = NotificationData::FRReceived {
|
||||
id: alert.from_user.id,
|
||||
username: name,
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
token: Some(payload.token),
|
||||
data: Some(data),
|
||||
data: Some(data.into_payload()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -121,30 +213,29 @@ impl FcmOutboundConsumer {
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("missing name"))?;
|
||||
|
||||
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 data = NotificationData::FRAccepted {
|
||||
id: alert.accepted_user.id,
|
||||
username: name,
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
token: Some(payload.token),
|
||||
data: Some(data),
|
||||
data: Some(data.into_payload()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
resp = self.client.send(&msg).await;
|
||||
}
|
||||
PayloadKind::Generic(alert) => {
|
||||
let data = NotificationData::Generic {
|
||||
title: alert.title,
|
||||
body: alert.body,
|
||||
image: alert.icon,
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
token: Some(payload.token),
|
||||
notification: Some(Notification {
|
||||
title: Some(alert.title),
|
||||
body: Some(alert.body),
|
||||
image: alert.icon,
|
||||
}),
|
||||
data: Some(data.into_payload()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -152,19 +243,16 @@ impl FcmOutboundConsumer {
|
||||
}
|
||||
|
||||
PayloadKind::MessageNotification(alert) => {
|
||||
let title = self.format_title(&alert);
|
||||
let data = NotificationData::Message {
|
||||
title: self.format_title(&alert),
|
||||
body: alert.body,
|
||||
image: alert.icon,
|
||||
tag: alert.tag,
|
||||
};
|
||||
|
||||
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()
|
||||
}),
|
||||
data: Some(data.into_payload()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -172,30 +260,17 @@ impl FcmOutboundConsumer {
|
||||
}
|
||||
|
||||
PayloadKind::DmCallStartEnd(alert) => {
|
||||
let mut data: HashMap<String, Value> = HashMap::new();
|
||||
data.insert(
|
||||
"initiator_id".to_string(),
|
||||
Value::String(alert.initiator_id),
|
||||
);
|
||||
data.insert("channel_id".to_string(), Value::String(alert.channel_id));
|
||||
data.insert(
|
||||
"started_at".to_string(),
|
||||
Value::String(alert.started_at.unwrap_or_else(|| "".to_string())),
|
||||
);
|
||||
data.insert("ended".to_string(), Value::Bool(alert.ended));
|
||||
let data = NotificationData::DmCallStartEnd {
|
||||
initiator_id: alert.initiator_id,
|
||||
channel_id: alert.channel_id,
|
||||
started_at: alert.started_at.unwrap_or_else(|| "".to_string()),
|
||||
ended: alert.ended,
|
||||
duration: config().await.api.livekit.call_ring_duration,
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
token: Some(payload.token),
|
||||
notification: None,
|
||||
data: Some(data),
|
||||
android: Some(AndroidConfig {
|
||||
priority: Some(AndroidMessagePriority::High),
|
||||
ttl: Some(format!(
|
||||
"{}s",
|
||||
config().await.api.livekit.call_ring_duration
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
data: Some(data.into_payload()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct GlobalLimits {
|
||||
/// restrict server creation to these users.
|
||||
/// if blank, all users can create servers
|
||||
pub restrict_server_creation: Vec<String>,
|
||||
/// New user hours
|
||||
new_user_hours: i64,
|
||||
}
|
||||
|
||||
/// # User Limits
|
||||
@@ -231,6 +233,7 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
|
||||
.limits
|
||||
.global
|
||||
.restrict_server_creation,
|
||||
new_user_hours: config.features.limits.global.new_user_hours as i64,
|
||||
},
|
||||
new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
|
||||
default: UserLimits::from_feature_limits(config.features.limits.default),
|
||||
|
||||
Reference in New Issue
Block a user