Merge branch 'main' into feat/admin-api

# Conflicts:
#	crates/core/database/src/models/users/model.rs
This commit is contained in:
ispik
2026-04-27 10:29:19 +03:00
9 changed files with 299 additions and 95 deletions

8
Cargo.lock generated
View File

@@ -2113,9 +2113,13 @@ dependencies = [
[[package]] [[package]]
name = "decancer" name = "decancer"
version = "1.6.5" version = "3.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "080b09f6adad25c23d8c47c54e52e59b0dc09d079c4b23e0f147dac440359d0d" checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
dependencies = [
"lazy_static",
"regex",
]
[[package]] [[package]]
name = "der" name = "der"

View File

@@ -171,7 +171,7 @@ config = "0.13.3"
cached = "0.44.0" cached = "0.44.0"
rand = "0.8.5" rand = "0.8.5"
base64 = "0.21.3" base64 = "0.21.3"
decancer = "1.6.2" decancer = "3.3.3"
linkify = "0.8.1" linkify = "0.8.1"
url-escape = "0.1.1" url-escape = "0.1.1"
revolt_optional_struct = "0.2.0" 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-permissions = { version = "0.12.0", path = "crates/core/permissions" }
revolt-presence = { version = "0.12.0", path = "crates/core/presence" } revolt-presence = { version = "0.12.0", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.12.0", path = "crates/core/ratelimits" } 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" }

View File

@@ -81,6 +81,8 @@ call_ring_duration = 30
[api.livekit.nodes] [api.livekit.nodes]
[api.users] [api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd] [pushd]
# this changes the names of the queues to not overlap # this changes the names of the queues to not overlap

View File

@@ -232,6 +232,7 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers { pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>, pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]

View File

@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
}, },
doc! { doc! {
"$set": { "$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"))? .map_err(|_| create_database_error!("to_document", "role"))?
} }
}, },

View File

@@ -7,6 +7,7 @@ use futures::future::join_all;
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits}; use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags}; use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online; use revolt_presence::filter_online;
@@ -163,6 +164,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect() 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)] #[allow(clippy::derivable_impls)]
impl Default for User { impl Default for User {
fn default() -> Self { fn default() -> Self {
@@ -198,11 +206,13 @@ impl User {
I: Into<Option<String>>, I: Into<Option<String>>,
D: Into<Option<PartialUser>>, 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 { let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()), id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &username, None).await?, discriminator: User::find_discriminator(db, &new_username, None).await?,
username, username: new_username.clone(),
last_acknowledged_policy_change: Timestamp::now_utc(), last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default() ..Default::default()
}; };
@@ -278,39 +288,40 @@ impl User {
} }
} }
/// Sanitise and validate a username can be used /// Validate a username
pub fn validate_username(username: String) -> Result<String> { ///
// Copy the username for validation /// 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(); let username_lowercase = username.to_lowercase();
// Block homoglyphs const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
return Err(create_error!(InvalidUsername)); return Err(create_error!(InvalidUsername));
} }
// Ensure the username itself isn't blocked Ok(())
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"]; }
for username in BLOCKED_USERNAMES { /// Sanitise a username
if username_lowercase == *username { ///
return Err(create_error!(InvalidUsername)); /// 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 let config = revolt_config::config().await;
const BLOCKED_SUBSTRINGS: &[&str] = &[ let username_length_diff = config
"```", .api
"discord.gg", .users
"rvlt.gg", .min_username_length
"guilded.gg", .saturating_sub(username.len());
"https://", if username_length_diff > 0 {
"http://", username.push_str(&"_".repeat(username_length_diff))
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
} }
Ok(username) Ok(username)
@@ -416,12 +427,14 @@ impl User {
/// Update a user's username /// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let username = User::validate_username(username)?; let new_username = User::sanitise_username(&username).await?;
if self.username.to_lowercase() == username.to_lowercase() { User::validate_username(&new_username)?;
if self.username.to_lowercase() == new_username.to_lowercase() {
self.update( self.update(
db, db,
PartialUser { PartialUser {
username: Some(username), username: Some(new_username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -434,12 +447,12 @@ impl User {
discriminator: Some( discriminator: Some(
User::find_discriminator( User::find_discriminator(
db, db,
&username, &new_username,
Some((self.discriminator.to_string(), self.id.clone())), Some((self.discriminator.to_string(), self.id.clone())),
) )
.await?, .await?,
), ),
username: Some(username), username: Some(new_username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -849,3 +862,109 @@ impl UserFlagsValue {
self 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());
});
}
}

View File

@@ -261,7 +261,7 @@ auto_derived!(
pub nonce: Option<String>, pub nonce: Option<String>,
/// Message content to send /// 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>, pub content: Option<String>,
/// Attachments to include in message /// Attachments to include in message
pub attachments: Option<Vec<String>>, pub attachments: Option<Vec<String>>,
@@ -345,7 +345,7 @@ auto_derived!(
#[cfg_attr(feature = "validator", derive(Validate))] #[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditMessage { pub struct DataEditMessage {
/// New message content /// 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>, pub content: Option<String>,
/// Embeds to include in the message /// Embeds to include in the message
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))] #[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]

View File

@@ -5,9 +5,8 @@ use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProp
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use async_trait::async_trait; use async_trait::async_trait;
use fcm_v1::{ use fcm_v1::{
android::{AndroidConfig, AndroidMessagePriority},
auth::{Authenticator, ServiceAccountKey}, auth::{Authenticator, ServiceAccountKey},
message::{Message, Notification}, message::Message,
Client, Error as FcmError, Client, Error as FcmError,
}; };
use revolt_config::config; use revolt_config::config;
@@ -15,6 +14,102 @@ use revolt_database::{events::rabbit::*, Database};
use revolt_models::v0::{Channel, PushNotification}; use revolt_models::v0::{Channel, PushNotification};
use serde_json::Value; 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 { pub struct FcmOutboundConsumer {
db: Database, db: Database,
client: Client, client: Client,
@@ -93,17 +188,14 @@ impl FcmOutboundConsumer {
.clone() .clone()
.ok_or_else(|| anyhow!("missing name"))?; .ok_or_else(|| anyhow!("missing name"))?;
let mut data = HashMap::new(); let data = NotificationData::FRReceived {
data.insert( id: alert.from_user.id,
"type".to_string(), username: name,
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 { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data), data: Some(data.into_payload()),
..Default::default() ..Default::default()
}; };
@@ -121,30 +213,29 @@ impl FcmOutboundConsumer {
.clone() .clone()
.ok_or_else(|| anyhow!("missing name"))?; .ok_or_else(|| anyhow!("missing name"))?;
let mut data: HashMap<String, Value> = HashMap::new(); let data = NotificationData::FRAccepted {
data.insert( id: alert.accepted_user.id,
"type".to_string(), username: name,
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 { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data), data: Some(data.into_payload()),
..Default::default() ..Default::default()
}; };
resp = self.client.send(&msg).await; resp = self.client.send(&msg).await;
} }
PayloadKind::Generic(alert) => { PayloadKind::Generic(alert) => {
let data = NotificationData::Generic {
title: alert.title,
body: alert.body,
image: alert.icon,
};
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
notification: Some(Notification { data: Some(data.into_payload()),
title: Some(alert.title),
body: Some(alert.body),
image: alert.icon,
}),
..Default::default() ..Default::default()
}; };
@@ -152,19 +243,16 @@ impl FcmOutboundConsumer {
} }
PayloadKind::MessageNotification(alert) => { 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 { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
notification: Some(Notification { data: Some(data.into_payload()),
title: Some(title),
body: Some(alert.body),
image: Some(alert.icon),
}),
android: Some(AndroidConfig {
collapse_key: Some(alert.tag),
..Default::default()
}),
..Default::default() ..Default::default()
}; };
@@ -172,30 +260,17 @@ impl FcmOutboundConsumer {
} }
PayloadKind::DmCallStartEnd(alert) => { PayloadKind::DmCallStartEnd(alert) => {
let mut data: HashMap<String, Value> = HashMap::new(); let data = NotificationData::DmCallStartEnd {
data.insert( initiator_id: alert.initiator_id,
"initiator_id".to_string(), channel_id: alert.channel_id,
Value::String(alert.initiator_id), started_at: alert.started_at.unwrap_or_else(|| "".to_string()),
); ended: alert.ended,
data.insert("channel_id".to_string(), Value::String(alert.channel_id)); duration: config().await.api.livekit.call_ring_duration,
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 msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
notification: None, data: Some(data.into_payload()),
data: Some(data),
android: Some(AndroidConfig {
priority: Some(AndroidMessagePriority::High),
ttl: Some(format!(
"{}s",
config().await.api.livekit.call_ring_duration
)),
..Default::default()
}),
..Default::default() ..Default::default()
}; };

View File

@@ -92,6 +92,8 @@ pub struct GlobalLimits {
/// restrict server creation to these users. /// restrict server creation to these users.
/// if blank, all users can create servers /// if blank, all users can create servers
pub restrict_server_creation: Vec<String>, pub restrict_server_creation: Vec<String>,
/// New user hours
new_user_hours: i64,
} }
/// # User Limits /// # User Limits
@@ -231,6 +233,7 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
.limits .limits
.global .global
.restrict_server_creation, .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), new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
default: UserLimits::from_feature_limits(config.features.limits.default), default: UserLimits::from_feature_limits(config.features.limits.default),