diff --git a/migrations/V0039__emoji.sql b/migrations/V0039__emoji.sql new file mode 100644 index 0000000..14be98d --- /dev/null +++ b/migrations/V0039__emoji.sql @@ -0,0 +1,16 @@ +CREATE TABLE emoji ( + id UUID PRIMARY KEY, + emoji_name VARCHAR(100) NOT NULL, + hostname VARCHAR(100) REFERENCES instance (hostname) ON DELETE RESTRICT, + image JSONB NOT NULL, + object_id VARCHAR(250) UNIQUE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE (emoji_name, hostname), + CHECK ((hostname IS NULL) = (object_id IS NULL)) +); + +CREATE TABLE post_emoji ( + post_id UUID NOT NULL REFERENCES post (id) ON DELETE CASCADE, + emoji_id UUID NOT NULL REFERENCES emoji (id) ON DELETE CASCADE, + PRIMARY KEY (post_id, emoji_id) +); diff --git a/migrations/schema.sql b/migrations/schema.sql index 7dc7d9b..930b9f5 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -136,6 +136,23 @@ CREATE TABLE post_link ( PRIMARY KEY (source_id, target_id) ); +CREATE TABLE emoji ( + id UUID PRIMARY KEY, + emoji_name VARCHAR(100) NOT NULL, + hostname VARCHAR(100) REFERENCES instance (hostname) ON DELETE RESTRICT, + image JSONB NOT NULL, + object_id VARCHAR(250) UNIQUE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE (emoji_name, hostname), + CHECK ((hostname IS NULL) = (object_id IS NULL)) +); + +CREATE TABLE post_emoji ( + post_id UUID NOT NULL REFERENCES post (id) ON DELETE CASCADE, + emoji_id UUID NOT NULL REFERENCES emoji (id) ON DELETE CASCADE, + PRIMARY KEY (post_id, emoji_id) +); + CREATE TABLE notification ( id SERIAL PRIMARY KEY, sender_id UUID NOT NULL REFERENCES actor_profile (id) ON DELETE CASCADE, diff --git a/src/activitypub/handlers/create.rs b/src/activitypub/handlers/create.rs index f1c75e4..29049e5 100644 --- a/src/activitypub/handlers/create.rs +++ b/src/activitypub/handlers/create.rs @@ -23,6 +23,11 @@ use crate::config::{Config, Instance}; use crate::database::DatabaseError; use crate::errors::{ConversionError, ValidationError}; use crate::models::attachments::queries::create_attachment; +use crate::models::emojis::queries::{ + create_emoji, + get_emoji_by_remote_object_id, + update_emoji, +}; use crate::models::posts::{ hashtags::normalize_hashtag, helpers::get_post_by_object_id, @@ -33,11 +38,17 @@ use crate::models::posts::{ content_allowed_classes, ATTACHMENTS_MAX_NUM, CONTENT_MAX_SIZE, + EMOJI_MAX_SIZE, + EMOJI_MEDIA_TYPE, + EMOJIS_MAX_NUM, }, }; use crate::models::profiles::types::DbActorProfile; use crate::models::users::queries::get_user_by_name; -use crate::utils::html::clean_html; +use crate::utils::{ + html::clean_html, + urls::get_hostname, +}; use super::HandlerResult; fn get_note_author_id(object: &Object) -> Result { @@ -221,6 +232,7 @@ pub async fn handle_note( let mut mentions: Vec = Vec::new(); let mut hashtags = vec![]; let mut links = vec![]; + let mut emojis = vec![]; if let Some(value) = object.tag { let list: Vec = parse_property_value(&value) .map_err(|_| ValidationError("invalid tag property"))?; @@ -343,13 +355,84 @@ pub async fn handle_note( links.push(linked.id); }; } else if tag_type == EMOJI { - let _tag: EmojiTag = match serde_json::from_value(tag_value.clone()) { + let tag: EmojiTag = match serde_json::from_value(tag_value) { Ok(tag) => tag, Err(_) => { log::warn!("invalid emoji tag"); continue; }, }; + if emojis.len() >= EMOJIS_MAX_NUM { + log::warn!("too many emojis"); + continue; + }; + let tag_name = tag.name.trim_matches(':'); + let maybe_emoji_id = match get_emoji_by_remote_object_id( + db_client, + &tag.id, + ).await { + Ok(emoji) => { + if emoji.updated_at >= tag.updated { + // Emoji already exists and is up to date + if !emojis.contains(&emoji.id) { + emojis.push(emoji.id); + }; + continue; + }; + if emoji.emoji_name != tag_name { + log::warn!("emoji name can't be changed"); + continue; + }; + Some(emoji.id) + }, + Err(DatabaseError::NotFound("emoji")) => None, + Err(other_error) => return Err(other_error.into()), + }; + let (file_name, maybe_media_type) = match fetch_file( + instance, + &tag.icon.url, + tag.icon.media_type.as_deref(), + EMOJI_MAX_SIZE, + media_dir, + ).await { + Ok(file) => file, + Err(error) => { + log::warn!("failed to fetch emoji: {}", error); + continue; + }, + }; + let media_type = match maybe_media_type.as_deref() { + Some(media_type @ EMOJI_MEDIA_TYPE) => media_type, + _ => { + log::warn!("unexpected emoji media type: {:?}", maybe_media_type); + continue; + }, + }; + log::info!("downloaded emoji {}", tag.icon.url); + let emoji = if let Some(emoji_id) = maybe_emoji_id { + update_emoji( + db_client, + &emoji_id, + &file_name, + media_type, + &tag.updated, + ).await? + } else { + let hostname = get_hostname(&tag.id) + .map_err(|_| ValidationError("invalid emoji ID"))?; + create_emoji( + db_client, + tag_name, + Some(&hostname), + &file_name, + media_type, + Some(&tag.id), + &tag.updated, + ).await? + }; + if !emojis.contains(&emoji.id) { + emojis.push(emoji.id); + }; } else { log::warn!( "skipping tag of type {}", @@ -417,6 +500,7 @@ pub async fn handle_note( mentions: mentions, tags: hashtags, links: links, + emojis: emojis, object_id: Some(object.id), created_at, }; diff --git a/src/activitypub/types.rs b/src/activitypub/types.rs index 16f1ed5..63d3d42 100644 --- a/src/activitypub/types.rs +++ b/src/activitypub/types.rs @@ -57,11 +57,11 @@ pub struct LinkTag { #[allow(dead_code)] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct EmojiImage { +pub struct EmojiImage { #[serde(rename = "type")] object_type: String, - url: String, - media_type: Option, + pub url: String, + pub media_type: Option, } #[allow(dead_code)] @@ -70,10 +70,10 @@ struct EmojiImage { pub struct EmojiTag { #[serde(rename = "type")] tag_type: String, - icon: EmojiImage, - id: String, - name: String, - updated: DateTime, + pub icon: EmojiImage, + pub id: String, + pub name: String, + pub updated: DateTime, } #[derive(Deserialize)] diff --git a/src/mastodon_api/statuses/views.rs b/src/mastodon_api/statuses/views.rs index 156306b..7008a2c 100644 --- a/src/mastodon_api/statuses/views.rs +++ b/src/mastodon_api/statuses/views.rs @@ -155,6 +155,7 @@ async fn create_status( mentions: mentions, tags: tags, links: links, + emojis: vec![], object_id: None, created_at: Utc::now(), }; diff --git a/src/models/emojis/mod.rs b/src/models/emojis/mod.rs new file mode 100644 index 0000000..0333ab5 --- /dev/null +++ b/src/models/emojis/mod.rs @@ -0,0 +1,2 @@ +pub mod queries; +pub mod types; diff --git a/src/models/emojis/queries.rs b/src/models/emojis/queries.rs new file mode 100644 index 0000000..a4aff0c --- /dev/null +++ b/src/models/emojis/queries.rs @@ -0,0 +1,135 @@ +use chrono::{DateTime, Utc}; +use tokio_postgres::GenericClient; +use uuid::Uuid; + +use crate::database::{catch_unique_violation, DatabaseError}; +use crate::models::{ + instances::queries::create_instance, + profiles::types::ProfileImage, +}; +use crate::utils::id::new_uuid; +use super::types::DbEmoji; + +pub async fn create_emoji( + db_client: &impl GenericClient, + emoji_name: &str, + hostname: Option<&str>, + file_name: &str, + media_type: &str, + object_id: Option<&str>, + updated_at: &DateTime, +) -> Result { + let emoji_id = new_uuid(); + if let Some(hostname) = hostname { + create_instance(db_client, hostname).await?; + }; + let image = ProfileImage { + file_name: file_name.to_string(), + media_type: Some(media_type.to_string()), + }; + let row = db_client.query_one( + " + INSERT INTO emoji ( + id, + emoji_name, + hostname, + image, + object_id, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING emoji + ", + &[ + &emoji_id, + &emoji_name, + &hostname, + &image, + &object_id, + &updated_at, + ], + ).await.map_err(catch_unique_violation("emoji"))?; + let emoji = row.try_get("emoji")?; + Ok(emoji) +} + +pub async fn update_emoji( + db_client: &impl GenericClient, + emoji_id: &Uuid, + file_name: &str, + media_type: &str, + updated_at: &DateTime, +) -> Result { + let image = ProfileImage { + file_name: file_name.to_string(), + media_type: Some(media_type.to_string()), + }; + let row = db_client.query_one( + " + UPDATE emoji + SET + image = $1, + updated_at = $2 + WHERE id = $4 + RETURNING emoji + ", + &[ + &image, + &updated_at, + &emoji_id, + ], + ).await?; + let emoji = row.try_get("emoji")?; + Ok(emoji) +} + +pub async fn get_emoji_by_remote_object_id( + db_client: &impl GenericClient, + object_id: &str, +) -> Result { + let maybe_row = db_client.query_opt( + " + SELECT emoji + FROM emoji WHERE object_id = $1 + ", + &[&object_id], + ).await?; + let row = maybe_row.ok_or(DatabaseError::NotFound("emoji"))?; + let emoji = row.try_get("emoji")?; + Ok(emoji) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use crate::database::test_utils::create_test_database; + use super::*; + + #[tokio::test] + #[serial] + async fn test_create_emoji() { + let db_client = &create_test_database().await; + let emoji_name = "test"; + let hostname = "example.org"; + let file_name = "test.png"; + let media_type = "image/png"; + let object_id = "https://example.org/emojis/test"; + let updated_at = Utc::now(); + let DbEmoji { id: emoji_id, .. } = create_emoji( + db_client, + emoji_name, + Some(hostname), + file_name, + media_type, + Some(object_id), + &updated_at, + ).await.unwrap(); + let emoji = get_emoji_by_remote_object_id( + db_client, + object_id, + ).await.unwrap(); + assert_eq!(emoji.id, emoji_id); + assert_eq!(emoji.emoji_name, emoji_name); + assert_eq!(emoji.hostname, Some(hostname.to_string())); + } +} diff --git a/src/models/emojis/types.rs b/src/models/emojis/types.rs new file mode 100644 index 0000000..b73b131 --- /dev/null +++ b/src/models/emojis/types.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use postgres_types::FromSql; +use uuid::Uuid; + +use crate::models::profiles::types::ProfileImage; + +#[derive(Clone, FromSql)] +#[postgres(name = "emoji")] +pub struct DbEmoji { + pub id: Uuid, + pub emoji_name: String, + pub hostname: Option, + pub image: ProfileImage, + pub object_id: Option, + pub updated_at: DateTime, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index f626378..46196c8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod attachments; pub mod background_jobs; pub mod cleanup; +pub mod emojis; pub mod instances; pub mod invoices; pub mod markers; diff --git a/src/models/notifications/queries.rs b/src/models/notifications/queries.rs index 3cbbe99..860ee15 100644 --- a/src/models/notifications/queries.rs +++ b/src/models/notifications/queries.rs @@ -5,6 +5,7 @@ use crate::database::DatabaseError; use crate::models::posts::helpers::{add_related_posts, add_user_actions}; use crate::models::posts::queries::{ RELATED_ATTACHMENTS, + RELATED_EMOJIS, RELATED_LINKS, RELATED_MENTIONS, RELATED_TAGS, @@ -138,7 +139,8 @@ pub async fn get_notifications( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM notification JOIN actor_profile AS sender ON notification.sender_id = sender.id @@ -156,6 +158,7 @@ pub async fn get_notifications( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, ); let rows = db_client.query( &statement, diff --git a/src/models/notifications/types.rs b/src/models/notifications/types.rs index 54580d7..708613a 100644 --- a/src/models/notifications/types.rs +++ b/src/models/notifications/types.rs @@ -8,9 +8,12 @@ use crate::database::{ DatabaseError, DatabaseTypeError, }; -use crate::models::attachments::types::DbMediaAttachment; -use crate::models::posts::types::{DbPost, Post}; -use crate::models::profiles::types::DbActorProfile; +use crate::models::{ + attachments::types::DbMediaAttachment, + emojis::types::DbEmoji, + posts::types::{DbPost, Post}, + profiles::types::DbActorProfile, +}; #[derive(Debug)] pub enum EventType { @@ -102,6 +105,7 @@ impl TryFrom<&Row> for Notification { let db_mentions: Vec = row.try_get("mentions")?; let db_tags: Vec = row.try_get("tags")?; let db_links: Vec = row.try_get("links")?; + let db_emojis: Vec = row.try_get("emojis")?; let post = Post::new( db_post, db_post_author, @@ -109,6 +113,7 @@ impl TryFrom<&Row> for Notification { db_mentions, db_tags, db_links, + db_emojis, )?; Some(post) }, diff --git a/src/models/posts/queries.rs b/src/models/posts/queries.rs index 2e377fa..77981a5 100644 --- a/src/models/posts/queries.rs +++ b/src/models/posts/queries.rs @@ -14,6 +14,7 @@ use crate::models::cleanup::{ find_orphaned_ipfs_objects, DeletionQueue, }; +use crate::models::emojis::types::DbEmoji; use crate::models::notifications::queries::{ create_mention_notification, create_reply_notification, @@ -157,6 +158,24 @@ pub async fn create_post( let db_links: Vec = links_rows.iter() .map(|row| row.try_get("target_id")) .collect::>()?; + // Create emojis + let emojis_rows = transaction.query( + " + INSERT INTO post_emoji (post_id, emoji_id) + SELECT $1, emoji.id FROM emoji WHERE id = ANY($2) + RETURNING ( + SELECT emoji FROM emoji + WHERE emoji.id = emoji_id + ) + ", + &[&db_post.id, &data.emojis], + ).await?; + if emojis_rows.len() != data.emojis.len() { + return Err(DatabaseError::NotFound("emoji")); + }; + let db_emojis: Vec = emojis_rows.iter() + .map(|row| row.try_get("emoji")) + .collect::>()?; // Update counters let author = update_post_count(&transaction, &db_post.author_id, 1).await?; let mut notified_users = vec![]; @@ -216,6 +235,7 @@ pub async fn create_post( db_mentions, db_tags, db_links, + db_emojis, )?; Ok(post) } @@ -247,6 +267,14 @@ pub const RELATED_LINKS: &str = WHERE post_link.source_id = post.id ) AS links"; +pub const RELATED_EMOJIS: &str = + "ARRAY( + SELECT emoji + FROM post_emoji + JOIN emoji ON post_emoji.emoji_id = emoji.id + WHERE post_emoji.post_id = post.id + ) AS emojis"; + fn build_visibility_filter() -> String { format!( "( @@ -287,7 +315,8 @@ pub async fn get_home_timeline( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE @@ -353,6 +382,7 @@ pub async fn get_home_timeline( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, relationship_follow=i16::from(&RelationshipType::Follow), relationship_subscription=i16::from(&RelationshipType::Subscription), relationship_hide_reposts=i16::from(&RelationshipType::HideReposts), @@ -386,7 +416,8 @@ pub async fn get_local_timeline( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE @@ -400,6 +431,7 @@ pub async fn get_local_timeline( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, visibility_public=i16::from(&Visibility::Public), ); let limit: i64 = limit.into(); @@ -427,7 +459,8 @@ pub async fn get_related_posts( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE post.id IN ( @@ -449,6 +482,7 @@ pub async fn get_related_posts( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, ); let rows = db_client.query( &statement, @@ -488,7 +522,8 @@ pub async fn get_posts_by_author( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE {condition} @@ -499,6 +534,7 @@ pub async fn get_posts_by_author( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, condition=condition, ); let limit: i64 = limit.into(); @@ -531,7 +567,8 @@ pub async fn get_posts_by_tag( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE @@ -548,6 +585,7 @@ pub async fn get_posts_by_tag( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, visibility_filter=build_visibility_filter(), ); let limit: i64 = limit.into(); @@ -576,7 +614,8 @@ pub async fn get_post_by_id( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE post.id = $1 @@ -585,6 +624,7 @@ pub async fn get_post_by_id( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, ); let maybe_row = db_client.query_opt( &statement, @@ -629,7 +669,8 @@ pub async fn get_thread( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN thread ON post.id = thread.id JOIN actor_profile ON post.author_id = actor_profile.id @@ -640,6 +681,7 @@ pub async fn get_thread( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, visibility_filter=build_visibility_filter(), ); let query = query!( @@ -668,7 +710,8 @@ pub async fn get_post_by_remote_object_id( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE post.object_id = $1 @@ -677,6 +720,7 @@ pub async fn get_post_by_remote_object_id( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, ); let maybe_row = db_client.query_opt( &statement, @@ -698,7 +742,8 @@ pub async fn get_post_by_ipfs_cid( {related_attachments}, {related_mentions}, {related_tags}, - {related_links} + {related_links}, + {related_emojis} FROM post JOIN actor_profile ON post.author_id = actor_profile.id WHERE post.ipfs_cid = $1 @@ -707,6 +752,7 @@ pub async fn get_post_by_ipfs_cid( related_mentions=RELATED_MENTIONS, related_tags=RELATED_TAGS, related_links=RELATED_LINKS, + related_emojis=RELATED_EMOJIS, ); let result = db_client.query_opt( &statement, diff --git a/src/models/posts/types.rs b/src/models/posts/types.rs index ad8eedd..bd36281 100644 --- a/src/models/posts/types.rs +++ b/src/models/posts/types.rs @@ -9,8 +9,11 @@ use crate::database::{ DatabaseError, DatabaseTypeError, }; -use crate::models::attachments::types::DbMediaAttachment; -use crate::models::profiles::types::DbActorProfile; +use crate::models::{ + attachments::types::DbMediaAttachment, + emojis::types::DbEmoji, + profiles::types::DbActorProfile, +}; #[derive(Clone, Debug, PartialEq)] pub enum Visibility { @@ -95,6 +98,7 @@ pub struct Post { pub mentions: Vec, pub tags: Vec, pub links: Vec, + pub emojis: Vec, pub object_id: Option, pub ipfs_cid: Option, pub token_id: Option, @@ -118,6 +122,7 @@ impl Post { db_mentions: Vec, db_tags: Vec, db_links: Vec, + db_emojis: Vec, ) -> Result { // Consistency checks if db_post.author_id != db_author.id { @@ -135,7 +140,8 @@ impl Post { !db_attachments.is_empty() || !db_mentions.is_empty() || !db_tags.is_empty() || - !db_links.is_empty() + !db_links.is_empty() || + !db_emojis.is_empty() ) { return Err(DatabaseTypeError); }; @@ -153,6 +159,7 @@ impl Post { mentions: db_mentions, tags: db_tags, links: db_links, + emojis: db_emojis, object_id: db_post.object_id, ipfs_cid: db_post.ipfs_cid, token_id: db_post.token_id, @@ -200,6 +207,7 @@ impl Default for Post { mentions: vec![], tags: vec![], links: vec![], + emojis: vec![], object_id: None, ipfs_cid: None, token_id: None, @@ -225,6 +233,7 @@ impl TryFrom<&Row> for Post { let db_mentions: Vec = row.try_get("mentions")?; let db_tags: Vec = row.try_get("tags")?; let db_links: Vec = row.try_get("links")?; + let db_emojis: Vec = row.try_get("emojis")?; let post = Self::new( db_post, db_profile, @@ -232,6 +241,7 @@ impl TryFrom<&Row> for Post { db_mentions, db_tags, db_links, + db_emojis, )?; Ok(post) } @@ -247,6 +257,7 @@ pub struct PostCreateData { pub mentions: Vec, pub tags: Vec, pub links: Vec, + pub emojis: Vec, pub object_id: Option, pub created_at: DateTime, } diff --git a/src/models/posts/validators.rs b/src/models/posts/validators.rs index a7c0df1..ca44da0 100644 --- a/src/models/posts/validators.rs +++ b/src/models/posts/validators.rs @@ -2,6 +2,10 @@ use crate::errors::ValidationError; use crate::utils::html::clean_html_strict; pub const ATTACHMENTS_MAX_NUM: usize = 15; +pub const EMOJI_MAX_SIZE: u64 = 100 * 1000; // 100 kB +pub const EMOJI_MEDIA_TYPE: &str = "image/png"; +pub const EMOJIS_MAX_NUM: usize = 20; + pub const CONTENT_MAX_SIZE: usize = 100000; const CONTENT_ALLOWED_TAGS: [&str; 8] = [ "a",