use api::pagination::{Page, PageQuery}; use models::chat::emojis; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use space::Space; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::authz; 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 { 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.database.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, page: PageQuery, ) -> Result, ChatError> { let space = lookup_space_by_name(self, space_name).await?; require_space_member(self, &space, caller).await?; let (limit, offset) = page.resolve( self.settings.chat.default_pagination, self.settings.chat.max_pagination, )?; let db = self.database.read(); let select = emojis::Entity::find().filter(emojis::Column::SpaceId.eq(space.id())); let total = select.clone().count(db).await?; let rows = select .order_by_asc(emojis::Column::LowerName) .limit(limit) .offset(offset) .all(db) .await?; let items = 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(); Ok(Page::new(items, total, limit, offset)) } pub async fn upload_emoji( &self, caller: i64, space_name: &str, cmd: UploadEmojiCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_space_member(self, &space, caller).await?; authz::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.settings.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.database.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.media .client .as_ref() .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.media.client.as_ref().bucket_name().to_string()), s3_key: Set(key), animated: Set(cmd.animated), disabled: Set(false), created_at: Set(now), } .insert(self.database.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?; authz::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.database.write()) .await? .ok_or(ChatError::NotFound)?; emojis::Entity::delete_by_id(row.id) .exec(self.database.write()) .await?; if let Err(error) = self.media.client.as_ref().delete(&row.s3_key).await { tracing::warn!(%error, "failed to delete emoji object"); } Ok(()) } pub(crate) async fn emoji_image( &self, space: &Space, emoji_id: Uuid, ) -> Result, ChatError> { let row = emojis::Entity::find_by_id(emoji_id) .filter(emojis::Column::SpaceId.eq(space.id())) .one(self.database.read()) .await? .ok_or(ChatError::NotFound)?; Ok(self.media.client.as_ref().get(&row.s3_key).await?) } }