From 23ad1359834bb7d07a460b8678d6a6ebffc73eb0 Mon Sep 17 00:00:00 2001 From: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com> Date: Sat, 9 May 2026 00:23:57 +0200 Subject: [PATCH] feat: add emoji rename endpoint (#714) * feat: add rename endpoint with rename-only update Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com> * fix: enforce detached emoji edit restrictions Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com> * fix: always enforce emoji edit permissions Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com> --------- Signed-off-by: Gabriel <60961939+gabrielfordevelopment@users.noreply.github.com> --- crates/core/database/src/events/client.rs | 8 +- .../core/database/src/models/emojis/model.rs | 27 +++ crates/core/database/src/models/emojis/ops.rs | 5 +- .../database/src/models/emojis/ops/mongodb.rs | 7 +- .../src/models/emojis/ops/reference.rs | 15 +- crates/core/models/src/v0/emojis.rs | 18 ++ .../src/routes/customisation/emoji_edit.rs | 212 ++++++++++++++++++ crates/delta/src/routes/customisation/mod.rs | 2 + docs/docs/developers/events/protocol.md | 16 ++ 9 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 crates/delta/src/routes/customisation/emoji_edit.rs diff --git a/crates/core/database/src/events/client.rs b/crates/core/database/src/events/client.rs index 4615273d..38c6f941 100644 --- a/crates/core/database/src/events/client.rs +++ b/crates/core/database/src/events/client.rs @@ -3,7 +3,7 @@ use revolt_result::Error; use serde::{Deserialize, Serialize}; use revolt_models::v0::{ - AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook + AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook }; use crate::Database; @@ -219,6 +219,12 @@ pub enum EventV1 { /// New emoji EmojiCreate(Emoji), + /// Update existing emoji + EmojiUpdate { + id: String, + data: PartialEmoji, + }, + /// Delete emoji EmojiDelete { id: String }, diff --git a/crates/core/database/src/models/emojis/model.rs b/crates/core/database/src/models/emojis/model.rs index 8294d7c7..3f380716 100644 --- a/crates/core/database/src/models/emojis/model.rs +++ b/crates/core/database/src/models/emojis/model.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::str::FromStr; use once_cell::sync::Lazy; +use revolt_models::v0; use revolt_result::Result; use ulid::Ulid; @@ -41,6 +42,12 @@ auto_derived!( Server { id: String }, Detached, } + + /// Partial representation of an emoji + pub struct PartialEmoji { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + } ); #[allow(clippy::disallowed_methods)] @@ -75,6 +82,26 @@ impl Emoji { db.detach_emoji(&self).await } + /// Update an emoji + pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> { + if let Some(name) = partial.name.clone() { + self.name = name; + } + + db.update_emoji(&self.id, &partial).await?; + + EventV1::EmojiUpdate { + id: self.id.clone(), + data: v0::PartialEmoji { + name: partial.name.clone(), + }, + } + .p(self.parent().to_string()) + .await; + + Ok(()) + } + /// Check whether we can use a given emoji pub async fn can_use(db: &Database, emoji: &str) -> Result { if Ulid::from_str(emoji).is_ok() { diff --git a/crates/core/database/src/models/emojis/ops.rs b/crates/core/database/src/models/emojis/ops.rs index 26e23aca..d28f30d4 100644 --- a/crates/core/database/src/models/emojis/ops.rs +++ b/crates/core/database/src/models/emojis/ops.rs @@ -1,6 +1,6 @@ use revolt_result::Result; -use crate::Emoji; +use crate::{Emoji, PartialEmoji}; #[cfg(feature = "mongodb")] mod mongodb; @@ -20,6 +20,9 @@ pub trait AbstractEmojis: Sync + Send { /// Fetch emoji by their parent ids async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result>; + /// Update emoji with new information + async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()>; + /// Detach an emoji by its id async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>; } diff --git a/crates/core/database/src/models/emojis/ops/mongodb.rs b/crates/core/database/src/models/emojis/ops/mongodb.rs index 6dd3137b..ad7b557c 100644 --- a/crates/core/database/src/models/emojis/ops/mongodb.rs +++ b/crates/core/database/src/models/emojis/ops/mongodb.rs @@ -1,7 +1,7 @@ use bson::Document; use revolt_result::Result; -use crate::Emoji; +use crate::{Emoji, PartialEmoji}; use crate::MongoDb; use super::AbstractEmojis; @@ -46,6 +46,11 @@ impl AbstractEmojis for MongoDb { ) } + /// Update emoji with new information + async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> { + query!(self, update_one_by_id, COL, emoji_id, partial, vec![], None).map(|_| ()) + } + /// Detach an emoji by its id async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { self.col::(COL) diff --git a/crates/core/database/src/models/emojis/ops/reference.rs b/crates/core/database/src/models/emojis/ops/reference.rs index 2f0c2a2d..2f9be435 100644 --- a/crates/core/database/src/models/emojis/ops/reference.rs +++ b/crates/core/database/src/models/emojis/ops/reference.rs @@ -1,6 +1,6 @@ use revolt_result::Result; -use crate::Emoji; +use crate::{Emoji, PartialEmoji}; use crate::EmojiParent; use crate::ReferenceDb; @@ -54,6 +54,19 @@ impl AbstractEmojis for ReferenceDb { .collect()) } + /// Update emoji with new information + async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> { + let mut emojis = self.emojis.lock().await; + if let Some(emoji) = emojis.get_mut(emoji_id) { + if let Some(name) = partial.name.clone() { + emoji.name = name; + } + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + /// Detach an emoji by its id async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { let mut emojis = self.emojis.lock().await; diff --git a/crates/core/models/src/v0/emojis.rs b/crates/core/models/src/v0/emojis.rs index 2d7c015e..58d47429 100644 --- a/crates/core/models/src/v0/emojis.rs +++ b/crates/core/models/src/v0/emojis.rs @@ -54,4 +54,22 @@ auto_derived!( #[serde(default)] pub nsfw: bool, } + + /// Partial emoji representation + #[derive(Default)] + pub struct PartialEmoji { + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub name: Option, + } + + /// Edit emoji information + #[cfg_attr(feature = "validator", derive(Validate))] + pub struct DataEditEmoji { + /// Emoji name + #[cfg_attr( + feature = "validator", + validate(length(min = 1, max = 32), regex = "RE_EMOJI") + )] + pub name: Option, + } ); diff --git a/crates/delta/src/routes/customisation/emoji_edit.rs b/crates/delta/src/routes/customisation/emoji_edit.rs new file mode 100644 index 00000000..72e99e34 --- /dev/null +++ b/crates/delta/src/routes/customisation/emoji_edit.rs @@ -0,0 +1,212 @@ +use revolt_database::{ + util::{permissions::DatabasePermissionQuery, reference::Reference}, + Database, EmojiParent, PartialEmoji, User, +}; +use revolt_models::v0; +use revolt_permissions::{calculate_server_permissions, ChannelPermission}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use validator::Validate; + +/// # Edit Emoji +/// +/// Edit an emoji by its id. +#[openapi(tag = "Emojis")] +#[patch("/emoji/", data = "")] +pub async fn edit_emoji( + db: &State, + user: User, + emoji_id: Reference<'_>, + data: Json, +) -> Result> { + let data = data.into_inner(); + data.validate().map_err(|error| { + create_error!(FailedValidation { + error: error.to_string() + }) + })?; + + let mut emoji = emoji_id.as_emoji(db).await?; + + match &emoji.parent { + EmojiParent::Server { id } => { + let server = db.fetch_server(id.as_str()).await?; + + let mut query = DatabasePermissionQuery::new(db, &user).server(&server); + calculate_server_permissions(&mut query) + .await + .throw_if_lacking_channel_permission(ChannelPermission::ManageCustomisation)?; + } + EmojiParent::Detached => return Err(create_error!(NotAuthenticated)), + } + + if data.name.is_none() { + return Ok(Json(emoji.into())); + } + + let partial = PartialEmoji { name: data.name }; + emoji.update(db, partial).await?; + + Ok(Json(emoji.into())) +} + +#[cfg(test)] +mod test { + use crate::util::test::TestHarness; + use revolt_database::{Emoji, EmojiParent, Member}; + use revolt_models::v0; + use rocket::http::{ContentType, Header, Status}; + use ulid::Ulid; + + #[rocket::async_test] + async fn edit_emoji_name_as_creator() { + let harness = TestHarness::new().await; + let (_, session, user) = harness.new_user().await; + let (server, _) = harness.new_server(&user).await; + + let emoji_id = Ulid::new().to_string(); + let emoji = Emoji { + id: emoji_id.clone(), + parent: EmojiParent::Server { + id: server.id.clone(), + }, + creator_id: user.id.clone(), + name: "initial_name".to_string(), + animated: false, + nsfw: false, + }; + emoji.create(&harness.db).await.expect("`Emoji` created"); + + let response = harness + .client + .patch(format!("/custom/emoji/{emoji_id}")) + .header(Header::new("x-session-token", session.token.to_string())) + .header(ContentType::JSON) + .body( + json!(v0::DataEditEmoji { + name: Some("renamed_emoji".to_string()), + }) + .to_string(), + ) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Ok); + + let edited: v0::Emoji = response.into_json().await.expect("`Emoji`"); + assert_eq!(edited.name, "renamed_emoji"); + } + + #[rocket::async_test] + async fn reject_invalid_emoji_name() { + let harness = TestHarness::new().await; + let (_, session, user) = harness.new_user().await; + let (server, _) = harness.new_server(&user).await; + + let emoji_id = Ulid::new().to_string(); + let emoji = Emoji { + id: emoji_id.clone(), + parent: EmojiParent::Server { + id: server.id.clone(), + }, + creator_id: user.id.clone(), + name: "valid_name".to_string(), + animated: false, + nsfw: false, + }; + emoji.create(&harness.db).await.expect("`Emoji` created"); + + let response = harness + .client + .patch(format!("/custom/emoji/{emoji_id}")) + .header(Header::new("x-session-token", session.token.to_string())) + .header(ContentType::JSON) + .body( + json!(v0::DataEditEmoji { + name: Some("Invalid Name".to_string()), + }) + .to_string(), + ) + .dispatch() + .await; + + assert_eq!(response.status(), Status::BadRequest); + } + + #[rocket::async_test] + async fn reject_edit_for_detached_emoji() { + let harness = TestHarness::new().await; + let (_, session, user) = harness.new_user().await; + + let emoji_id = Ulid::new().to_string(); + let emoji = Emoji { + id: emoji_id.clone(), + parent: EmojiParent::Detached, + creator_id: user.id.clone(), + name: "detached_name".to_string(), + animated: false, + nsfw: false, + }; + emoji.create(&harness.db).await.expect("`Emoji` created"); + + let response = harness + .client + .patch(format!("/custom/emoji/{emoji_id}")) + .header(Header::new("x-session-token", session.token.to_string())) + .header(ContentType::JSON) + .body( + json!(v0::DataEditEmoji { + name: Some("should_not_apply".to_string()), + }) + .to_string(), + ) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn reject_edit_for_creator_without_manage_customisation() { + let harness = TestHarness::new().await; + let (_, _, owner) = harness.new_user().await; + let (_, creator_session, creator) = harness.new_user().await; + let (server, _) = harness.new_server(&owner).await; + + Member::create(&harness.db, &server, &creator, None) + .await + .expect("`Member` created"); + + let emoji_id = Ulid::new().to_string(); + let emoji = Emoji { + id: emoji_id.clone(), + parent: EmojiParent::Server { + id: server.id.clone(), + }, + creator_id: creator.id.clone(), + name: "member_uploaded_name".to_string(), + animated: false, + nsfw: false, + }; + emoji.create(&harness.db).await.expect("`Emoji` created"); + + let response = harness + .client + .patch(format!("/custom/emoji/{emoji_id}")) + .header(Header::new( + "x-session-token", + creator_session.token.to_string(), + )) + .header(ContentType::JSON) + .body( + json!(v0::DataEditEmoji { + name: Some("renamed_without_permission".to_string()), + }) + .to_string(), + ) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Forbidden); + } +} diff --git a/crates/delta/src/routes/customisation/mod.rs b/crates/delta/src/routes/customisation/mod.rs index 56d40995..5ff6f699 100644 --- a/crates/delta/src/routes/customisation/mod.rs +++ b/crates/delta/src/routes/customisation/mod.rs @@ -3,12 +3,14 @@ use rocket::Route; mod emoji_create; mod emoji_delete; +mod emoji_edit; mod emoji_fetch; pub fn routes() -> (Vec, OpenApi) { openapi_get_routes_spec![ emoji_create::create_emoji, emoji_delete::delete_emoji, + emoji_edit::edit_emoji, emoji_fetch::fetch_emoji ] } diff --git a/docs/docs/developers/events/protocol.md b/docs/docs/developers/events/protocol.md index 1c8a8618..6e0c8d5e 100644 --- a/docs/docs/developers/events/protocol.md +++ b/docs/docs/developers/events/protocol.md @@ -539,6 +539,22 @@ Emoji created, the event object has the same schema as the Emoji object in the A } ``` +### EmojiUpdate + +Emoji has been updated. + +```json +{ + "type": "EmojiUpdate", + "id": "{emoji_id}", + "data": { + "name"?: "{emoji_name}" + } +} +``` + +- `data` field contains a partial Emoji object. + ### EmojiDelete Emoji has been deleted.