Merge branch 'main' into feat/sso

This commit is contained in:
Zomatree
2025-11-18 21:38:43 +00:00
149 changed files with 34635 additions and 894 deletions

View File

@@ -72,6 +72,7 @@ revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database", features = [
"rocket-impl",
"redis-is-patched",
"voice",
] }
revolt-models = { path = "../core/models", features = [
"schemas",
@@ -83,5 +84,9 @@ revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
# voice
livekit-api = "0.4.4"
livekit-protocol = "0.4.0"
[build-dependencies]
vergen = "7.5.0"

View File

@@ -25,6 +25,7 @@ use amqprs::{
use async_std::channel::unbounded;
use authifier::AuthifierEvent;
use rocket::data::ToByteUnit;
use revolt_database::voice::VoiceClient;
pub async fn web() -> Rocket<Build> {
// Get settings
@@ -35,6 +36,7 @@ pub async fn web() -> Rocket<Build> {
// Setup database
let db = revolt_database::DatabaseInfo::Auto.connect().await.unwrap();
log::info!("database_here {db:?}");
db.migrate_database().await.unwrap();
// Setup Authifier event channel
@@ -90,6 +92,16 @@ pub async fn web() -> Rocket<Build> {
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
// Voice handler
let voice_client = VoiceClient::new(config.api.livekit.nodes.clone());
// Configure Rabbit
let connection = Connection::open(&OpenConnectionArguments::new(
&config.rabbit.host,
@@ -137,6 +149,7 @@ pub async fn web() -> Rocket<Build> {
.manage(db)
.manage(amqp)
.manage(cors.clone())
.manage(voice_client)
.manage(ratelimits)
.attach(ratelimiter::RatelimitFairing)
.attach(cors)

View File

@@ -1,4 +1,4 @@
use revolt_database::{util::reference::Reference, Database, User};
use revolt_database::{Database, User, util::reference::Reference, voice::{VoiceClient, remove_user_from_voice_channels}};
use revolt_result::{create_error, Result};
use rocket::State;
use rocket_empty::EmptyResponse;
@@ -10,6 +10,7 @@ use rocket_empty::EmptyResponse;
#[delete("/<target>")]
pub async fn delete_bot(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
) -> Result<EmptyResponse> {
@@ -18,7 +19,11 @@ pub async fn delete_bot(
return Err(create_error!(NotFound));
}
bot.delete(db).await.map(|_| EmptyResponse)
bot.delete(db).await?;
remove_user_from_voice_channels(db, voice_client, &bot.id).await?;
Ok(EmptyResponse)
}
#[cfg(test)]

View File

@@ -1,6 +1,7 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Channel, Database, PartialChannel, User, AMQP,
AMQP, Channel, Database, PartialChannel, User, util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{
VoiceClient, delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel
}
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -15,6 +16,7 @@ use rocket_empty::EmptyResponse;
#[delete("/<target>?<options..>")]
pub async fn delete(
db: &State<Database>,
voice_client: &State<VoiceClient>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
@@ -26,34 +28,45 @@ pub async fn delete(
permissions.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?;
#[allow(deprecated)]
match &channel {
Channel::SavedMessages { .. } => Err(create_error!(NoEffect)),
Channel::DirectMessage { .. } => channel
.update(
db,
PartialChannel {
active: Some(false),
..Default::default()
},
vec![],
)
.await
.map(|_| EmptyResponse),
Channel::Group { .. } => channel
.remove_user_from_group(
db,
amqp,
&user,
None,
options.leave_silently.unwrap_or_default(),
)
.await
.map(|_| EmptyResponse),
Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?;
channel.delete(db).await.map(|_| EmptyResponse)
Channel::SavedMessages { .. } => Err(create_error!(NoEffect))?,
Channel::DirectMessage { .. } => {
channel
.update(
db,
PartialChannel {
active: Some(false),
..Default::default()
},
vec![],
)
.await?
}
}
Channel::Group { .. } => {
channel
.remove_user_from_group(
db,
amqp,
&user,
None,
options.leave_silently.unwrap_or_default(),
)
.await?;
if is_in_voice_channel(&user.id, channel.id()).await? {
remove_user_from_voice_channel(db, voice_client, channel.id(), &user.id).await?;
};
}
Channel::TextChannel { .. } => {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?;
channel.delete(db).await?;
delete_voice_channel(voice_client, channel.id(), channel.server()).await?;
}
};
Ok(EmptyResponse)
}
#[cfg(test)]

View File

@@ -1,5 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{delete_voice_channel, VoiceClient},
Channel, Database, File, PartialChannel, SystemMessage, User, AMQP,
};
use revolt_models::v0;
@@ -15,6 +16,7 @@ use validator::Validate;
#[patch("/<target>", data = "<data>")]
pub async fn edit(
db: &State<Database>,
voice_client: &State<VoiceClient>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
@@ -38,6 +40,7 @@ pub async fn edit(
&& data.icon.is_none()
&& data.nsfw.is_none()
&& data.owner.is_none()
&& data.voice.is_none()
&& data.remove.is_empty()
{
return Ok(Json(channel.into()));
@@ -95,22 +98,6 @@ pub async fn edit(
icon,
nsfw,
..
}
| Channel::TextChannel {
id,
name,
description,
icon,
nsfw,
..
}
| Channel::VoiceChannel {
id,
name,
description,
icon,
nsfw,
..
} => {
if data.remove.contains(&v0::FieldsChannel::Icon) {
if let Some(icon) = &icon {
@@ -151,73 +138,130 @@ pub async fn edit(
}
// Send out mutation system messages.
if let Channel::Group { .. } = &channel {
if let Some(name) = &partial.name {
SystemMessage::ChannelRenamed {
name: name.to_string(),
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
if let Some(name) = &partial.name {
SystemMessage::ChannelRenamed {
name: name.to_string(),
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
}
if partial.description.is_some() {
SystemMessage::ChannelDescriptionChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
if partial.description.is_some() {
SystemMessage::ChannelDescriptionChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
}
if partial.icon.is_some() {
SystemMessage::ChannelIconChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
if partial.icon.is_some() {
SystemMessage::ChannelIconChanged {
by: user.id.clone(),
}
.into_message(channel.id().to_string())
.send(
db,
Some(amqp),
user.as_author_for_system(),
None,
None,
&channel,
false,
)
.await
.ok();
}
}
Channel::TextChannel {
id,
name,
description,
icon,
nsfw,
voice,
..
} => {
if data.remove.contains(&v0::FieldsChannel::Icon) {
if let Some(icon) = &icon {
db.mark_attachment_as_deleted(&icon.id).await?;
}
}
channel
.update(
db,
partial,
data.remove.into_iter().map(|f| f.into()).collect(),
)
.await?;
for field in &data.remove {
match field {
v0::FieldsChannel::Description => {
description.take();
}
v0::FieldsChannel::Icon => {
icon.take();
}
v0::FieldsChannel::Voice => {
voice.take();
}
_ => {}
}
}
if let Some(icon_id) = data.icon {
partial.icon = Some(File::use_channel_icon(db, &icon_id, id, &user.id).await?);
*icon = partial.icon.clone();
}
if let Some(new_name) = data.name {
*name = new_name.clone();
partial.name = Some(new_name);
}
if let Some(new_description) = data.description {
partial.description = Some(new_description);
*description = partial.description.clone();
}
if let Some(new_nsfw) = data.nsfw {
*nsfw = new_nsfw;
partial.nsfw = Some(new_nsfw);
}
if let Some(new_voice) = data.voice {
*voice = Some(new_voice.clone().into());
partial.voice = Some(new_voice.into());
}
}
_ => return Err(create_error!(InvalidOperation)),
};
channel
.update(
db,
partial,
data.remove.into_iter().map(|f| f.into()).collect(),
)
.await?;
if channel.voice().is_none() {
delete_voice_channel(voice_client, channel.id(), channel.server()).await?;
}
Ok(Json(channel.into()))
}

View File

@@ -1,4 +1,8 @@
use revolt_database::{util::reference::Reference, Channel, Database, User, AMQP};
use revolt_database::{
util::reference::Reference,
voice::{is_in_voice_channel, remove_user_from_voice_channel, VoiceClient},
Channel, Database, User, AMQP,
};
use revolt_permissions::ChannelPermission;
use revolt_result::{create_error, Result};
@@ -12,6 +16,7 @@ use rocket_empty::EmptyResponse;
#[delete("/<target>/recipients/<member>")]
pub async fn remove_member(
db: &State<Database>,
voice_client: &State<VoiceClient>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
@@ -23,32 +28,37 @@ pub async fn remove_member(
let channel = target.as_channel(db).await?;
match &channel {
Channel::Group {
owner, recipients, ..
} => {
if &user.id != owner {
return Err(create_error!(MissingPermission {
permission: ChannelPermission::ManageChannel.to_string()
}));
}
let member = member.as_user(db).await?;
if user.id == member.id {
return Err(create_error!(CannotRemoveYourself));
}
if !recipients.iter().any(|x| *x == member.id) {
return Err(create_error!(NotInGroup));
}
channel
.remove_user_from_group(db, amqp, &member, Some(&user.id), false)
.await
.map(|_| EmptyResponse)
if let Channel::Group {
owner, recipients, ..
} = &channel
{
if &user.id != owner {
return Err(create_error!(MissingPermission {
permission: ChannelPermission::ManageChannel.to_string()
}));
}
_ => Err(create_error!(InvalidOperation)),
}
let member = member.as_user(db).await?;
if user.id == member.id {
return Err(create_error!(CannotRemoveYourself));
}
if !recipients.contains(&member.id) {
return Err(create_error!(NotInGroup));
}
channel
.remove_user_from_group(db, amqp, &member, Some(&user.id), false)
.await?;
} else {
return Err(create_error!(InvalidOperation));
};
if is_in_voice_channel(&member.id, channel.id()).await? {
remove_user_from_voice_channel(db, voice_client, channel.id(), &member.id).await?;
};
Ok(EmptyResponse)
}
#[cfg(test)]

View File

@@ -1,6 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Channel, Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User,
Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User,
};
use revolt_models::v0::{self, MessageSort};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -65,12 +65,7 @@ pub async fn query(
},
&user,
include_users,
match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
Some(server)
}
_ => None,
},
channel.server(),
)
.await
.map(Json)

View File

@@ -1,6 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Channel, Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User,
Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -31,7 +31,7 @@ pub async fn search(
})?;
if options.query.is_some() && options.pinned.is_some() {
return Err(create_error!(InvalidOperation))
return Err(create_error!(InvalidOperation));
}
let channel = target.as_channel(db).await?;
@@ -69,12 +69,7 @@ pub async fn search(
},
&user,
include_users,
match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
Some(server)
}
_ => None,
},
channel.server(),
)
.await
.map(Json)

View File

@@ -151,6 +151,7 @@ mod test {
name: "Hidden Channel".to_string(),
description: None,
nsfw: Some(false),
voice: None
},
true,
)
@@ -193,6 +194,7 @@ mod test {
d: ChannelPermission::ViewChannel as i64,
}),
last_message_id: None,
voice: None,
};
locked_channel
.update(&harness.db, partial, vec![])
@@ -287,6 +289,8 @@ mod test {
avatar: None,
timeout: None,
roles: Some(second_member_roles),
can_publish: None,
can_receive: None
};
second_member
.update(&harness.db, partial, vec![])
@@ -613,6 +617,8 @@ mod test {
nickname: None,
roles: Some(vec![role_id.clone()]),
timeout: None,
can_publish: None,
can_receive: None
},
vec![],
)

View File

@@ -25,6 +25,7 @@ mod message_unreact;
mod permissions_set;
mod permissions_set_default;
mod voice_join;
mod voice_stop_ring;
mod webhook_create;
mod webhook_fetch_all;
@@ -49,6 +50,7 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
group_add_member::add_member,
group_remove_member::remove_member,
voice_join::call,
voice_stop_ring::stop_ring,
permissions_set::set_role_permissions,
permissions_set_default::set_default_channel_permissions,
message_react::react_message,

View File

@@ -1,6 +1,5 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User,
util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, Override};
@@ -11,19 +10,20 @@ use rocket::{serde::json::Json, State};
///
/// Sets permissions for the specified role in this channel.
///
/// Channel must be a `TextChannel` or `VoiceChannel`.
/// Channel must be a `TextChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/<role_id>", data = "<data>", rank = 2)]
pub async fn set_role_permissions(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
role_id: String,
data: Json<v0::DataSetRolePermissions>,
) -> Result<Json<v0::Channel>> {
let mut channel = target.as_channel(db).await?;
let channel = target.as_channel(db).await?;
let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel);
let permissions = calculate_channel_permissions(&mut query).await;
let permissions: revolt_permissions::PermissionValue = calculate_channel_permissions(&mut query).await;
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?;
@@ -38,11 +38,15 @@ pub async fn set_role_permissions(
.throw_permission_override(current_value, &data.permissions)
.await?;
channel
let mut new_channel = channel.clone();
new_channel
.set_role_permission(db, &role_id, data.permissions.clone().into())
.await?;
Ok(Json(channel.into()))
sync_voice_permissions(db, voice_client, &new_channel, Some(server), Some(&role_id)).await?;
Ok(Json(new_channel.into()))
} else {
Err(create_error!(NotFound))
}

View File

@@ -1,6 +1,5 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Channel, Database, PartialChannel, User,
util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Channel, Database, PartialChannel, User
};
use revolt_models::v0::{self, DataDefaultChannelPermissions};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -11,11 +10,12 @@ use rocket::{serde::json::Json, State};
///
/// Sets permissions for the default role in this channel.
///
/// Channel must be a `Group`, `TextChannel` or `VoiceChannel`.
/// Channel must be a `Group` or `TextChannel`.
#[openapi(tag = "Channel Permissions")]
#[put("/<target>/permissions/default", data = "<data>", rank = 1)]
pub async fn set_default_channel_permissions(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
data: Json<v0::DataDefaultChannelPermissions>,
@@ -48,10 +48,6 @@ pub async fn set_default_channel_permissions(
Channel::TextChannel {
default_permissions,
..
}
| Channel::VoiceChannel {
default_permissions,
..
} => {
if let DataDefaultChannelPermissions::Field { permissions: field } = data {
permissions
@@ -75,5 +71,12 @@ pub async fn set_default_channel_permissions(
_ => return Err(create_error!(InvalidOperation)),
}
let server = match channel.server() {
Some(server_id) => Some(Reference::from_unchecked(server_id).as_server(db).await?),
None => None
};
sync_voice_permissions(db, voice_client, &channel, server.as_ref(), None).await?;
Ok(Json(channel.into()))
}

View File

@@ -1,105 +1,115 @@
use revolt_config::config;
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Channel, Database, User,
util::{permissions::perms, reference::Reference},
voice::{
delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members,
raise_if_in_voice, set_call_notification_recipients, VoiceClient,
},
Database, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
/// # Join Call
///
/// Asks the voice server for a token to join the call.
#[openapi(tag = "Voice")]
#[post("/<target>/join_call")]
#[post("/<target>/join_call", data = "<data>")]
pub async fn call(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
) -> Result<Json<v0::LegacyCreateVoiceUserResponse>> {
data: Json<v0::DataJoinCall>,
) -> Result<Json<v0::CreateVoiceUserResponse>> {
if !voice_client.is_enabled() {
return Err(create_error!(LiveKitUnavailable));
}
let v0::DataJoinCall {
node,
force_disconnect,
recipients,
} = data.into_inner();
if user.bot.is_some() && force_disconnect == Some(true) {
return Err(create_error!(IsBot));
}
let channel = target.as_channel(db).await?;
let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel);
calculate_channel_permissions(&mut query)
.await
.throw_if_lacking_channel_permission(ChannelPermission::Connect)?;
let Some(voice_info) = channel.voice() else {
return Err(create_error!(NotAVoiceChannel));
};
let mut permissions = perms(db, &user).channel(&channel);
let current_permissions = calculate_channel_permissions(&mut permissions).await;
current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?;
if get_voice_channel_members(channel.id())
.await?
.zip(voice_info.max_users)
.is_some_and(|(ms, max_users)| ms.len() >= max_users)
&& !current_permissions.has(ChannelPermission::ManageChannel as u64)
{
return Err(create_error!(CannotJoinCall));
}
let existing_node = get_channel_node(channel.id()).await?;
let node = existing_node
.or(node)
.ok_or_else(|| create_error!(UnknownNode))?;
let config = config().await;
if config.api.security.voso_legacy_token.is_empty() {
return Err(create_error!(VosoUnavailable));
}
match channel {
Channel::SavedMessages { .. } | Channel::TextChannel { .. } => {
return Err(create_error!(CannotJoinCall))
let node_host = config
.hosts
.livekit
.get(&node)
.ok_or_else(|| create_error!(UnknownNode))?
.clone();
if force_disconnect == Some(true) {
// Finds and disconnects any existing voice connections by the user,
// should only ever loop once but just to cover our backs.
for channel_id in get_user_voice_channels(&user.id).await? {
if let Some(node) = get_channel_node(&channel_id).await? {
// if this errors its just a mismatching state - ignore and proceed to still delete our state
let _ = voice_client.remove_user(&node, &user.id, &channel_id).await;
};
let channel = Reference::from_unchecked(&channel_id)
.as_channel(db)
.await?;
delete_voice_state(&channel_id, channel.server(), &user.id).await?;
}
_ => {}
}
// To join a call:
// - Check if the room exists.
// - If not, create it.
let client = reqwest::Client::new();
let result = client
.get(format!(
"{}/room/{}",
config.hosts.voso_legacy,
channel.id()
))
.header(
reqwest::header::AUTHORIZATION,
config.api.security.voso_legacy_token.clone(),
)
.send()
.await;
match result {
Err(_) => return Err(create_error!(VosoUnavailable)),
Ok(result) => match result.status() {
reqwest::StatusCode::OK => (),
reqwest::StatusCode::NOT_FOUND => {
if (client
.post(format!(
"{}/room/{}",
config.hosts.voso_legacy,
channel.id()
))
.header(
reqwest::header::AUTHORIZATION,
config.api.security.voso_legacy_token.clone(),
)
.send()
.await)
.is_err()
{
return Err(create_error!(VosoUnavailable));
}
}
_ => return Err(create_error!(VosoUnavailable)),
},
}
// Then create a user for the room.
if let Ok(response) = client
.post(format!(
"{}/room/{}/user/{}",
config.hosts.voso_legacy,
channel.id(),
user.id
))
.header(
reqwest::header::AUTHORIZATION,
config.api.security.voso_legacy_token,
)
.send()
.await
{
response
.json()
.await
.map_err(|_| create_error!(InvalidOperation))
.map(Json)
} else {
Err(create_error!(VosoUnavailable))
raise_if_in_voice(&user, channel.id()).await?;
}
let token = voice_client
.create_token(&node, db, &user, current_permissions, &channel)
.await?;
let room = voice_client.create_room(&node, &channel).await?;
log::debug!("Created room {}", room.name);
if let Some(recipients) = recipients {
if room.num_participants == 0 && !recipients.is_empty() {
set_call_notification_recipients(channel.id(), &user.id, &recipients).await?;
}
}
Ok(Json(v0::CreateVoiceUserResponse {
token,
url: node_host.clone(),
}))
}

View File

@@ -0,0 +1,69 @@
use revolt_database::{
util::reference::Reference,
voice::{get_voice_state, VoiceClient},
Channel, Database, User, AMQP,
};
use revolt_result::{create_error, Result, ToRevoltError};
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Stop Ring
/// Stops ringing a specific user in a dm call.
/// You must be in the call to use this endpoint, returns NotConnected otherwise.
/// Only valid in DM/Group channels, will return NoEffect in servers.
/// Returns NotFound if the user is not in the dm/group channel
#[openapi(tag = "Voice")]
#[put("/<target>/end_ring/<target_user>")]
pub async fn stop_ring(
db: &State<Database>,
amqp: &State<AMQP>,
voice: &State<VoiceClient>,
user: User,
target: Reference<'_>,
target_user: Reference<'_>,
) -> Result<EmptyResponse> {
if !voice.is_enabled() {
return Err(create_error!(LiveKitUnavailable));
}
let channel = target.as_channel(db).await?;
if channel.server().is_some() {
return Err(create_error!(NoEffect));
}
if get_voice_state(channel.id(), None, &user.id)
.await?
.is_none()
{
return Err(create_error!(NotConnected));
}
let members = match channel {
Channel::DirectMessage { ref recipients, .. } | Channel::Group { ref recipients, .. } => {
recipients
}
_ => return Err(create_error!(NoEffect)),
};
if members.iter().any(|m| &target_user.id == m) {
if let Err(e) = amqp
.dm_call_updated(
&user.id,
channel.id(),
None,
true,
Some(vec![target_user.id.to_string()]),
)
.await
.to_internal_error()
{
revolt_config::capture_internal_error!(&e);
return Err(e);
}
Ok(EmptyResponse)
} else {
Err(create_error!(NotFound))
}
}

View File

@@ -23,13 +23,6 @@ pub async fn fetch(db: &State<Database>, target: Reference<'_>) -> Result<Json<v
name,
description,
..
}
| Channel::VoiceChannel {
id,
server,
name,
description,
..
} => {
let server = db.fetch_server(&server).await?;
@@ -202,6 +195,7 @@ mod test {
name: "Voice Channel".to_string(),
description: None,
nsfw: Some(false),
voice: None
},
true,
)

View File

@@ -2,12 +2,14 @@ use revolt_database::{Database, User};
use revolt_result::Result;
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Acknowledge Policy Changes
///
/// Accept/acknowledge changes to platform policy.
#[openapi(tag = "Policy")]
#[post("/acknowledge")]
pub async fn acknowledge_policy_changes(db: &State<Database>, user: User) -> Result<()> {
db.acknowledge_policy_changes(&user.id).await
pub async fn acknowledge_policy_changes(db: &State<Database>, user: User) -> Result<EmptyResponse> {
db.acknowledge_policy_changes(&user.id).await?;
Ok(EmptyResponse)
}

View File

@@ -21,15 +21,22 @@ pub struct Feature {
pub url: String,
}
/// # Information about a livekit node
#[derive(Serialize, JsonSchema, Debug)]
pub struct VoiceNode {
pub name: String,
pub lat: f64,
pub lon: f64,
pub public_url: String,
}
/// # Voice Server Configuration
#[derive(Serialize, JsonSchema, Debug)]
pub struct VoiceFeature {
/// Whether voice is enabled
pub enabled: bool,
/// URL pointing to the voice API
pub url: String,
/// URL pointing to the voice WebSocket server
pub ws: String,
/// All livekit nodes
pub nodes: Vec<VoiceNode>,
}
/// # Feature Configuration
@@ -46,7 +53,7 @@ pub struct RevoltFeatures {
/// Proxy service configuration
pub january: Feature,
/// Voice server configuration
pub voso: VoiceFeature,
pub livekit: VoiceFeature,
}
/// # Build Information
@@ -94,22 +101,38 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
features: RevoltFeatures {
captcha: CaptchaFeature {
enabled: !config.api.security.captcha.hcaptcha_key.is_empty(),
key: config.api.security.captcha.hcaptcha_sitekey,
key: config.api.security.captcha.hcaptcha_sitekey.clone(),
},
email: !config.api.smtp.host.is_empty(),
invite_only: config.api.registration.invite_only,
autumn: Feature {
enabled: !config.hosts.autumn.is_empty(),
url: config.hosts.autumn,
url: config.hosts.autumn.clone(),
},
january: Feature {
enabled: !config.hosts.january.is_empty(),
url: config.hosts.january,
url: config.hosts.january.clone(),
},
voso: VoiceFeature {
enabled: !config.hosts.voso_legacy.is_empty(),
url: config.hosts.voso_legacy,
ws: config.hosts.voso_legacy_ws,
livekit: VoiceFeature {
enabled: !config.hosts.livekit.is_empty(),
nodes: config
.api
.livekit
.nodes
.iter()
.filter(|(_, node)| !node.private)
.map(|(name, value)| VoiceNode {
name: name.clone(),
lat: value.lat,
lon: value.lon,
public_url: config
.hosts
.livekit
.get(name)
.expect("Missing corresponding host for voice node")
.clone(),
})
.collect(),
},
},
ws: config.hosts.events,

View File

@@ -1,6 +1,7 @@
use revolt_database::{events::client::EventV1, Database, Report, Snapshot, SnapshotContent, User};
use revolt_models::v0::{ReportStatus, ReportedContent};
use revolt_result::{create_error, Result};
use rocket_empty::EmptyResponse;
use serde::Deserialize;
use ulid::Ulid;
use validator::Validate;
@@ -27,7 +28,7 @@ pub async fn report_content(
db: &State<Database>,
user: User,
data: Json<DataReportContent>,
) -> Result<()> {
) -> Result<EmptyResponse> {
let data = data.into_inner();
data.validate().map_err(|error| {
create_error!(FailedValidation {
@@ -129,5 +130,5 @@ pub async fn report_content(
EventV1::ReportCreate(report.into()).global().await;
Ok(())
Ok(EmptyResponse)
}

View File

@@ -1,5 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient},
Database, RemovalIntention, ServerBan, User,
};
use revolt_models::v0;
@@ -16,6 +17,7 @@ use validator::Validate;
#[put("/<server>/bans/<target>", data = "<data>")]
pub async fn ban(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
server: Reference<'_>,
target: Reference<'_>,
@@ -54,6 +56,11 @@ pub async fn ban(
member
.remove(db, &server, RemovalIntention::Ban, false)
.await?;
// If the member is in a voice channel while banned kick them from the voice channel
if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?;
}
}
ServerBan::create(db, &server, target.id, data.reason)

View File

@@ -1,14 +1,25 @@
use std::collections::HashSet;
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
events::client::EventV1,
util::{
permissions::{perms, DatabasePermissionQuery},
reference::Reference,
},
voice::{
get_channel_node, get_user_voice_channel_in_server, set_channel_node,
set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions,
VoiceClient,
},
Database, File, PartialMember, User,
};
use revolt_models::v0;
use revolt_models::v0::{self, FieldsMember};
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_permissions::{
calculate_channel_permissions, calculate_server_permissions, ChannelPermission,
};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
use rocket::{form::validate::Contains, serde::json::Json, State};
use validator::Validate;
/// # Edit Member
@@ -18,6 +29,7 @@ use validator::Validate;
#[patch("/<server>/members/<member>", data = "<data>")]
pub async fn edit(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
server: Reference<'_>,
member: Reference<'_>,
@@ -78,6 +90,44 @@ pub async fn edit(
permissions.throw_if_lacking_channel_permission(ChannelPermission::TimeoutMembers)?;
}
if data.can_publish.is_some() {
permissions.throw_if_lacking_channel_permission(ChannelPermission::MuteMembers)?;
}
if data.can_receive.is_some() {
permissions.throw_if_lacking_channel_permission(ChannelPermission::DeafenMembers)?;
}
let new_voice_channel = if let Some(new_channel) = &data.voice_channel {
if !voice_client.is_enabled() {
return Err(create_error!(LiveKitUnavailable));
};
permissions.throw_if_lacking_channel_permission(ChannelPermission::MoveMembers)?;
// ensure the channel we are moving them to is in the server and is a voice channel
let channel = Reference::from_unchecked(new_channel)
.as_channel(db)
.await
.map_err(|_| create_error!(UnknownChannel))?;
if channel.server().is_none_or(|v| v != member.id.server) {
Err(create_error!(UnknownChannel))?
}
if get_user_voice_channel_in_server(&target_user.id, &server.id)
.await?
.is_none()
{
Err(create_error!(NotConnected))?
};
Some(channel)
} else {
None
};
// Resolve our ranking
let our_ranking = query.get_member_rank().unwrap_or(i64::MIN);
@@ -113,12 +163,17 @@ pub async fn edit(
roles,
timeout,
remove,
can_publish,
can_receive,
voice_channel: _,
} = data;
let mut partial = PartialMember {
nickname,
roles,
timeout,
can_publish,
can_receive,
..Default::default()
};
@@ -134,9 +189,69 @@ pub async fn edit(
partial.avatar = Some(File::use_user_avatar(db, &avatar, &user.id, &user.id).await?);
}
let remove_contains_voice = remove.contains(FieldsMember::CanPublish) || remove.contains(FieldsMember::CanReceive);
member
.update(db, partial, remove.into_iter().map(Into::into).collect())
.await?;
if let Some(new_voice_channel) = new_voice_channel {
if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await?
{
let old_node = get_channel_node(&channel).await?.unwrap();
let new_node = match get_channel_node(new_voice_channel.id()).await? {
Some(node) => node,
None => {
set_channel_node(new_voice_channel.id(), &old_node).await?;
old_node.clone()
}
};
set_user_moved_from_voice(&channel, new_voice_channel.id(), &target_user.id).await?;
set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?;
let mut query = perms(db, &target_user).channel(&new_voice_channel);
let permissions = calculate_channel_permissions(&mut query).await;
voice_client
.create_room(&new_node, &new_voice_channel)
.await?;
let token = voice_client
.create_token(&new_node, db, &target_user, permissions, &new_voice_channel)
.await?;
voice_client
.remove_user(&old_node, &target_user.id, &channel)
.await?;
EventV1::UserMoveVoiceChannel {
node: new_node,
from: channel,
to: new_voice_channel.id().to_string(),
token,
}
.private(target_user.id.clone())
.await;
};
} else if can_publish.is_some() || can_receive.is_some() || remove_contains_voice {
if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await?
{
let node = get_channel_node(&channel).await?.unwrap();
let channel = Reference::from_unchecked(&channel).as_channel(db).await?;
sync_user_voice_permissions(
db,
voice_client,
&node,
&user,
&channel,
Some(&server),
None,
)
.await?;
};
};
Ok(Json(member.into()))
}

View File

@@ -1,5 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient},
Database, RemovalIntention, User,
};
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -14,6 +15,7 @@ use rocket_empty::EmptyResponse;
#[delete("/<target>/members/<member>")]
pub async fn kick(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
member: Reference<'_>,
@@ -42,6 +44,11 @@ pub async fn kick(
member
.remove(db, &server, RemovalIntention::Kick, false)
.await
.map(|_| EmptyResponse)
.await?;
if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?;
};
Ok(EmptyResponse)
}

View File

@@ -1,5 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{sync_voice_permissions, VoiceClient},
Database, User,
};
use revolt_models::v0;
@@ -14,6 +15,7 @@ use rocket::{serde::json::Json, State};
#[put("/<target>/permissions/<role_id>", data = "<data>", rank = 2)]
pub async fn set_role_permission(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
role_id: String,
@@ -22,30 +24,38 @@ pub async fn set_role_permission(
let data = data.into_inner();
let mut server = target.as_server(db).await?;
if let Some((current_value, rank)) = server.roles.get(&role_id).map(|x| (x.permissions, x.rank))
{
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
let permissions = calculate_server_permissions(&mut query).await;
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?;
let (current_value, rank) = server
.roles
.get(&role_id)
.map(|x| (x.permissions, x.rank))
.ok_or_else(|| create_error!(NotFound))?;
// Prevent us from editing roles above us
if rank <= query.get_member_rank().unwrap_or(i64::MIN) {
return Err(create_error!(NotElevated));
}
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
let permissions = calculate_server_permissions(&mut query).await;
// Ensure we have access to grant these permissions forwards
let current_value: Override = current_value.into();
permissions
.throw_permission_override(current_value, &data.permissions)
.await?;
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?;
server
.set_role_permission(db, &role_id, data.permissions.into())
.await?;
Ok(Json(server.into()))
} else {
Err(create_error!(NotFound))
// Prevent us from editing roles above us
if rank <= query.get_member_rank().unwrap_or(i64::MIN) {
return Err(create_error!(NotElevated));
}
// Ensure we have access to grant these permissions forwards
let current_value: Override = current_value.into();
permissions
.throw_permission_override(current_value, &data.permissions)
.await?;
server
.set_role_permission(db, &role_id, data.permissions.into())
.await?;
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?;
};
Ok(Json(server.into()))
}

View File

@@ -1,6 +1,5 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, PartialServer, User,
util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, PartialServer, User
};
use revolt_models::v0;
use revolt_permissions::{
@@ -16,6 +15,7 @@ use rocket::{serde::json::Json, State};
#[put("/<target>/permissions/default", data = "<data>", rank = 1)]
pub async fn set_default_server_permissions(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
data: Json<DataPermissionsValue>,
@@ -50,5 +50,11 @@ pub async fn set_default_server_permissions(
)
.await?;
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?;
};
Ok(Json(server.into()))
}

View File

@@ -1,5 +1,6 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{sync_voice_permissions, VoiceClient},
Database, User,
};
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -17,6 +18,7 @@ pub async fn delete(
user: User,
target: Reference<'_>,
role_id: String,
voice_client: &State<VoiceClient>,
) -> Result<EmptyResponse> {
let mut server = target.as_server(db).await?;
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
@@ -26,15 +28,22 @@ pub async fn delete(
let member_rank = query.get_member_rank().unwrap_or(i64::MIN);
if let Some(role) = server.roles.remove(&role_id) {
if role.rank <= member_rank {
return Err(create_error!(NotElevated));
}
let role = server
.roles
.remove(&role_id)
.ok_or_else(|| create_error!(NotFound))?;
role.delete(db, &server.id, &role_id)
.await
.map(|_| EmptyResponse)
} else {
Err(create_error!(NotFound))
if role.rank <= member_rank {
return Err(create_error!(NotElevated));
}
role.delete(db, &server.id, &role_id).await?;
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?;
}
Ok(EmptyResponse)
}

View File

@@ -1,6 +1,7 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, PartialRole, User,
voice::{sync_voice_permissions, VoiceClient},
Database, PartialRole, User
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -15,6 +16,7 @@ use validator::Validate;
#[patch("/<target>/roles/<role_id>", data = "<data>", rank = 1)]
pub async fn edit(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
role_id: String,
@@ -65,6 +67,12 @@ pub async fn edit(
)
.await?;
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?;
};
Ok(Json(role.into()))
} else {
Err(create_error!(NotFound))

View File

@@ -1,6 +1,5 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User,
util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -14,6 +13,7 @@ use rocket::{serde::json::Json, State};
#[patch("/<target>/roles/ranks", data = "<data>")]
pub async fn edit_role_ranks(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
data: Json<v0::DataEditRoleRanks>,
@@ -70,6 +70,12 @@ pub async fn edit_role_ranks(
server.set_role_ordering(db, new_order).await?;
for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?;
};
Ok(Json(server.into()))
}

View File

@@ -1,4 +1,11 @@
use revolt_database::{util::reference::Reference, Database, RemovalIntention, User};
use revolt_database::{
util::reference::Reference,
voice::{
delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel,
VoiceClient,
},
Database, RemovalIntention, User,
};
use revolt_models::v0;
use revolt_result::Result;
use rocket::State;
@@ -12,6 +19,7 @@ use rocket_empty::EmptyResponse;
#[delete("/<target>?<options..>")]
pub async fn delete(
db: &State<Database>,
voice_client: &State<VoiceClient>,
user: User,
target: Reference<'_>,
options: v0::OptionsServerDelete,
@@ -20,8 +28,16 @@ pub async fn delete(
let member = db.fetch_member(target.id, &user.id).await?;
if server.owner == user.id {
for channel_id in &server.channels {
delete_voice_channel(voice_client, channel_id, Some(&server.id)).await?;
}
server.delete(db).await
} else {
if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &user.id).await?;
};
member
.remove(
db,

View File

@@ -166,6 +166,7 @@ impl TestHarness {
name: "Test Channel".to_string(),
description: None,
nsfw: Some(false),
voice: None
},
true,
)