b453d5d
use models::chat::emojis;
use models::space::spaces;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::errors::ChatError;
use crate::services::service::ChatService;
use crate::share::authz::require_space_manager;
use crate::share::lookup::{lookup_space_by_name, require_space_member};
pub const EMOJI_PREFIX: &str = "chat/emojis";
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EmojiDto {
pub id: Uuid,
pub space_id: Uuid,
pub name: String,
pub animated: bool,
pub disabled: bool,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UploadEmojiCmd {
pub name: String,
pub content_type: String,
pub data_base64: String,
#[serde(default)]
pub animated: bool,
}
const MAX_EMOJI_BYTES: usize = 512 * 1024;
impl ChatService {
pub async fn get_emoji(
&self,
caller: i64,
space_name: &str,
emoji_id: Uuid,
) -> Result<EmojiDto, ChatError> {
let space = lookup_space_by_name(self, space_name).await?;
require_space_member(self, &space, caller).await?;
let e = emojis::Entity::find_by_id(emoji_id)
.filter(emojis::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(ChatError::NotFound)?;
Ok(EmojiDto {
id: e.id,
space_id: e.space_id,
name: e.name,
animated: e.animated,
disabled: e.disabled,
created_at: e.created_at,
})
}
pub async fn list_emojis(
&self,
caller: i64,
space_name: &str,
) -> Result<Vec<EmojiDto>, ChatError> {
let space = lookup_space_by_name(self, space_name).await?;
require_space_member(self, &space, caller).await?;
let rows = emojis::Entity::find()
.filter(emojis::Column::SpaceId.eq(space.id))
.order_by_asc(emojis::Column::LowerName)
.all(self.db().read())
.await?;
Ok(rows
.into_iter()
.map(|e| EmojiDto {
id: e.id,
space_id: e.space_id,
name: e.name,
animated: e.animated,
disabled: e.disabled,
created_at: e.created_at,
})
.collect())
}
pub async fn upload_emoji(
&self,
caller: i64,
space_name: &str,
cmd: UploadEmojiCmd,
) -> Result<EmojiDto, ChatError> {
let space = lookup_space_by_name(self, space_name).await?;
require_space_member(self, &space, caller).await?;
require_space_manager(self, &space, caller).await?;
let name = cmd.name.trim().to_string();
let lower = name.to_lowercase();
if name.is_empty()
|| name.chars().count() > self.config().chat.max_emoji_name_len
|| !lower
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
return Err(ChatError::BadRequest("invalid emoji name".to_string()));
}
if !matches!(
cmd.content_type.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp"
) {
return Err(ChatError::BadRequest(
"unsupported emoji content_type".to_string(),
));
}
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
cmd.data_base64.as_bytes(),
)
.map_err(|e| ChatError::BadRequest(format!("invalid base64 payload: {e}")))?;
if bytes.is_empty() || bytes.len() > MAX_EMOJI_BYTES {
return Err(ChatError::BadRequest(
"emoji payload size invalid".to_string(),
));
}
let duplicate = emojis::Entity::find()
.filter(emojis::Column::SpaceId.eq(space.id))
.filter(emojis::Column::LowerName.eq(&lower))
.one(self.db().read())
.await?;
if duplicate.is_some() {
return Err(ChatError::Conflict("emoji name already exists".to_string()));
}
let now = OffsetDateTime::now_utc();
let id = Uuid::now_v7();
let ext = match cmd.content_type.as_str() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
_ => "webp",
};
let key = format!("{EMOJI_PREFIX}/{}/{id}.{ext}", space.id);
self.storage()
.client()
.put_with_content_type(&key, &bytes, &cmd.content_type)
.await
.map_err(ChatError::from)?;
let row = emojis::ActiveModel {
id: Set(id),
space_id: Set(space.id),
name: Set(name),
lower_name: Set(lower),
user_id: Set(caller),
bucket: Set(self.storage().client().bucket_name().to_string()),
s3_key: Set(key),
animated: Set(cmd.animated),
disabled: Set(false),
created_at: Set(now),
}
.insert(self.db().write())
.await?;
Ok(EmojiDto {
id: row.id,
space_id: row.space_id,
name: row.name,
animated: row.animated,
disabled: row.disabled,
created_at: row.created_at,
})
}
pub async fn delete_emoji(
&self,
caller: i64,
space_name: &str,
emoji_id: Uuid,
) -> Result<(), ChatError> {
let space = lookup_space_by_name(self, space_name).await?;
require_space_member(self, &space, caller).await?;
require_space_manager(self, &space, caller).await?;
let row = emojis::Entity::find_by_id(emoji_id)
.filter(emojis::Column::SpaceId.eq(space.id))
.one(self.db().write())
.await?
.ok_or(ChatError::NotFound)?;
emojis::Entity::delete_by_id(row.id)
.exec(self.db().write())
.await?;
if let Err(error) = self.storage().client().delete(&row.s3_key).await {
tracing::warn!(%error, "failed to delete emoji object");
}
Ok(())
}
pub(crate) async fn emoji_image(
&self,
space: &spaces::Model,
emoji_id: Uuid,
) -> Result<Vec<u8>, ChatError> {
let row = emojis::Entity::find_by_id(emoji_id)
.filter(emojis::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(ChatError::NotFound)?;
Ok(self.storage().client().get(&row.s3_key).await?)
}
}