Add support for emoji shortcodes

This commit is contained in:
silverpill 2023-01-21 00:23:15 +00:00
parent e8500b982b
commit 75579eae4f
12 changed files with 207 additions and 22 deletions

View file

@ -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. - Save sizes of media attachments and other files to database.
- Added `import-emoji` command. - Added `import-emoji` command.
- Added support for emoji shortcodes.
### Security ### Security

View file

@ -9,17 +9,21 @@ use crate::activitypub::{
local_actor_id, local_actor_id,
local_actor_followers, local_actor_followers,
local_actor_subscribers, local_actor_subscribers,
local_emoji_id,
local_object_id, local_object_id,
}, },
types::{Attachment, LinkTag, SimpleTag}, types::{Attachment, EmojiTag, EmojiTagImage, LinkTag, SimpleTag},
vocabulary::{CREATE, DOCUMENT, HASHTAG, LINK, MENTION, NOTE}, vocabulary::*,
}; };
use crate::config::Instance; use crate::config::Instance;
use crate::database::{DatabaseClient, DatabaseError}; use crate::database::{DatabaseClient, DatabaseError};
use crate::models::posts::queries::get_post_author; use crate::models::{
use crate::models::posts::types::{Post, Visibility}; emojis::types::DbEmoji,
use crate::models::relationships::queries::{get_followers, get_subscribers}; posts::queries::get_post_author,
use crate::models::users::types::User; posts::types::{Post, Visibility},
relationships::queries::{get_followers, get_subscribers},
users::types::User,
};
use crate::utils::files::get_file_url; use crate::utils::files::get_file_url;
use crate::web_client::urls::get_tag_page_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 { enum Tag {
SimpleTag(SimpleTag), SimpleTag(SimpleTag),
LinkTag(LinkTag), LinkTag(LinkTag),
EmojiTag(EmojiTag),
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -64,6 +69,20 @@ pub struct Note {
quote_url: Option<String>, quote_url: Option<String>,
} }
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( pub fn build_note(
instance_hostname: &str, instance_hostname: &str,
instance_url: &str, instance_url: &str,
@ -125,6 +144,7 @@ pub fn build_note(
}; };
tags.push(Tag::SimpleTag(tag)); tags.push(Tag::SimpleTag(tag));
}; };
assert_eq!(post.links.len(), post.linked.len()); assert_eq!(post.links.len(), post.linked.len());
for linked in &post.linked { for linked in &post.linked {
// Build FEP-e232 object link // Build FEP-e232 object link
@ -140,9 +160,16 @@ pub fn build_note(
}; };
let maybe_quote_url = post.linked.get(0) let maybe_quote_url = post.linked.get(0)
.map(|linked| linked.object_id(instance_url)); .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 { let in_reply_to_object_id = match post.in_reply_to_id {
Some(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); assert_eq!(in_reply_to.id, in_reply_to_id);
let in_reply_to_actor_id = in_reply_to.author.actor_id(instance_url); let in_reply_to_actor_id = in_reply_to.author.actor_id(instance_url);
if !primary_audience.contains(&in_reply_to_actor_id) { 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] #[test]
fn test_build_note() { fn test_build_note() {
let author = DbActorProfile { let author = DbActorProfile {

View file

@ -61,6 +61,10 @@ pub fn local_object_id(instance_url: &str, internal_object_id: &Uuid) -> String
format!("{}/objects/{}", instance_url, internal_object_id) 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( pub fn parse_local_actor_id(
instance_url: &str, instance_url: &str,
actor_id: &str, actor_id: &str,

View file

@ -54,7 +54,7 @@ pub struct LinkTag {
pub name: Option<String>, pub name: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EmojiTagImage { pub struct EmojiTagImage {
#[serde(rename = "type")] #[serde(rename = "type")]
@ -63,7 +63,7 @@ pub struct EmojiTagImage {
pub media_type: Option<String>, pub media_type: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EmojiTag { pub struct EmojiTag {
#[serde(rename = "type")] #[serde(rename = "type")]

View file

@ -12,12 +12,19 @@ use uuid::Uuid;
use crate::config::Config; use crate::config::Config;
use crate::database::{get_database_client, DbPool}; use crate::database::{get_database_client, DbPool};
use crate::errors::HttpError; use crate::errors::HttpError;
use crate::models::posts::helpers::{add_related_posts, can_view_post}; use crate::models::{
use crate::models::posts::queries::{get_post_by_id, get_posts_by_author}; emojis::queries::get_local_emoji_by_name,
use crate::models::users::queries::get_user_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 crate::web_client::urls::{get_post_page_url, get_profile_page_url};
use super::actors::types::{get_local_actor, get_instance_actor}; 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::{ use super::collections::{
COLLECTION_PAGE_SIZE, COLLECTION_PAGE_SIZE,
OrderedCollection, OrderedCollection,
@ -336,6 +343,27 @@ pub async fn object_view(
Ok(response) Ok(response)
} }
#[get("/objects/emojis/{emoji_name}")]
pub async fn emoji_view(
config: web::Data<Config>,
db_pool: web::Data<DbPool>,
emoji_name: web::Path<String>,
) -> Result<HttpResponse, HttpError> {
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)] #[cfg(test)]
mod tests { mod tests {
use actix_web::http::{ use actix_web::http::{

View file

@ -158,6 +158,7 @@ async fn main() -> std::io::Result<()> {
.service(activitypub::actor_scope()) .service(activitypub::actor_scope())
.service(activitypub::instance_actor_scope()) .service(activitypub::instance_actor_scope())
.service(activitypub::object_view) .service(activitypub::object_view)
.service(activitypub::emoji_view)
.service(atom::get_atom_feed) .service(atom::get_atom_feed)
.service(nodeinfo::get_nodeinfo) .service(nodeinfo::get_nodeinfo)
.service(nodeinfo::get_nodeinfo_2_0) .service(nodeinfo::get_nodeinfo_2_0)

View file

@ -4,6 +4,7 @@ use crate::config::Instance;
use crate::database::{DatabaseClient, DatabaseError}; use crate::database::{DatabaseClient, DatabaseError};
use crate::models::{ use crate::models::{
posts::{ posts::{
emojis::find_emojis,
hashtags::{find_hashtags, replace_hashtags}, hashtags::{find_hashtags, replace_hashtags},
helpers::{add_related_posts, add_user_actions}, helpers::{add_related_posts, add_user_actions},
links::{replace_object_links, find_linked_posts}, links::{replace_object_links, find_linked_posts},
@ -20,6 +21,7 @@ pub struct PostContent {
pub tags: Vec<String>, pub tags: Vec<String>,
pub links: Vec<Uuid>, pub links: Vec<Uuid>,
pub linked: Vec<Post>, pub linked: Vec<Post>,
pub emojis: Vec<Uuid>,
} }
pub async fn parse_microsyntaxes( 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 links = link_map.values().map(|post| post.id).collect();
let linked = link_map.into_values().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 /// Load related objects and build status for API response

View file

@ -31,7 +31,11 @@ use crate::models::posts::queries::{
delete_post, delete_post,
}; };
use crate::models::posts::types::{PostCreateData, Visibility}; 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::{ use crate::models::reactions::queries::{
create_reaction, create_reaction,
delete_reaction, delete_reaction,
@ -83,7 +87,7 @@ async fn create_status(
_ => return Err(ValidationError("unsupported content type").into()), _ => return Err(ValidationError("unsupported content type").into()),
}; };
// Parse content // Parse content
let PostContent { mut content, mut mentions, tags, links, linked } = let PostContent { mut content, mut mentions, tags, links, linked, emojis } =
parse_microsyntaxes( parse_microsyntaxes(
db_client, db_client,
&instance, &instance,
@ -111,6 +115,12 @@ async fn create_status(
if links.len() > 0 && visibility != Visibility::Public { if links.len() > 0 && visibility != Visibility::Public {
return Err(ValidationError("can't add links to non-public posts").into()); 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 // Reply validation
let maybe_in_reply_to = if let Some(in_reply_to_id) = status_data.in_reply_to_id.as_ref() { 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 { 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, mentions: mentions,
tags: tags, tags: tags,
links: links, links: links,
emojis: vec![], emojis: emojis,
object_id: None, object_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}; };

View file

@ -76,6 +76,41 @@ pub async fn update_emoji(
Ok(emoji) Ok(emoji)
} }
pub async fn get_local_emoji_by_name(
db_client: &impl DatabaseClient,
emoji_name: &str,
) -> Result<DbEmoji, DatabaseError> {
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<Vec<DbEmoji>, 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::<Result<_, _>>()?;
Ok(emojis)
}
pub async fn get_emoji_by_name_and_hostname( pub async fn get_emoji_by_name_and_hostname(
db_client: &impl DatabaseClient, db_client: &impl DatabaseClient,
emoji_name: &str, emoji_name: &str,
@ -172,11 +207,7 @@ mod tests {
#[serial] #[serial]
async fn test_delete_emoji() { async fn test_delete_emoji() {
let db_client = &create_test_database().await; let db_client = &create_test_database().await;
let image = EmojiImage { let image = EmojiImage::default();
file_name: "test.png".to_string(),
file_size: 10000,
media_type: "image/png".to_string(),
};
let emoji = create_emoji( let emoji = create_emoji(
db_client, db_client,
"test", "test",

View file

@ -9,6 +9,7 @@ use super::validators::EMOJI_MAX_SIZE;
fn default_emoji_file_size() -> usize { EMOJI_MAX_SIZE } fn default_emoji_file_size() -> usize { EMOJI_MAX_SIZE }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(test, derive(Default))]
pub struct EmojiImage { pub struct EmojiImage {
pub file_name: String, pub file_name: String,
#[serde(default = "default_emoji_file_size")] #[serde(default = "default_emoji_file_size")]
@ -20,6 +21,7 @@ json_from_sql!(EmojiImage);
json_to_sql!(EmojiImage); json_to_sql!(EmojiImage);
#[derive(Clone, FromSql)] #[derive(Clone, FromSql)]
#[cfg_attr(test, derive(Default))]
#[postgres(name = "emoji")] #[postgres(name = "emoji")]
pub struct DbEmoji { pub struct DbEmoji {
pub id: Uuid, pub id: Uuid,

View file

@ -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<name>[\w.]+):";
/// Finds emoji shortcodes in text
fn find_shortcodes(text: &str) -> Vec<String> {
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<HashMap<String, DbEmoji>, 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<String, DbEmoji> = 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",
]);
}
}

View file

@ -1,3 +1,4 @@
pub mod emojis;
pub mod hashtags; pub mod hashtags;
pub mod helpers; pub mod helpers;
pub mod links; pub mod links;