diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index 66c76c7..99261eb 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -658,6 +658,7 @@ impl MigrationTrait for Migration { .primary_key(), ) .col(ColumnDef::new(Alias::new("server_id")).uuid().null()) + .col(ColumnDef::new(Alias::new("name")).string().not_null()) .col(ColumnDef::new(Alias::new("emoji_type")).string().not_null()) .col(ColumnDef::new(Alias::new("unicode_sequence")).text().null()) .col(ColumnDef::new(Alias::new("file_path")).text().null()) @@ -705,56 +706,6 @@ impl MigrationTrait for Migration { seed_unicode_emojis(manager).await?; - manager - .create_table( - Table::create() - .table(Alias::new("emoji_alias")) - .if_not_exists() - .col( - ColumnDef::new(Alias::new("id")) - .uuid() - .not_null() - .primary_key(), - ) - .col(ColumnDef::new(Alias::new("emoji_id")).uuid().not_null()) - .col(ColumnDef::new(Alias::new("alias")).string().not_null()) - .col( - ColumnDef::new(Alias::new("created_at")) - .timestamp_with_time_zone() - .not_null() - .default(Expr::current_timestamp()), - ) - .col( - ColumnDef::new(Alias::new("updated_at")) - .timestamp_with_time_zone() - .not_null() - .default(Expr::current_timestamp()), - ) - .foreign_key( - ForeignKey::create() - .name("fk_emoji_alias_emoji") - .from(Alias::new("emoji_alias"), Alias::new("emoji_id")) - .to(Alias::new("emoji"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade), - ) - .to_owned(), - ) - .await?; - - manager - .create_index( - Index::create() - .name("uq_emoji_alias_emoji") - .table(Alias::new("emoji_alias")) - .col(Alias::new("emoji_id")) - .col(Alias::new("alias")) - .unique() - .to_owned(), - ) - .await?; - - seed_unicode_aliases(manager).await?; - manager .create_table( Table::create() @@ -1019,7 +970,6 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let tables = [ "computed_permission", - "emoji_alias", "emoji", "channel_user_read_state", "channel_user_permission", @@ -1060,21 +1010,42 @@ impl MigrationTrait for Migration { /// number of bind parameters in a single statement to 999. async fn seed_unicode_emojis(manager: &SchemaManager<'_>) -> Result<(), DbErr> { const CHUNK_SIZE: usize = 250; - let rows = unicode_rows()?; + let mut used_names = HashSet::with_capacity(3944); + let rows = unicode_rows()? + .into_iter() + .map(|(id, sequence, raw_name)| { + let base = normalize_unicode_name(&raw_name); + let mut name = base.clone(); + if !used_names.insert(name.clone()) { + name = format!("{base}_u{}", sequence.replace(' ', "")); + if name.len() > 64 { + name.truncate(64); + } + if !used_names.insert(name.clone()) { + return Err(DbErr::Custom(format!( + "duplicate generated emoji name: {name}" + ))); + } + } + Ok((id, sequence, name)) + }) + .collect::, DbErr>>()?; for chunk in rows.chunks(CHUNK_SIZE) { let mut insert = Query::insert(); insert.into_table(Alias::new("emoji")).columns([ Alias::new("id"), Alias::new("server_id"), + Alias::new("name"), Alias::new("emoji_type"), Alias::new("unicode_sequence"), ]); - for (id, sequence, _) in chunk { + for (id, sequence, name) in chunk { insert.values_panic([ Expr::val(*id).into(), Expr::val(Option::::None).into(), + Expr::val(name.as_str()).into(), Expr::val("unicode").into(), Expr::val(sequence.as_str()).into(), ]); @@ -1086,61 +1057,6 @@ async fn seed_unicode_emojis(manager: &SchemaManager<'_>) -> Result<(), DbErr> { Ok(()) } -async fn seed_unicode_aliases(manager: &SchemaManager<'_>) -> Result<(), DbErr> { - const CHUNK_SIZE: usize = 250; - let rows = unicode_rows()?; - let mut aliases = Vec::with_capacity(rows.len()); - let mut used = HashSet::with_capacity(rows.len()); - - for (_, sequence, name) in rows { - let base = normalize_unicode_alias(&name); - let mut alias = base.clone(); - if !used.insert(alias.clone()) { - alias = format!("{base}_u{}", sequence.replace(' ', "")); - if alias.len() > 64 { - alias.truncate(64); - } - if !used.insert(alias.clone()) { - return Err(DbErr::Custom(format!( - "duplicate generated emoji alias: {alias}" - ))); - } - } - - let emoji_id = Uuid::new_v5( - &Uuid::NAMESPACE_URL, - format!("https://oxspeak.local/unicode/{UNICODE_EMOJI_VERSION}/{sequence}").as_bytes(), - ); - let id = Uuid::new_v5( - &Uuid::NAMESPACE_URL, - format!("https://oxspeak.local/unicode-alias/{UNICODE_EMOJI_VERSION}/{sequence}") - .as_bytes(), - ); - aliases.push((id, emoji_id, alias)); - } - - for chunk in aliases.chunks(CHUNK_SIZE) { - let mut insert = Query::insert(); - insert.into_table(Alias::new("emoji_alias")).columns([ - Alias::new("id"), - Alias::new("emoji_id"), - Alias::new("alias"), - ]); - - for (id, emoji_id, alias) in chunk { - insert.values_panic([ - Expr::val(*id).into(), - Expr::val(*emoji_id).into(), - Expr::val(alias.as_str()).into(), - ]); - } - - manager.exec_stmt(insert).await?; - } - - Ok(()) -} - fn unicode_rows() -> Result, DbErr> { UNICODE_EMOJI_DATA .lines() @@ -1160,7 +1076,7 @@ fn unicode_rows() -> Result, DbErr> { .collect() } -fn normalize_unicode_alias(name: &str) -> String { +fn normalize_unicode_name(name: &str) -> String { let mut alias = String::with_capacity(name.len()); let mut separator = false; @@ -1223,16 +1139,16 @@ mod tests { } #[test] - fn unicode_aliases_are_normalized_for_the_existing_api_constraints() { + fn unicode_names_are_normalized_for_the_existing_api_constraints() { assert_eq!( - normalize_unicode_alias("flag: United States"), + normalize_unicode_name("flag: United States"), "flag_united_states" ); assert_eq!( - normalize_unicode_alias("thumbs up: medium skin tone"), + normalize_unicode_name("thumbs up: medium skin tone"), "thumbs_up_medium_skin_tone" ); - assert!(normalize_unicode_alias("woman technologist") + assert!(normalize_unicode_name("woman technologist") .chars() .all(|character| character.is_ascii_alphanumeric() || character == '_')); } diff --git a/src/domain/dto/emoji.rs b/src/domain/dto/emoji.rs index fee3c0f..949ea53 100644 --- a/src/domain/dto/emoji.rs +++ b/src/domain/dto/emoji.rs @@ -13,14 +13,14 @@ pub struct CreateEmojiRequest { pub server_id: Option, pub emoji_type: String, pub unicode_sequence: Option, - pub aliases: Vec, + pub name: String, pub mime_type: Option, pub is_animated: Option, } #[derive(Debug, Deserialize, Serialize, ToSchema)] pub struct UpdateEmojiRequest { - pub aliases: Option>, + pub name: Option, pub server_id: Option, pub unicode_sequence: Option, } @@ -31,7 +31,7 @@ pub struct EmojiResponse { pub server_id: Option, pub emoji_type: String, pub unicode_sequence: Option, - pub aliases: Vec, + pub name: String, pub asset_url: Option, pub mime_type: Option, pub file_size: Option, diff --git a/src/models/emoji.rs b/src/models/emoji.rs index 9309d1c..04271e8 100644 --- a/src/models/emoji.rs +++ b/src/models/emoji.rs @@ -9,6 +9,7 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub server_id: Option, + pub name: String, pub emoji_type: String, pub unicode_sequence: Option, pub file_path: Option, @@ -18,8 +19,6 @@ pub struct Model { pub sha256: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, - #[sea_orm(has_many)] - pub aliases: HasMany, } #[async_trait] diff --git a/src/models/emoji_alias.rs b/src/models/emoji_alias.rs deleted file mode 100644 index 9bfc9a0..0000000 --- a/src/models/emoji_alias.rs +++ /dev/null @@ -1,27 +0,0 @@ -use sea_orm::Set; -use sea_orm::entity::prelude::*; -use sea_orm::prelude::async_trait::async_trait; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] -#[sea_orm(table_name = "emoji_alias")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub emoji_id: Uuid, - pub alias: String, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(belongs_to, from = "emoji_id", to = "id", on_delete = "Cascade")] - pub emoji: HasOne, -} - -#[async_trait] -impl ActiveModelBehavior for ActiveModel { - fn new() -> Self { - Self { - id: Set(Uuid::new_v4()), - ..Default::default() - } - } -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 6cf5c93..05f2593 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -11,7 +11,6 @@ pub mod channel_user_permission; pub mod channel_user_read_state; pub mod computed_permission; pub mod emoji; -pub mod emoji_alias; pub mod message; pub mod role; pub mod role_user; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index dce8714..a024e27 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -7,7 +7,6 @@ pub use super::channel_user::Entity as ChannelUser; pub use super::channel_user_read_state::Entity as ChannelUserReadState; pub use super::computed_permission::Entity as ComputedPermission; pub use super::emoji::Entity as Emoji; -pub use super::emoji_alias::Entity as EmojiAlias; pub use super::message::Entity as Message; pub use super::role::Entity as Group; pub use super::role_user::Entity as GroupMember; diff --git a/src/repositories/emoji.rs b/src/repositories/emoji.rs index 49b1d8a..4e904d3 100644 --- a/src/repositories/emoji.rs +++ b/src/repositories/emoji.rs @@ -1,8 +1,6 @@ -use crate::models::{emoji, emoji_alias}; +use crate::models::emoji; use crate::repositories::{AnyResult, RepositoryContext}; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, Set, -}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder}; use std::sync::Arc; use uuid::Uuid; @@ -42,30 +40,16 @@ impl EmojiRepository { Ok(query.all(&self.context.db).await?) } - pub async fn aliases(&self, emoji_id: Uuid) -> AnyResult> { - Ok(emoji_alias::Entity::find() - .filter(emoji_alias::Column::EmojiId.eq(emoji_id)) - .all(&self.context.db) - .await?) - } - - pub async fn find_alias( + pub async fn find_name( &self, - alias: &str, + name: &str, server_id: Option, ) -> AnyResult> { - let rows = self.list(server_id).await?; - for model in rows { - if self - .aliases(model.id) - .await? - .iter() - .any(|a| a.alias == alias) - { - return Ok(Some(model)); - } - } - Ok(None) + Ok(self + .list(server_id) + .await? + .into_iter() + .find(|model| model.name == name)) } pub async fn create(&self, model: emoji::ActiveModel) -> AnyResult { @@ -81,20 +65,4 @@ impl EmojiRepository { .rows_affected > 0) } - pub async fn add_alias(&self, emoji_id: Uuid, alias: String) -> AnyResult { - Ok((emoji_alias::ActiveModel { - emoji_id: Set(emoji_id), - alias: Set(alias), - ..Default::default() - }) - .insert(&self.context.db) - .await?) - } - pub async fn clear_aliases(&self, emoji_id: Uuid) -> AnyResult<()> { - emoji_alias::Entity::delete_many() - .filter(emoji_alias::Column::EmojiId.eq(emoji_id)) - .exec(&self.context.db) - .await?; - Ok(()) - } } diff --git a/src/routes/emoji/handlers.rs b/src/routes/emoji/handlers.rs index 17ea8c6..f63262b 100644 --- a/src/routes/emoji/handlers.rs +++ b/src/routes/emoji/handlers.rs @@ -13,7 +13,7 @@ use axum::{ http::{StatusCode, header}, response::Response, }; -use sea_orm::{EntityTrait, Set}; +use sea_orm::Set; use std::path::PathBuf; use uuid::Uuid; @@ -34,8 +34,7 @@ pub async fn get_all( ) -> Result>, HTTPError> { let mut result = Vec::new(); for emoji in state.repositories.emoji.list(query.server_id).await? { - let aliases = state.repositories.emoji.aliases(emoji.id).await?; - result.push(mapper::response(emoji, mapper::aliases(aliases))); + result.push(mapper::response(emoji)); } Ok(Json(result)) } @@ -51,8 +50,7 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - let aliases = state.repositories.emoji.aliases(id).await?; - Ok(Json(mapper::response(emoji, mapper::aliases(aliases)))) + Ok(Json(mapper::response(emoji))) } #[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] @@ -63,7 +61,7 @@ pub async fn create( let mut server_id = None; let mut emoji_type = None; let mut unicode_sequence = None; - let mut aliases = Vec::new(); + let mut name = None; let mut file = None; let mut mime = None; let mut animated = false; @@ -72,8 +70,8 @@ pub async fn create( .await .map_err(|e| HTTPError::BadRequest(e.to_string()))? { - let name = field.name().unwrap_or_default().to_string(); - if name == "file" { + let field_name = field.name().unwrap_or_default().to_string(); + if field_name == "file" { mime = field.content_type().map(str::to_string); file = Some( field @@ -87,7 +85,7 @@ pub async fn create( .text() .await .map_err(|e| HTTPError::BadRequest(e.to_string()))?; - match name.as_str() { + match field_name.as_str() { "server_id" => { server_id = Some( value @@ -97,13 +95,13 @@ pub async fn create( } "emoji_type" => emoji_type = Some(value), "unicode_sequence" => unicode_sequence = Some(value), - "alias" => aliases.push(value), - "aliases" => aliases.extend(value.split(',').map(str::to_string)), + "name" => name = Some(value), "is_animated" => animated = value.parse().unwrap_or(false), _ => {} } } let emoji_type = normalize_type(emoji_type.as_deref().unwrap_or("custom"))?; + let name = name.ok_or(HTTPError::BadRequest("name is required".into()))?; if emoji_type == "unicode" && unicode_sequence.is_none() { return Err(HTTPError::BadRequest("unicode_sequence is required".into())); } @@ -138,6 +136,7 @@ pub async fn create( let model = emoji::ActiveModel { id: Set(id), server_id: Set(server_id), + name: Set(String::new()), emoji_type: Set(emoji_type), unicode_sequence: Set(unicode_sequence), file_path: Set(path), @@ -147,12 +146,8 @@ pub async fn create( sha256: Set(sha), ..Default::default() }; - let created = state.services.emoji.create(model, aliases).await?; - let aliases = state.repositories.emoji.aliases(created.id).await?; - Ok(( - StatusCode::CREATED, - Json(mapper::response(created, mapper::aliases(aliases))), - )) + let created = state.services.emoji.create(model, name).await?; + Ok((StatusCode::CREATED, Json(mapper::response(created)))) } #[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] @@ -168,21 +163,11 @@ pub async fn update( .await? .ok_or(HTTPError::NotFound)?; let target_server_id = payload.server_id.or(existing.server_id); - let aliases_to_validate = match &payload.aliases { - Some(aliases) => aliases.clone(), - None => state - .repositories - .emoji - .aliases(id) - .await? - .into_iter() - .map(|a| a.alias) - .collect(), - }; + let target_name = payload.name.as_deref().unwrap_or(&existing.name); state .services .emoji - .aliases_available(&aliases_to_validate, target_server_id, Some(id)) + .name_available(target_name, target_server_id, Some(id)) .await?; let mut active: emoji::ActiveModel = existing.into(); if let Some(server_id) = payload.server_id { @@ -197,21 +182,11 @@ pub async fn update( if payload.unicode_sequence.is_some() { active.unicode_sequence = Set(payload.unicode_sequence); } - let updated = state.repositories.emoji.update(active).await?; - if let Some(ref aliases) = payload.aliases { - state - .services - .emoji - .aliases_available(aliases, updated.server_id, Some(id)) - .await?; - state - .services - .emoji - .replace_aliases(id, aliases.clone()) - .await?; + if let Some(name) = payload.name { + active.name = Set(EmojiService::normalize_name(&name)?); } - let aliases = state.repositories.emoji.aliases(id).await?; - Ok(Json(mapper::response(updated, mapper::aliases(aliases)))) + let updated = state.repositories.emoji.update(active).await?; + Ok(Json(mapper::response(updated))) } fn detect_mime(bytes: &[u8]) -> Option { diff --git a/src/routes/emoji/mapper.rs b/src/routes/emoji/mapper.rs index ce2d30c..fa6213d 100644 --- a/src/routes/emoji/mapper.rs +++ b/src/routes/emoji/mapper.rs @@ -1,12 +1,12 @@ use crate::{domain::dto::emoji::EmojiResponse, models::emoji}; -pub fn response(model: emoji::Model, aliases: Vec) -> EmojiResponse { +pub fn response(model: emoji::Model) -> EmojiResponse { EmojiResponse { id: model.id, server_id: model.server_id, emoji_type: model.emoji_type, unicode_sequence: model.unicode_sequence, - aliases, + name: model.name, asset_url: model .file_path .as_ref() @@ -19,7 +19,3 @@ pub fn response(model: emoji::Model, aliases: Vec) -> EmojiResponse { updated_at: model.updated_at, } } - -pub fn aliases(models: Vec) -> Vec { - models.into_iter().map(|a| a.alias).collect() -} diff --git a/src/services/emoji.rs b/src/services/emoji.rs index 7531a9f..705f7fd 100644 --- a/src/services/emoji.rs +++ b/src/services/emoji.rs @@ -1,7 +1,7 @@ use crate::http::error::HTTPError; use crate::models::emoji; use crate::services::ServicesContext; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use sea_orm::{ActiveModelTrait, Set, TransactionTrait}; use sha2::{Digest, Sha256}; use std::{ path::{Path, PathBuf}, @@ -19,32 +19,23 @@ impl EmojiService { pub fn new(context: Arc) -> Self { Self { context } } - pub fn normalize_alias(alias: &str) -> Result { - let alias = alias.trim().trim_matches(':').to_lowercase(); - if alias.is_empty() - || alias.len() > 64 - || !alias - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+') + pub fn normalize_name(name: &str) -> Result { + let name = name.trim().trim_matches(':').to_lowercase(); + if name.is_empty() + || name.len() > 64 + || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - return Err(HTTPError::BadRequest("Invalid emoji alias".into())); + return Err(HTTPError::BadRequest("Invalid emoji name".into())); } - Ok(alias) + Ok(name) } - pub async fn aliases_available( + pub async fn name_available( &self, - aliases: &[String], + name: &str, server_id: Option, except: Option, ) -> Result<(), HTTPError> { - let normalized = aliases - .iter() - .map(|a| Self::normalize_alias(a)) - .collect::, _>>()?; - let mut unique = std::collections::HashSet::new(); - if normalized.iter().any(|alias| !unique.insert(alias)) { - return Err(HTTPError::BadRequest("Duplicate emoji alias".into())); - } + let name = Self::normalize_name(name)?; let scoped = self .context .repositories @@ -55,74 +46,35 @@ impl EmojiService { if Some(model.id) == except { continue; } - for alias in self.context.repositories.emoji.aliases(model.id).await? { - if normalized.iter().any(|candidate| candidate == &alias.alias) { - return Err(HTTPError::BadRequest( - "Emoji alias already exists in this scope".into(), - )); - } + if model.name == name { + return Err(HTTPError::BadRequest( + "Emoji name already exists in this scope".into(), + )); } } Ok(()) } pub async fn create( &self, - model: emoji::ActiveModel, - aliases: Vec, + mut model: emoji::ActiveModel, + name: String, ) -> Result { let db = &self.context.repositories.emoji.context.db; - let aliases = aliases - .into_iter() - .map(|a| Self::normalize_alias(&a)) - .collect::, _>>()?; + let name = Self::normalize_name(&name)?; let server_id = match &model.server_id { sea_orm::ActiveValue::Set(value) => *value, _ => None, }; - self.aliases_available(&aliases, server_id, None).await?; + self.name_available(&name, server_id, None).await?; + model.name = Set(name); let result = db .transaction::<_, emoji::Model, anyhow::Error>(|txn| { - Box::pin(async move { - let model = model.insert(txn).await?; - for alias in aliases { - crate::models::emoji_alias::ActiveModel { - emoji_id: Set(model.id), - alias: Set(alias), - ..Default::default() - } - .insert(txn) - .await?; - } - Ok(model) - }) + Box::pin(async move { Ok(model.insert(txn).await?) }) }) .await .map_err(|e| HTTPError::Internal(anyhow::anyhow!(e)))?; Ok(result) } - pub async fn replace_aliases(&self, id: Uuid, aliases: Vec) -> Result<(), HTTPError> { - let aliases = aliases - .into_iter() - .map(|a| Self::normalize_alias(&a)) - .collect::, _>>()?; - let db = &self.context.repositories.emoji.context.db; - let txn = db.begin().await?; - crate::models::emoji_alias::Entity::delete_many() - .filter(crate::models::emoji_alias::Column::EmojiId.eq(id)) - .exec(&txn) - .await?; - for alias in aliases { - crate::models::emoji_alias::ActiveModel { - emoji_id: Set(id), - alias: Set(alias), - ..Default::default() - } - .insert(&txn) - .await?; - } - txn.commit().await?; - Ok(()) - } pub fn hash(data: &[u8]) -> String { Sha256::digest(data) .iter()