Download custom emojis contained in remote posts

This commit is contained in:
silverpill 2023-01-07 19:28:26 +00:00
parent 7b8a56dd8f
commit 56e75895bd
14 changed files with 366 additions and 25 deletions

View file

@ -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)
);

View file

@ -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,

View file

@ -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<String, ValidationError> {
@ -221,6 +232,7 @@ pub async fn handle_note(
let mut mentions: Vec<Uuid> = Vec::new();
let mut hashtags = vec![];
let mut links = vec![];
let mut emojis = vec![];
if let Some(value) = object.tag {
let list: Vec<JsonValue> = 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,
};

View file

@ -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<String>,
pub url: String,
pub media_type: Option<String>,
}
#[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<Utc>,
pub icon: EmojiImage,
pub id: String,
pub name: String,
pub updated: DateTime<Utc>,
}
#[derive(Deserialize)]

View file

@ -155,6 +155,7 @@ async fn create_status(
mentions: mentions,
tags: tags,
links: links,
emojis: vec![],
object_id: None,
created_at: Utc::now(),
};

2
src/models/emojis/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod queries;
pub mod types;

View file

@ -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<Utc>,
) -> Result<DbEmoji, DatabaseError> {
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<Utc>,
) -> Result<DbEmoji, DatabaseError> {
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<DbEmoji, DatabaseError> {
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()));
}
}

View file

@ -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<String>,
pub image: ProfileImage,
pub object_id: Option<String>,
pub updated_at: DateTime<Utc>,
}

View file

@ -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;

View file

@ -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,

View file

@ -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<DbActorProfile> = row.try_get("mentions")?;
let db_tags: Vec<String> = row.try_get("tags")?;
let db_links: Vec<Uuid> = row.try_get("links")?;
let db_emojis: Vec<DbEmoji> = 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)
},

View file

@ -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<Uuid> = links_rows.iter()
.map(|row| row.try_get("target_id"))
.collect::<Result<_, _>>()?;
// 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<DbEmoji> = emojis_rows.iter()
.map(|row| row.try_get("emoji"))
.collect::<Result<_, _>>()?;
// 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,

View file

@ -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<DbActorProfile>,
pub tags: Vec<String>,
pub links: Vec<Uuid>,
pub emojis: Vec<DbEmoji>,
pub object_id: Option<String>,
pub ipfs_cid: Option<String>,
pub token_id: Option<i32>,
@ -118,6 +122,7 @@ impl Post {
db_mentions: Vec<DbActorProfile>,
db_tags: Vec<String>,
db_links: Vec<Uuid>,
db_emojis: Vec<DbEmoji>,
) -> Result<Self, DatabaseTypeError> {
// 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<DbActorProfile> = row.try_get("mentions")?;
let db_tags: Vec<String> = row.try_get("tags")?;
let db_links: Vec<Uuid> = row.try_get("links")?;
let db_emojis: Vec<DbEmoji> = 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<Uuid>,
pub tags: Vec<String>,
pub links: Vec<Uuid>,
pub emojis: Vec<Uuid>,
pub object_id: Option<String>,
pub created_at: DateTime<Utc>,
}

View file

@ -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",