diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a2f30..37edec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Save sizes of media attachments and other files to database. - Added `import-emoji` command. +- Added support for emoji shortcodes. ### Security diff --git a/src/activitypub/builders/create_note.rs b/src/activitypub/builders/create_note.rs index 2a7c166..1b213a4 100644 --- a/src/activitypub/builders/create_note.rs +++ b/src/activitypub/builders/create_note.rs @@ -9,17 +9,21 @@ use crate::activitypub::{ local_actor_id, local_actor_followers, local_actor_subscribers, + local_emoji_id, local_object_id, }, - types::{Attachment, LinkTag, SimpleTag}, - vocabulary::{CREATE, DOCUMENT, HASHTAG, LINK, MENTION, NOTE}, + types::{Attachment, EmojiTag, EmojiTagImage, LinkTag, SimpleTag}, + vocabulary::*, }; use crate::config::Instance; use crate::database::{DatabaseClient, DatabaseError}; -use crate::models::posts::queries::get_post_author; -use crate::models::posts::types::{Post, Visibility}; -use crate::models::relationships::queries::{get_followers, get_subscribers}; -use crate::models::users::types::User; +use crate::models::{ + emojis::types::DbEmoji, + posts::queries::get_post_author, + posts::types::{Post, Visibility}, + relationships::queries::{get_followers, get_subscribers}, + users::types::User, +}; use crate::utils::files::get_file_url; use crate::web_client::urls::get_tag_page_url; @@ -29,6 +33,7 @@ use crate::web_client::urls::get_tag_page_url; enum Tag { SimpleTag(SimpleTag), LinkTag(LinkTag), + EmojiTag(EmojiTag), } #[derive(Serialize)] @@ -64,6 +69,20 @@ pub struct Note { quote_url: Option, } +pub fn build_emoji_tag(instance_url: &str, emoji: &DbEmoji) -> EmojiTag { + EmojiTag { + tag_type: EMOJI.to_string(), + icon: EmojiTagImage { + object_type: IMAGE.to_string(), + url: get_file_url(instance_url, &emoji.image.file_name), + media_type: Some(emoji.image.media_type.clone()), + }, + id: local_emoji_id(instance_url, &emoji.emoji_name), + name: format!(":{}:", emoji.emoji_name), + updated: emoji.updated_at, + } +} + pub fn build_note( instance_hostname: &str, instance_url: &str, @@ -125,6 +144,7 @@ pub fn build_note( }; tags.push(Tag::SimpleTag(tag)); }; + assert_eq!(post.links.len(), post.linked.len()); for linked in &post.linked { // Build FEP-e232 object link @@ -140,9 +160,16 @@ pub fn build_note( }; let maybe_quote_url = post.linked.get(0) .map(|linked| linked.object_id(instance_url)); + + for emoji in &post.emojis { + let tag = build_emoji_tag(instance_url, emoji); + tags.push(Tag::EmojiTag(tag)); + }; + let in_reply_to_object_id = match post.in_reply_to_id { Some(in_reply_to_id) => { - let in_reply_to = post.in_reply_to.as_ref().unwrap(); + let in_reply_to = post.in_reply_to.as_ref() + .expect("in_reply_to should be populated"); assert_eq!(in_reply_to.id, in_reply_to_id); let in_reply_to_actor_id = in_reply_to.author.actor_id(instance_url); if !primary_audience.contains(&in_reply_to_actor_id) { @@ -283,6 +310,17 @@ mod tests { })); } + #[test] + fn test_build_emoji_tag() { + let emoji = DbEmoji { + emoji_name: "test".to_string(), + ..Default::default() + }; + let emoji_tag = build_emoji_tag(INSTANCE_URL, &emoji); + assert_eq!(emoji_tag.id, "https://example.com/objects/emojis/test"); + assert_eq!(emoji_tag.name, ":test:"); + } + #[test] fn test_build_note() { let author = DbActorProfile { diff --git a/src/activitypub/identifiers.rs b/src/activitypub/identifiers.rs index 6728132..38a3007 100644 --- a/src/activitypub/identifiers.rs +++ b/src/activitypub/identifiers.rs @@ -61,6 +61,10 @@ pub fn local_object_id(instance_url: &str, internal_object_id: &Uuid) -> String format!("{}/objects/{}", instance_url, internal_object_id) } +pub fn local_emoji_id(instance_url: &str, emoji_name: &str) -> String { + format!("{}/objects/emojis/{}", instance_url, emoji_name) +} + pub fn parse_local_actor_id( instance_url: &str, actor_id: &str, diff --git a/src/activitypub/types.rs b/src/activitypub/types.rs index 22c68e6..d0786a0 100644 --- a/src/activitypub/types.rs +++ b/src/activitypub/types.rs @@ -54,7 +54,7 @@ pub struct LinkTag { pub name: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmojiTagImage { #[serde(rename = "type")] @@ -63,7 +63,7 @@ pub struct EmojiTagImage { pub media_type: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmojiTag { #[serde(rename = "type")] diff --git a/src/activitypub/views.rs b/src/activitypub/views.rs index 0e65c19..2fe7412 100644 --- a/src/activitypub/views.rs +++ b/src/activitypub/views.rs @@ -12,12 +12,19 @@ use uuid::Uuid; use crate::config::Config; use crate::database::{get_database_client, DbPool}; use crate::errors::HttpError; -use crate::models::posts::helpers::{add_related_posts, can_view_post}; -use crate::models::posts::queries::{get_post_by_id, get_posts_by_author}; -use crate::models::users::queries::get_user_by_name; +use crate::models::{ + emojis::queries::get_local_emoji_by_name, + posts::helpers::{add_related_posts, can_view_post}, + posts::queries::{get_post_by_id, get_posts_by_author}, + users::queries::get_user_by_name, +}; use crate::web_client::urls::{get_post_page_url, get_profile_page_url}; use super::actors::types::{get_local_actor, get_instance_actor}; -use super::builders::create_note::{build_note, build_create_note}; +use super::builders::create_note::{ + build_emoji_tag, + build_note, + build_create_note, +}; use super::collections::{ COLLECTION_PAGE_SIZE, OrderedCollection, @@ -336,6 +343,27 @@ pub async fn object_view( Ok(response) } +#[get("/objects/emojis/{emoji_name}")] +pub async fn emoji_view( + config: web::Data, + db_pool: web::Data, + emoji_name: web::Path, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let emoji = get_local_emoji_by_name( + db_client, + &emoji_name, + ).await?; + let object = build_emoji_tag( + &config.instance().url(), + &emoji, + ); + let response = HttpResponse::Ok() + .content_type(AP_MEDIA_TYPE) + .json(object); + Ok(response) +} + #[cfg(test)] mod tests { use actix_web::http::{ diff --git a/src/main.rs b/src/main.rs index 79eab6c..e574a1e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -158,6 +158,7 @@ async fn main() -> std::io::Result<()> { .service(activitypub::actor_scope()) .service(activitypub::instance_actor_scope()) .service(activitypub::object_view) + .service(activitypub::emoji_view) .service(atom::get_atom_feed) .service(nodeinfo::get_nodeinfo) .service(nodeinfo::get_nodeinfo_2_0) diff --git a/src/mastodon_api/statuses/helpers.rs b/src/mastodon_api/statuses/helpers.rs index 9e7a21d..5f2388b 100644 --- a/src/mastodon_api/statuses/helpers.rs +++ b/src/mastodon_api/statuses/helpers.rs @@ -4,6 +4,7 @@ use crate::config::Instance; use crate::database::{DatabaseClient, DatabaseError}; use crate::models::{ posts::{ + emojis::find_emojis, hashtags::{find_hashtags, replace_hashtags}, helpers::{add_related_posts, add_user_actions}, links::{replace_object_links, find_linked_posts}, @@ -20,6 +21,7 @@ pub struct PostContent { pub tags: Vec, pub links: Vec, pub linked: Vec, + pub emojis: Vec, } pub async fn parse_microsyntaxes( @@ -59,7 +61,13 @@ pub async fn parse_microsyntaxes( ); let links = link_map.values().map(|post| post.id).collect(); let linked = link_map.into_values().collect(); - Ok(PostContent { content, mentions, tags, links, linked }) + // Emojis + let emoji_map = find_emojis( + db_client, + &content, + ).await?; + let emojis = emoji_map.values().map(|emoji| emoji.id).collect(); + Ok(PostContent { content, mentions, tags, links, linked, emojis }) } /// Load related objects and build status for API response diff --git a/src/mastodon_api/statuses/views.rs b/src/mastodon_api/statuses/views.rs index 7008a2c..154a7c7 100644 --- a/src/mastodon_api/statuses/views.rs +++ b/src/mastodon_api/statuses/views.rs @@ -31,7 +31,11 @@ use crate::models::posts::queries::{ delete_post, }; use crate::models::posts::types::{PostCreateData, Visibility}; -use crate::models::posts::validators::{clean_content, ATTACHMENTS_MAX_NUM}; +use crate::models::posts::validators::{ + clean_content, + ATTACHMENTS_MAX_NUM, + EMOJIS_MAX_NUM, +}; use crate::models::reactions::queries::{ create_reaction, delete_reaction, @@ -83,7 +87,7 @@ async fn create_status( _ => return Err(ValidationError("unsupported content type").into()), }; // Parse content - let PostContent { mut content, mut mentions, tags, links, linked } = + let PostContent { mut content, mut mentions, tags, links, linked, emojis } = parse_microsyntaxes( db_client, &instance, @@ -111,6 +115,12 @@ async fn create_status( if links.len() > 0 && visibility != Visibility::Public { return Err(ValidationError("can't add links to non-public posts").into()); }; + + // Emoji validation + if emojis.len() > EMOJIS_MAX_NUM { + return Err(ValidationError("too many emojis").into()); + }; + // Reply validation let maybe_in_reply_to = if let Some(in_reply_to_id) = status_data.in_reply_to_id.as_ref() { let in_reply_to = match get_post_by_id(db_client, in_reply_to_id).await { @@ -155,7 +165,7 @@ async fn create_status( mentions: mentions, tags: tags, links: links, - emojis: vec![], + emojis: emojis, object_id: None, created_at: Utc::now(), }; diff --git a/src/models/emojis/queries.rs b/src/models/emojis/queries.rs index d539b2c..324a7e9 100644 --- a/src/models/emojis/queries.rs +++ b/src/models/emojis/queries.rs @@ -76,6 +76,41 @@ pub async fn update_emoji( Ok(emoji) } +pub async fn get_local_emoji_by_name( + db_client: &impl DatabaseClient, + emoji_name: &str, +) -> Result { + let maybe_row = db_client.query_opt( + " + SELECT emoji + FROM emoji + WHERE hostname IS NULL AND emoji_name = $1 + ", + &[&emoji_name], + ).await?; + let row = maybe_row.ok_or(DatabaseError::NotFound("emoji"))?; + let emoji = row.try_get("emoji")?; + Ok(emoji) +} + +pub async fn get_local_emojis_by_names( + db_client: &impl DatabaseClient, + names: &[String], +) -> Result, DatabaseError> { + let rows = db_client.query( + " + SELECT emoji + FROM emoji + WHERE hostname IS NULL AND emoji_name = ANY($1) + ", + &[&names], + ).await?; + let emojis = rows.iter() + .map(|row| row.try_get("emoji")) + .collect::>()?; + Ok(emojis) +} + pub async fn get_emoji_by_name_and_hostname( db_client: &impl DatabaseClient, emoji_name: &str, @@ -172,11 +207,7 @@ mod tests { #[serial] async fn test_delete_emoji() { let db_client = &create_test_database().await; - let image = EmojiImage { - file_name: "test.png".to_string(), - file_size: 10000, - media_type: "image/png".to_string(), - }; + let image = EmojiImage::default(); let emoji = create_emoji( db_client, "test", diff --git a/src/models/emojis/types.rs b/src/models/emojis/types.rs index bcf57f3..aa9cbfb 100644 --- a/src/models/emojis/types.rs +++ b/src/models/emojis/types.rs @@ -9,6 +9,7 @@ use super::validators::EMOJI_MAX_SIZE; fn default_emoji_file_size() -> usize { EMOJI_MAX_SIZE } #[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(Default))] pub struct EmojiImage { pub file_name: String, #[serde(default = "default_emoji_file_size")] @@ -20,6 +21,7 @@ json_from_sql!(EmojiImage); json_to_sql!(EmojiImage); #[derive(Clone, FromSql)] +#[cfg_attr(test, derive(Default))] #[postgres(name = "emoji")] pub struct DbEmoji { pub id: Uuid, diff --git a/src/models/posts/emojis.rs b/src/models/posts/emojis.rs new file mode 100644 index 0000000..a6aaa21 --- /dev/null +++ b/src/models/posts/emojis.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; + +use regex::Regex; + +use crate::database::{DatabaseClient, DatabaseError}; +use crate::models::emojis::queries::get_local_emojis_by_names; +use crate::models::emojis::types::DbEmoji; +use super::links::is_inside_code_block; + +// See also: EMOJI_NAME_RE in models::emojis::validators +const SHORTCODE_SEARCH_RE: &str = r"(?m):(?P[\w.]+):"; + +/// Finds emoji shortcodes in text +fn find_shortcodes(text: &str) -> Vec { + let shortcode_re = Regex::new(SHORTCODE_SEARCH_RE) + .expect("regex should be valid"); + let mut emoji_names = vec![]; + for caps in shortcode_re.captures_iter(text) { + let name_match = caps.name("name").expect("should have name group"); + if is_inside_code_block(&name_match, text) { + // Ignore shortcodes inside code blocks + continue; + }; + let name = caps["name"].to_string(); + if !emoji_names.contains(&name) { + emoji_names.push(name); + }; + }; + emoji_names +} + +pub async fn find_emojis( + db_client: &impl DatabaseClient, + text: &str, +) -> Result, DatabaseError> { + let emoji_names = find_shortcodes(text); + // If shortcode doesn't exist in database, it is ignored + let emojis = get_local_emojis_by_names(db_client, &emoji_names).await?; + let mut emoji_map: HashMap = HashMap::new(); + for emoji in emojis { + emoji_map.insert(emoji.emoji_name.clone(), emoji); + }; + Ok(emoji_map) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEXT_WITH_EMOJIS: &str = "@user1@server1 text :emoji_name: :abc:"; + + #[test] + fn test_find_shortcodes() { + let emoji_names = find_shortcodes(TEXT_WITH_EMOJIS); + + assert_eq!(emoji_names, vec![ + "emoji_name", + "abc", + ]); + } +} diff --git a/src/models/posts/mod.rs b/src/models/posts/mod.rs index 8507fd7..19cfe13 100644 --- a/src/models/posts/mod.rs +++ b/src/models/posts/mod.rs @@ -1,3 +1,4 @@ +pub mod emojis; pub mod hashtags; pub mod helpers; pub mod links;