This commit is contained in:
2026-08-16 20:48:53 +02:00
parent c48faa4d6e
commit 07f4a76d74
10 changed files with 83 additions and 306 deletions
+29 -113
View File
@@ -658,6 +658,7 @@ impl MigrationTrait for Migration {
.primary_key(), .primary_key(),
) )
.col(ColumnDef::new(Alias::new("server_id")).uuid().null()) .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("emoji_type")).string().not_null())
.col(ColumnDef::new(Alias::new("unicode_sequence")).text().null()) .col(ColumnDef::new(Alias::new("unicode_sequence")).text().null())
.col(ColumnDef::new(Alias::new("file_path")).text().null()) .col(ColumnDef::new(Alias::new("file_path")).text().null())
@@ -705,56 +706,6 @@ impl MigrationTrait for Migration {
seed_unicode_emojis(manager).await?; 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 manager
.create_table( .create_table(
Table::create() Table::create()
@@ -1019,7 +970,6 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let tables = [ let tables = [
"computed_permission", "computed_permission",
"emoji_alias",
"emoji", "emoji",
"channel_user_read_state", "channel_user_read_state",
"channel_user_permission", "channel_user_permission",
@@ -1060,21 +1010,42 @@ impl MigrationTrait for Migration {
/// number of bind parameters in a single statement to 999. /// number of bind parameters in a single statement to 999.
async fn seed_unicode_emojis(manager: &SchemaManager<'_>) -> Result<(), DbErr> { async fn seed_unicode_emojis(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
const CHUNK_SIZE: usize = 250; 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::<Result<Vec<_>, DbErr>>()?;
for chunk in rows.chunks(CHUNK_SIZE) { for chunk in rows.chunks(CHUNK_SIZE) {
let mut insert = Query::insert(); let mut insert = Query::insert();
insert.into_table(Alias::new("emoji")).columns([ insert.into_table(Alias::new("emoji")).columns([
Alias::new("id"), Alias::new("id"),
Alias::new("server_id"), Alias::new("server_id"),
Alias::new("name"),
Alias::new("emoji_type"), Alias::new("emoji_type"),
Alias::new("unicode_sequence"), Alias::new("unicode_sequence"),
]); ]);
for (id, sequence, _) in chunk { for (id, sequence, name) in chunk {
insert.values_panic([ insert.values_panic([
Expr::val(*id).into(), Expr::val(*id).into(),
Expr::val(Option::<Uuid>::None).into(), Expr::val(Option::<Uuid>::None).into(),
Expr::val(name.as_str()).into(),
Expr::val("unicode").into(), Expr::val("unicode").into(),
Expr::val(sequence.as_str()).into(), Expr::val(sequence.as_str()).into(),
]); ]);
@@ -1086,61 +1057,6 @@ async fn seed_unicode_emojis(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
Ok(()) 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<Vec<(Uuid, String, String)>, DbErr> { fn unicode_rows() -> Result<Vec<(Uuid, String, String)>, DbErr> {
UNICODE_EMOJI_DATA UNICODE_EMOJI_DATA
.lines() .lines()
@@ -1160,7 +1076,7 @@ fn unicode_rows() -> Result<Vec<(Uuid, String, String)>, DbErr> {
.collect() .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 alias = String::with_capacity(name.len());
let mut separator = false; let mut separator = false;
@@ -1223,16 +1139,16 @@ mod tests {
} }
#[test] #[test]
fn unicode_aliases_are_normalized_for_the_existing_api_constraints() { fn unicode_names_are_normalized_for_the_existing_api_constraints() {
assert_eq!( assert_eq!(
normalize_unicode_alias("flag: United States"), normalize_unicode_name("flag: United States"),
"flag_united_states" "flag_united_states"
); );
assert_eq!( assert_eq!(
normalize_unicode_alias("thumbs up: medium skin tone"), normalize_unicode_name("thumbs up: medium skin tone"),
"thumbs_up_medium_skin_tone" "thumbs_up_medium_skin_tone"
); );
assert!(normalize_unicode_alias("woman technologist") assert!(normalize_unicode_name("woman technologist")
.chars() .chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_')); .all(|character| character.is_ascii_alphanumeric() || character == '_'));
} }
+3 -3
View File
@@ -13,14 +13,14 @@ pub struct CreateEmojiRequest {
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub emoji_type: String, pub emoji_type: String,
pub unicode_sequence: Option<String>, pub unicode_sequence: Option<String>,
pub aliases: Vec<String>, pub name: String,
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub is_animated: Option<bool>, pub is_animated: Option<bool>,
} }
#[derive(Debug, Deserialize, Serialize, ToSchema)] #[derive(Debug, Deserialize, Serialize, ToSchema)]
pub struct UpdateEmojiRequest { pub struct UpdateEmojiRequest {
pub aliases: Option<Vec<String>>, pub name: Option<String>,
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub unicode_sequence: Option<String>, pub unicode_sequence: Option<String>,
} }
@@ -31,7 +31,7 @@ pub struct EmojiResponse {
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub emoji_type: String, pub emoji_type: String,
pub unicode_sequence: Option<String>, pub unicode_sequence: Option<String>,
pub aliases: Vec<String>, pub name: String,
pub asset_url: Option<String>, pub asset_url: Option<String>,
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub file_size: Option<i64>, pub file_size: Option<i64>,
+1 -2
View File
@@ -9,6 +9,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub name: String,
pub emoji_type: String, pub emoji_type: String,
pub unicode_sequence: Option<String>, pub unicode_sequence: Option<String>,
pub file_path: Option<String>, pub file_path: Option<String>,
@@ -18,8 +19,6 @@ pub struct Model {
pub sha256: Option<String>, pub sha256: Option<String>,
pub created_at: DateTimeUtc, pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc, pub updated_at: DateTimeUtc,
#[sea_orm(has_many)]
pub aliases: HasMany<super::emoji_alias::Entity>,
} }
#[async_trait] #[async_trait]
-27
View File
@@ -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<super::emoji::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
..Default::default()
}
}
}
-1
View File
@@ -11,7 +11,6 @@ pub mod channel_user_permission;
pub mod channel_user_read_state; pub mod channel_user_read_state;
pub mod computed_permission; pub mod computed_permission;
pub mod emoji; pub mod emoji;
pub mod emoji_alias;
pub mod message; pub mod message;
pub mod role; pub mod role;
pub mod role_user; pub mod role_user;
-1
View File
@@ -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::channel_user_read_state::Entity as ChannelUserReadState;
pub use super::computed_permission::Entity as ComputedPermission; pub use super::computed_permission::Entity as ComputedPermission;
pub use super::emoji::Entity as Emoji; 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::message::Entity as Message;
pub use super::role::Entity as Group; pub use super::role::Entity as Group;
pub use super::role_user::Entity as GroupMember; pub use super::role_user::Entity as GroupMember;
+9 -41
View File
@@ -1,8 +1,6 @@
use crate::models::{emoji, emoji_alias}; use crate::models::emoji;
use crate::repositories::{AnyResult, RepositoryContext}; use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder};
ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, Set,
};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -42,30 +40,16 @@ impl EmojiRepository {
Ok(query.all(&self.context.db).await?) Ok(query.all(&self.context.db).await?)
} }
pub async fn aliases(&self, emoji_id: Uuid) -> AnyResult<Vec<emoji_alias::Model>> { pub async fn find_name(
Ok(emoji_alias::Entity::find()
.filter(emoji_alias::Column::EmojiId.eq(emoji_id))
.all(&self.context.db)
.await?)
}
pub async fn find_alias(
&self, &self,
alias: &str, name: &str,
server_id: Option<Uuid>, server_id: Option<Uuid>,
) -> AnyResult<Option<emoji::Model>> { ) -> AnyResult<Option<emoji::Model>> {
let rows = self.list(server_id).await?; Ok(self
for model in rows { .list(server_id)
if self .await?
.aliases(model.id) .into_iter()
.await? .find(|model| model.name == name))
.iter()
.any(|a| a.alias == alias)
{
return Ok(Some(model));
}
}
Ok(None)
} }
pub async fn create(&self, model: emoji::ActiveModel) -> AnyResult<emoji::Model> { pub async fn create(&self, model: emoji::ActiveModel) -> AnyResult<emoji::Model> {
@@ -81,20 +65,4 @@ impl EmojiRepository {
.rows_affected .rows_affected
> 0) > 0)
} }
pub async fn add_alias(&self, emoji_id: Uuid, alias: String) -> AnyResult<emoji_alias::Model> {
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(())
}
} }
+18 -43
View File
@@ -13,7 +13,7 @@ use axum::{
http::{StatusCode, header}, http::{StatusCode, header},
response::Response, response::Response,
}; };
use sea_orm::{EntityTrait, Set}; use sea_orm::Set;
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@@ -34,8 +34,7 @@ pub async fn get_all(
) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> { ) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> {
let mut result = Vec::new(); let mut result = Vec::new();
for emoji in state.repositories.emoji.list(query.server_id).await? { 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));
result.push(mapper::response(emoji, mapper::aliases(aliases)));
} }
Ok(Json(result)) Ok(Json(result))
} }
@@ -51,8 +50,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
let aliases = state.repositories.emoji.aliases(id).await?; Ok(Json(mapper::response(emoji)))
Ok(Json(mapper::response(emoji, mapper::aliases(aliases))))
} }
#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[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 server_id = None;
let mut emoji_type = None; let mut emoji_type = None;
let mut unicode_sequence = None; let mut unicode_sequence = None;
let mut aliases = Vec::new(); let mut name = None;
let mut file = None; let mut file = None;
let mut mime = None; let mut mime = None;
let mut animated = false; let mut animated = false;
@@ -72,8 +70,8 @@ pub async fn create(
.await .await
.map_err(|e| HTTPError::BadRequest(e.to_string()))? .map_err(|e| HTTPError::BadRequest(e.to_string()))?
{ {
let name = field.name().unwrap_or_default().to_string(); let field_name = field.name().unwrap_or_default().to_string();
if name == "file" { if field_name == "file" {
mime = field.content_type().map(str::to_string); mime = field.content_type().map(str::to_string);
file = Some( file = Some(
field field
@@ -87,7 +85,7 @@ pub async fn create(
.text() .text()
.await .await
.map_err(|e| HTTPError::BadRequest(e.to_string()))?; .map_err(|e| HTTPError::BadRequest(e.to_string()))?;
match name.as_str() { match field_name.as_str() {
"server_id" => { "server_id" => {
server_id = Some( server_id = Some(
value value
@@ -97,13 +95,13 @@ pub async fn create(
} }
"emoji_type" => emoji_type = Some(value), "emoji_type" => emoji_type = Some(value),
"unicode_sequence" => unicode_sequence = Some(value), "unicode_sequence" => unicode_sequence = Some(value),
"alias" => aliases.push(value), "name" => name = Some(value),
"aliases" => aliases.extend(value.split(',').map(str::to_string)),
"is_animated" => animated = value.parse().unwrap_or(false), "is_animated" => animated = value.parse().unwrap_or(false),
_ => {} _ => {}
} }
} }
let emoji_type = normalize_type(emoji_type.as_deref().unwrap_or("custom"))?; 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() { if emoji_type == "unicode" && unicode_sequence.is_none() {
return Err(HTTPError::BadRequest("unicode_sequence is required".into())); return Err(HTTPError::BadRequest("unicode_sequence is required".into()));
} }
@@ -138,6 +136,7 @@ pub async fn create(
let model = emoji::ActiveModel { let model = emoji::ActiveModel {
id: Set(id), id: Set(id),
server_id: Set(server_id), server_id: Set(server_id),
name: Set(String::new()),
emoji_type: Set(emoji_type), emoji_type: Set(emoji_type),
unicode_sequence: Set(unicode_sequence), unicode_sequence: Set(unicode_sequence),
file_path: Set(path), file_path: Set(path),
@@ -147,12 +146,8 @@ pub async fn create(
sha256: Set(sha), sha256: Set(sha),
..Default::default() ..Default::default()
}; };
let created = state.services.emoji.create(model, aliases).await?; let created = state.services.emoji.create(model, name).await?;
let aliases = state.repositories.emoji.aliases(created.id).await?; Ok((StatusCode::CREATED, Json(mapper::response(created))))
Ok((
StatusCode::CREATED,
Json(mapper::response(created, mapper::aliases(aliases))),
))
} }
#[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[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? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
let target_server_id = payload.server_id.or(existing.server_id); let target_server_id = payload.server_id.or(existing.server_id);
let aliases_to_validate = match &payload.aliases { let target_name = payload.name.as_deref().unwrap_or(&existing.name);
Some(aliases) => aliases.clone(),
None => state
.repositories
.emoji
.aliases(id)
.await?
.into_iter()
.map(|a| a.alias)
.collect(),
};
state state
.services .services
.emoji .emoji
.aliases_available(&aliases_to_validate, target_server_id, Some(id)) .name_available(target_name, target_server_id, Some(id))
.await?; .await?;
let mut active: emoji::ActiveModel = existing.into(); let mut active: emoji::ActiveModel = existing.into();
if let Some(server_id) = payload.server_id { if let Some(server_id) = payload.server_id {
@@ -197,21 +182,11 @@ pub async fn update(
if payload.unicode_sequence.is_some() { if payload.unicode_sequence.is_some() {
active.unicode_sequence = Set(payload.unicode_sequence); active.unicode_sequence = Set(payload.unicode_sequence);
} }
let updated = state.repositories.emoji.update(active).await?; if let Some(name) = payload.name {
if let Some(ref aliases) = payload.aliases { active.name = Set(EmojiService::normalize_name(&name)?);
state
.services
.emoji
.aliases_available(aliases, updated.server_id, Some(id))
.await?;
state
.services
.emoji
.replace_aliases(id, aliases.clone())
.await?;
} }
let aliases = state.repositories.emoji.aliases(id).await?; let updated = state.repositories.emoji.update(active).await?;
Ok(Json(mapper::response(updated, mapper::aliases(aliases)))) Ok(Json(mapper::response(updated)))
} }
fn detect_mime(bytes: &[u8]) -> Option<String> { fn detect_mime(bytes: &[u8]) -> Option<String> {
+2 -6
View File
@@ -1,12 +1,12 @@
use crate::{domain::dto::emoji::EmojiResponse, models::emoji}; use crate::{domain::dto::emoji::EmojiResponse, models::emoji};
pub fn response(model: emoji::Model, aliases: Vec<String>) -> EmojiResponse { pub fn response(model: emoji::Model) -> EmojiResponse {
EmojiResponse { EmojiResponse {
id: model.id, id: model.id,
server_id: model.server_id, server_id: model.server_id,
emoji_type: model.emoji_type, emoji_type: model.emoji_type,
unicode_sequence: model.unicode_sequence, unicode_sequence: model.unicode_sequence,
aliases, name: model.name,
asset_url: model asset_url: model
.file_path .file_path
.as_ref() .as_ref()
@@ -19,7 +19,3 @@ pub fn response(model: emoji::Model, aliases: Vec<String>) -> EmojiResponse {
updated_at: model.updated_at, updated_at: model.updated_at,
} }
} }
pub fn aliases(models: Vec<crate::models::emoji_alias::Model>) -> Vec<String> {
models.into_iter().map(|a| a.alias).collect()
}
+21 -69
View File
@@ -1,7 +1,7 @@
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::models::emoji; use crate::models::emoji;
use crate::services::ServicesContext; use crate::services::ServicesContext;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; use sea_orm::{ActiveModelTrait, Set, TransactionTrait};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -19,32 +19,23 @@ impl EmojiService {
pub fn new(context: Arc<ServicesContext>) -> Self { pub fn new(context: Arc<ServicesContext>) -> Self {
Self { context } Self { context }
} }
pub fn normalize_alias(alias: &str) -> Result<String, HTTPError> { pub fn normalize_name(name: &str) -> Result<String, HTTPError> {
let alias = alias.trim().trim_matches(':').to_lowercase(); let name = name.trim().trim_matches(':').to_lowercase();
if alias.is_empty() if name.is_empty()
|| alias.len() > 64 || name.len() > 64
|| !alias || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || 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, &self,
aliases: &[String], name: &str,
server_id: Option<Uuid>, server_id: Option<Uuid>,
except: Option<Uuid>, except: Option<Uuid>,
) -> Result<(), HTTPError> { ) -> Result<(), HTTPError> {
let normalized = aliases let name = Self::normalize_name(name)?;
.iter()
.map(|a| Self::normalize_alias(a))
.collect::<Result<Vec<_>, _>>()?;
let mut unique = std::collections::HashSet::new();
if normalized.iter().any(|alias| !unique.insert(alias)) {
return Err(HTTPError::BadRequest("Duplicate emoji alias".into()));
}
let scoped = self let scoped = self
.context .context
.repositories .repositories
@@ -55,74 +46,35 @@ impl EmojiService {
if Some(model.id) == except { if Some(model.id) == except {
continue; continue;
} }
for alias in self.context.repositories.emoji.aliases(model.id).await? { if model.name == name {
if normalized.iter().any(|candidate| candidate == &alias.alias) { return Err(HTTPError::BadRequest(
return Err(HTTPError::BadRequest( "Emoji name already exists in this scope".into(),
"Emoji alias already exists in this scope".into(), ));
));
}
} }
} }
Ok(()) Ok(())
} }
pub async fn create( pub async fn create(
&self, &self,
model: emoji::ActiveModel, mut model: emoji::ActiveModel,
aliases: Vec<String>, name: String,
) -> Result<emoji::Model, HTTPError> { ) -> Result<emoji::Model, HTTPError> {
let db = &self.context.repositories.emoji.context.db; let db = &self.context.repositories.emoji.context.db;
let aliases = aliases let name = Self::normalize_name(&name)?;
.into_iter()
.map(|a| Self::normalize_alias(&a))
.collect::<Result<Vec<_>, _>>()?;
let server_id = match &model.server_id { let server_id = match &model.server_id {
sea_orm::ActiveValue::Set(value) => *value, sea_orm::ActiveValue::Set(value) => *value,
_ => None, _ => None,
}; };
self.aliases_available(&aliases, server_id, None).await?; self.name_available(&name, server_id, None).await?;
model.name = Set(name);
let result = db let result = db
.transaction::<_, emoji::Model, anyhow::Error>(|txn| { .transaction::<_, emoji::Model, anyhow::Error>(|txn| {
Box::pin(async move { Box::pin(async move { Ok(model.insert(txn).await?) })
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)
})
}) })
.await .await
.map_err(|e| HTTPError::Internal(anyhow::anyhow!(e)))?; .map_err(|e| HTTPError::Internal(anyhow::anyhow!(e)))?;
Ok(result) Ok(result)
} }
pub async fn replace_aliases(&self, id: Uuid, aliases: Vec<String>) -> Result<(), HTTPError> {
let aliases = aliases
.into_iter()
.map(|a| Self::normalize_alias(&a))
.collect::<Result<Vec<_>, _>>()?;
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 { pub fn hash(data: &[u8]) -> String {
Sha256::digest(data) Sha256::digest(data)
.iter() .iter()