Custom and remote emojis (#405)

* initial custom emoji schema

* some more progress idk

* emoji process

* better emoji processing

* it just works

* implement mastodon's /api/v1/custom_emojis

* proper url and shortcode in /api/v1/custom_emojis

* add emoji list to statues

* add some tests and stand against clippy

* handle some unsound unwraps

* make emoji a resolvable AP type

* fix tests

* add a missing test fixture

* address PR review

---------

Co-authored-by: aumetra <aumetra@cryptolab.net>
This commit is contained in:
Zeerooth 2023-11-11 20:49:36 +01:00 committed by GitHub
parent 2baa300297
commit b63370118d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1131 additions and 125 deletions

View File

@ -14,25 +14,30 @@ use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use headers::{ContentType, HeaderMapExt};
use http::HeaderValue;
use iso8601_timestamp::Timestamp;
use kitsune_cache::{ArcCache, CacheBackend};
use kitsune_db::{
model::{
account::{Account, AccountConflictChangeset, NewAccount, UpdateAccountMedia},
custom_emoji::CustomEmoji,
media_attachment::{MediaAttachment, NewMediaAttachment},
post::Post,
},
schema::{accounts, posts},
schema::{accounts, custom_emojis, media_attachments, posts},
PgPool,
};
use kitsune_embed::Client as EmbedClient;
use kitsune_http_client::Client;
use kitsune_search::SearchBackend;
use kitsune_type::{
ap::{actor::Actor, Object},
ap::{actor::Actor, emoji::Emoji, Object},
jsonld::RdfNode,
};
use mime::Mime;
use scoped_futures::ScopedFutureExt;
use serde::de::DeserializeOwned;
use speedy_uuid::Uuid;
use typed_builder::TypedBuilder;
use url::Url;
@ -175,7 +180,7 @@ impl Fetcher {
let mut actor: Actor = self.fetch_ap_resource(url.as_str()).await?;
let mut domain = url.host_str().unwrap();
let mut domain = url.host_str().ok_or(ApiError::MissingHost)?;
let domain_buf;
let fetch_webfinger = opts
.acct
@ -203,7 +208,7 @@ impl Fetcher {
};
if !used_webfinger && actor.id != url.as_str() {
url = Url::parse(&actor.id)?;
domain = url.host_str().unwrap();
domain = url.host_str().ok_or(ApiError::MissingHost)?;
}
actor.clean_html();
@ -300,6 +305,88 @@ impl Fetcher {
Ok(account)
}
pub async fn fetch_emoji(&self, url: &str) -> Result<CustomEmoji> {
let existing_emoji = self
.db_pool
.with_connection(|db_conn| {
async move {
custom_emojis::table
.filter(custom_emojis::remote_id.eq(url))
.select(CustomEmoji::as_select())
.first(db_conn)
.await
.optional()
}
.scoped()
})
.await?;
if let Some(emoji) = existing_emoji {
return Ok(emoji);
}
let mut url = Url::parse(url)?;
if !self.federation_filter.is_url_allowed(&url)? {
return Err(ApiError::Unauthorised.into());
}
let emoji: Emoji = self.client.get(url.as_str()).await?.jsonld().await?;
let mut domain = url.host_str().ok_or(ApiError::MissingHost)?;
if emoji.id != url.as_str() {
url = Url::parse(&emoji.id)?;
domain = url.host_str().ok_or(ApiError::MissingHost)?;
}
let content_type = emoji
.icon
.media_type
.as_deref()
.or_else(|| mime_guess::from_path(&emoji.icon.url).first_raw())
.ok_or(ApiError::UnsupportedMediaType)?;
let name_pure = emoji.name.replace(':', "");
let emoji: CustomEmoji = self
.db_pool
.with_transaction(|tx| {
async move {
let media_attachment = diesel::insert_into(media_attachments::table)
.values(NewMediaAttachment {
id: Uuid::now_v7(),
account_id: None,
content_type,
description: None,
blurhash: None,
file_path: None,
remote_url: Some(&emoji.icon.url),
})
.returning(MediaAttachment::as_returning())
.get_result::<MediaAttachment>(tx)
.await?;
let emoji = diesel::insert_into(custom_emojis::table)
.values(CustomEmoji {
id: Uuid::now_v7(),
remote_id: emoji.id,
shortcode: name_pure.to_string(),
domain: Some(domain.to_string()),
media_attachment_id: media_attachment.id,
endorsed: false,
created_at: Timestamp::now_utc(),
updated_at: Timestamp::now_utc(),
})
.returning(CustomEmoji::as_returning())
.get_result::<CustomEmoji>(tx)
.await?;
Ok::<_, Error>(emoji)
}
.scope_boxed()
})
.await?;
Ok(emoji)
}
#[async_recursion]
pub(super) async fn fetch_object_inner(
&self,
@ -382,7 +469,10 @@ mod test {
use iso8601_timestamp::Timestamp;
use kitsune_cache::NoopCache;
use kitsune_config::instance::FederationFilterConfiguration;
use kitsune_db::{model::account::Account, schema::accounts};
use kitsune_db::{
model::{account::Account, media_attachment::MediaAttachment},
schema::{accounts, media_attachments},
};
use kitsune_http_client::Client;
use kitsune_search::NoopSearchService;
use kitsune_test::{build_ap_response, database_test};
@ -915,6 +1005,55 @@ mod test {
.await;
}
#[tokio::test]
#[serial_test::serial]
async fn fetch_emoji() {
database_test(|db_pool| async move {
let client = Client::builder().service(service_fn(handle));
let fetcher = Fetcher::builder()
.client(client.clone())
.db_pool(db_pool.clone())
.embed_client(None)
.federation_filter(
FederationFilterService::new(&FederationFilterConfiguration::Deny {
domains: Vec::new(),
})
.unwrap(),
)
.search_backend(NoopSearchService)
.webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into())))
.post_cache(Arc::new(NoopCache.into()))
.user_cache(Arc::new(NoopCache.into()))
.build();
let emoji = fetcher
.fetch_emoji("https://corteximplant.com/emojis/7952")
.await
.expect("Fetch emoji");
assert_eq!(emoji.shortcode, "Blobhaj");
assert_eq!(emoji.domain, Some(String::from("corteximplant.com")));
let media_attachment = db_pool
.with_connection(|db_conn| {
media_attachments::table
.find(emoji.media_attachment_id)
.select(MediaAttachment::as_select())
.get_result::<MediaAttachment>(db_conn)
.scoped()
})
.await
.expect("Get media attachment");
assert_eq!(
media_attachment.remote_url,
Some(String::from(
"https://corteximplant.com/system/custom_emojis/images/000/007/952/original/33b7f12bd094b815.png"
)));
})
.await;
}
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
match req.uri().path_and_query().unwrap().as_str() {
"/users/0x0" => {
@ -933,6 +1072,16 @@ mod test {
);
Ok::<_, Infallible>(build_ap_response(body))
}
"/emojis/7952" => {
let body =
include_str!("../../../../test-fixtures/corteximplant.com_emoji_7952.json");
Ok::<_, Infallible>(build_ap_response(body))
}
"/emojis/8933" => {
let body =
include_str!("../../../../test-fixtures/corteximplant.com_emoji_8933.json");
Ok::<_, Infallible>(build_ap_response(body))
}
"/.well-known/webfinger?resource=acct:0x0@corteximplant.com" => {
let body = include_str!("../../../../test-fixtures/0x0_jrd.json");
Ok::<_, Infallible>(Response::new(Body::from(body)))

View File

@ -5,17 +5,20 @@ use crate::{
};
use diesel::{ExpressionMethods, SelectableHelper};
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use futures_util::FutureExt;
use futures_util::{future::try_join_all, FutureExt, TryFutureExt};
use http::Uri;
use iso8601_timestamp::Timestamp;
use kitsune_db::{
model::{
account::Account,
custom_emoji::PostCustomEmoji,
media_attachment::{NewMediaAttachment, NewPostMediaAttachment},
mention::NewMention,
post::{FullPostChangeset, NewPost, Post, PostConflictChangeset, Visibility},
},
schema::{media_attachments, posts, posts_media_attachments, posts_mentions},
schema::{
media_attachments, posts, posts_custom_emojis, posts_media_attachments, posts_mentions,
},
PgPool,
};
use kitsune_embed::Client as EmbedClient;
@ -64,6 +67,43 @@ async fn handle_mentions(
Ok(())
}
async fn handle_custom_emojis(
db_conn: &mut AsyncPgConnection,
post_id: Uuid,
fetcher: &Fetcher,
tags: &[Tag],
) -> Result<()> {
let emoji_iter = tags.iter().filter(|tag| tag.r#type == TagType::Emoji);
let emoji_count = emoji_iter.clone().count();
if emoji_count == 0 {
return Ok(());
}
let futures = emoji_iter.clone().filter_map(|emoji| {
let remote_id = emoji.id.as_ref()?;
Some(fetcher.fetch_emoji(remote_id).map_ok(move |f| (f, emoji)))
});
let emojis = try_join_all(futures)
.await?
.iter()
.map(|(resolved_emoji, emoji_tag)| PostCustomEmoji {
post_id,
custom_emoji_id: resolved_emoji.id,
emoji_text: emoji_tag.name.to_string(),
})
.collect::<Vec<PostCustomEmoji>>();
diesel::insert_into(posts_custom_emojis::table)
.values(emojis)
.on_conflict_do_nothing()
.execute(db_conn)
.await?;
Ok(())
}
/// Process a bunch of ActivityPub attachments
///
/// # Returns
@ -92,7 +132,7 @@ pub async fn process_attachments(
Some(NewMediaAttachment {
id: attachment_id,
account_id: author.id,
account_id: Some(author.id),
content_type,
description: attachment.name.as_deref(),
blurhash: attachment.blurhash.as_deref(),
@ -130,6 +170,7 @@ struct PreprocessedObject<'a> {
content_lang: Language,
db_pool: &'a PgPool,
object: Box<Object>,
fetcher: &'a Fetcher,
search_backend: &'a AnySearchBackend,
}
@ -201,6 +242,7 @@ async fn preprocess_object(
content_lang,
db_pool,
object,
fetcher,
search_backend,
})
}
@ -215,6 +257,7 @@ pub async fn process_new_object(process_data: ProcessNewObject<'_>) -> Result<Po
content_lang,
db_pool,
object,
fetcher,
search_backend,
} = preprocess_object(process_data).boxed().await?;
@ -263,6 +306,7 @@ pub async fn process_new_object(process_data: ProcessNewObject<'_>) -> Result<Po
.await?;
handle_mentions(tx, &user, new_post.id, &object.tag).await?;
handle_custom_emojis(tx, new_post.id, fetcher, &object.tag).await?;
Ok::<_, Error>(new_post)
}
@ -287,6 +331,7 @@ pub async fn update_object(process_data: ProcessNewObject<'_>) -> Result<Post> {
content_lang,
db_pool,
object,
fetcher: _,
search_backend,
} = preprocess_object(process_data).await?;

View File

@ -1,6 +1,7 @@
use const_format::concatcp;
pub const API_MAX_LIMIT: usize = 40;
pub const MAX_EMOJI_SHORTCODE_LENGTH: usize = 64;
pub const MAX_MEDIA_DESCRIPTION_LENGTH: usize = 5000;
pub const USER_AGENT: &str = concatcp!(env!("CARGO_PKG_NAME"), "/", VERSION);
pub const VERSION: &str = concatcp!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA"));

View File

@ -20,6 +20,9 @@ pub enum ApiError {
#[error("Invalid captcha")]
InvalidCaptcha,
#[error("Missing host")]
MissingHost,
#[error("Not found")]
NotFound,

View File

@ -56,7 +56,9 @@ use kitsune_search::{AnySearchBackend, NoopSearchService, SqlSearchService};
use kitsune_storage::{fs::Storage as FsStorage, s3::Storage as S3Storage, AnyStorageBackend};
use rusty_s3::{Bucket as S3Bucket, Credentials as S3Credentials};
use serde::{de::DeserializeOwned, Serialize};
use service::custom_emoji::CustomEmojiService;
use service::search::SearchService;
use std::{
fmt::Display,
str::FromStr,
@ -272,6 +274,12 @@ pub async fn prepare_state(
let captcha_backend = config.captcha.as_ref().map(prepare_captcha);
let captcha_service = CaptchaService::builder().backend(captcha_backend).build();
let custom_emoji_service = CustomEmojiService::builder()
.attachment_service(attachment_service.clone())
.db_pool(db_pool.clone())
.url_service(url_service.clone())
.build();
let instance_service = InstanceService::builder()
.db_pool(db_pool.clone())
.name(config.instance.name.as_str())
@ -292,6 +300,7 @@ pub async fn prepare_state(
let post_resolver = PostResolver::builder()
.account(account_service.clone())
.custom_emoji(custom_emoji_service.clone())
.build();
let post_service = PostService::builder()
@ -344,6 +353,7 @@ pub async fn prepare_state(
service: Service {
account: account_service,
captcha: captcha_service,
custom_emoji: custom_emoji_service,
federation_filter: federation_filter_service,
instance: instance_service,
job: job_service,

View File

@ -4,21 +4,23 @@ use crate::{
try_join,
util::BaseToCc,
};
use diesel::{BelongingToDsl, QueryDsl, SelectableHelper};
use diesel::{BelongingToDsl, ExpressionMethods, QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use futures_util::{future::OptionFuture, FutureExt, TryFutureExt, TryStreamExt};
use kitsune_db::{
model::{
account::Account,
custom_emoji::{CustomEmoji, PostCustomEmoji},
media_attachment::{MediaAttachment as DbMediaAttachment, PostMediaAttachment},
mention::Mention,
post::Post,
},
schema::{accounts, media_attachments, posts},
schema::{accounts, custom_emojis, media_attachments, posts, posts_custom_emojis},
};
use kitsune_type::ap::{
actor::{Actor, PublicKey},
ap_context,
emoji::Emoji,
object::{MediaAttachment, MediaAttachmentType},
AttributedToField, Object, ObjectType, Tag, TagType,
};
@ -56,6 +58,42 @@ impl IntoObject for DbMediaAttachment {
}
}
fn build_post_tags(
mentions: Vec<(Mention, Account)>,
to: &mut Vec<String>,
emojis: Vec<(CustomEmoji, PostCustomEmoji, DbMediaAttachment)>,
) -> Vec<Tag> {
let mut tag = Vec::new();
for (mention, mentioned) in mentions {
to.push(mentioned.url.clone());
tag.push(Tag {
id: None,
r#type: TagType::Mention,
name: mention.mention_text,
href: Some(mentioned.url),
icon: None,
});
}
for (custom_emoji, post_emoji, attachment) in emojis {
if let Some(attachment_url) = attachment.remote_url {
tag.push(Tag {
id: Some(custom_emoji.remote_id),
r#type: TagType::Emoji,
name: post_emoji.emoji_text,
href: None,
icon: Some(MediaAttachment {
r#type: MediaAttachmentType::Image,
name: None,
media_type: Some(attachment.content_type),
blurhash: None,
url: attachment_url,
}),
});
}
}
tag
}
impl IntoObject for Post {
type Output = Object;
@ -67,7 +105,7 @@ impl IntoObject for Post {
return Err(ApiError::NotFound.into());
}
let (account, in_reply_to, mentions, attachment_stream) = state
let (account, in_reply_to, mentions, emojis, attachment_stream) = state
.db_pool
.with_connection(|db_conn| {
async {
@ -90,6 +128,17 @@ impl IntoObject for Post {
.select((Mention::as_select(), Account::as_select()))
.load::<(Mention, Account)>(db_conn);
let custom_emojis_fut = custom_emojis::table
.inner_join(posts_custom_emojis::table)
.inner_join(media_attachments::table)
.filter(posts_custom_emojis::post_id.eq(self.id))
.select((
CustomEmoji::as_select(),
PostCustomEmoji::as_select(),
DbMediaAttachment::as_select(),
))
.load::<(CustomEmoji, PostCustomEmoji, DbMediaAttachment)>(db_conn);
let attachment_stream_fut = PostMediaAttachment::belonging_to(&self)
.inner_join(media_attachments::table)
.select(DbMediaAttachment::as_select())
@ -99,6 +148,7 @@ impl IntoObject for Post {
account_fut,
in_reply_to_fut,
mentions_fut,
custom_emojis_fut,
attachment_stream_fut
)
}
@ -122,17 +172,9 @@ impl IntoObject for Post {
.try_collect()
.await?;
let mut tag = Vec::new();
let (mut to, cc) = self.visibility.base_to_cc(state, &account);
for (mention, mentioned) in mentions {
to.push(mentioned.url.clone());
tag.push(Tag {
r#type: TagType::Mention,
name: mention.mention_text,
href: Some(mentioned.url),
icon: None,
});
}
let tag = build_post_tags(mentions, &mut to, emojis);
let account_url = state.service.url.user_url(account.id);
Ok(Object {
@ -219,3 +261,36 @@ impl IntoObject for Account {
})
}
}
impl IntoObject for CustomEmoji {
type Output = Emoji;
async fn into_object(self, state: &State) -> Result<Self::Output> {
// Officially we don't have any info about remote emojis as we're not the origin
// Let's pretend we're not home and do not answer
let name = match self.domain {
None => Ok(format!(":{}:", self.shortcode)),
Some(_) => Err(ApiError::NotFound),
}?;
let icon = state
.db_pool
.with_connection(|db_conn| {
media_attachments::table
.find(self.media_attachment_id)
.get_result::<DbMediaAttachment>(db_conn)
.map_err(Error::from)
.and_then(|media_attachment| media_attachment.into_object(state))
.scoped()
})
.await?;
Ok(Emoji {
context: ap_context(),
id: self.remote_id,
r#type: String::from("Emoji"),
name,
icon,
updated: self.updated_at,
})
}
}

View File

@ -9,9 +9,11 @@ use diesel::{
};
use diesel_async::RunQueryDsl;
use futures_util::{future::OptionFuture, FutureExt, TryFutureExt, TryStreamExt};
use iso8601_timestamp::Timestamp;
use kitsune_db::{
model::{
account::Account as DbAccount,
custom_emoji::{CustomEmoji as DbCustomEmoji, PostCustomEmoji as DbPostCustomEmoji},
favourite::Favourite as DbFavourite,
follower::Follow,
link_preview::LinkPreview,
@ -23,7 +25,8 @@ use kitsune_db::{
post::{Post as DbPost, PostSource},
},
schema::{
accounts, accounts_follows, media_attachments, notifications, posts, posts_favourites,
accounts, accounts_follows, custom_emojis, media_attachments, notifications, posts,
posts_favourites,
},
PgPool,
};
@ -35,7 +38,7 @@ use kitsune_type::mastodon::{
preview_card::PreviewType,
relationship::Relationship,
status::{Mention, StatusSource},
Account, MediaAttachment, Notification, PreviewCard, Status,
Account, CustomEmoji, MediaAttachment, Notification, PreviewCard, Status,
};
use mime::Mime;
use scoped_futures::ScopedFutureExt;
@ -332,56 +335,69 @@ impl IntoMastodon for DbPost {
state: MapperState<'_>,
) -> impl Future<Output = Result<Self::Output>> + Send {
async move {
let (account, reblog_count, favourites_count, media_attachments, mentions_stream) =
state
.db_pool
.with_connection(|db_conn| {
async {
let account_fut = accounts::table
.find(self.account_id)
.select(DbAccount::as_select())
.get_result::<DbAccount>(db_conn)
.map_err(Error::from)
.and_then(|db_account| db_account.into_mastodon(state));
let (
account,
reblog_count,
favourites_count,
media_attachments,
mentions_stream,
custom_emojis_stream,
) = state
.db_pool
.with_connection(|db_conn| {
async {
let account_fut = accounts::table
.find(self.account_id)
.select(DbAccount::as_select())
.get_result::<DbAccount>(db_conn)
.map_err(Error::from)
.and_then(|db_account| db_account.into_mastodon(state));
let reblog_count_fut = posts::table
.filter(posts::reposted_post_id.eq(self.id))
.count()
.get_result::<i64>(db_conn)
.map_err(Error::from);
let reblog_count_fut = posts::table
.filter(posts::reposted_post_id.eq(self.id))
.count()
.get_result::<i64>(db_conn)
.map_err(Error::from);
let favourites_count_fut = DbFavourite::belonging_to(&self)
.count()
.get_result::<i64>(db_conn)
.map_err(Error::from);
let favourites_count_fut = DbFavourite::belonging_to(&self)
.count()
.get_result::<i64>(db_conn)
.map_err(Error::from);
let media_attachments_fut = DbPostMediaAttachment::belonging_to(&self)
.inner_join(media_attachments::table)
.select(DbMediaAttachment::as_select())
.load_stream::<DbMediaAttachment>(db_conn)
.map_err(Error::from)
.and_then(|attachment_stream| {
attachment_stream
.map_err(Error::from)
.and_then(|attachment| attachment.into_mastodon(state))
.try_collect()
});
let media_attachments_fut = DbPostMediaAttachment::belonging_to(&self)
.inner_join(media_attachments::table)
.select(DbMediaAttachment::as_select())
.load_stream::<DbMediaAttachment>(db_conn)
.map_err(Error::from)
.and_then(|attachment_stream| {
attachment_stream
.map_err(Error::from)
.and_then(|attachment| attachment.into_mastodon(state))
.try_collect()
});
let mentions_stream_fut = DbMention::belonging_to(&self)
.load_stream::<DbMention>(db_conn)
.map_err(Error::from);
let mentions_stream_fut = DbMention::belonging_to(&self)
.load_stream::<DbMention>(db_conn)
.map_err(Error::from);
try_join!(
account_fut,
reblog_count_fut,
favourites_count_fut,
media_attachments_fut,
mentions_stream_fut,
)
}
.scoped()
})
.await?;
let custom_emojis_stream_fut = DbPostCustomEmoji::belonging_to(&self)
.inner_join(custom_emojis::table.inner_join(media_attachments::table))
.select((DbCustomEmoji::as_select(), DbMediaAttachment::as_select()))
.load_stream::<(DbCustomEmoji, DbMediaAttachment)>(db_conn)
.map_err(Error::from);
try_join!(
account_fut,
reblog_count_fut,
favourites_count_fut,
media_attachments_fut,
mentions_stream_fut,
custom_emojis_stream_fut
)
}
.scoped()
})
.await?;
let link_preview = OptionFuture::from(
self.link_preview_url
@ -402,6 +418,12 @@ impl IntoMastodon for DbPost {
.try_collect()
.await?;
let emojis = custom_emojis_stream
.map_err(Error::from)
.and_then(|(emoji, attachment)| (emoji, attachment, None).into_mastodon(state))
.try_collect()
.await?;
let reblog = state
.db_pool
.with_connection(|db_conn| {
@ -447,6 +469,7 @@ impl IntoMastodon for DbPost {
account,
media_attachments,
mentions,
emojis,
reblog,
favourited: false,
reblogged: false,
@ -588,3 +611,37 @@ impl IntoMastodon for DbNotification {
})
}
}
impl IntoMastodon for (DbCustomEmoji, DbMediaAttachment, Option<Timestamp>) {
type Output = CustomEmoji;
fn id(&self) -> Option<Uuid> {
Some(self.0.id)
}
async fn into_mastodon(self, state: MapperState<'_>) -> Result<Self::Output> {
let (emoji, attachment, last_used) = self;
let shortcode = if let Some(ref domain) = emoji.domain {
format!(":{}@{}:", emoji.shortcode, domain)
} else {
format!(":{}:", emoji.shortcode)
};
let url = state.url_service.media_url(attachment.id);
let category = if last_used.is_some() {
Some(String::from("recently used"))
} else if emoji.endorsed {
Some(String::from("endorsed"))
} else if emoji.domain.is_none() {
Some(String::from("local"))
} else {
Some(emoji.domain.unwrap())
};
Ok(CustomEmoji {
shortcode,
url: url.clone(),
static_url: url,
visible_in_picker: true,
category,
})
}
}

View File

@ -1,6 +1,9 @@
use crate::{
error::{Error, Result},
service::account::{AccountService, GetUser},
service::{
account::{AccountService, GetUser},
custom_emoji::{CustomEmojiService, GetEmoji},
},
};
use post_process::{BoxError, Element, Html, Render};
use speedy_uuid::Uuid;
@ -10,6 +13,13 @@ use typed_builder::TypedBuilder;
#[derive(Clone, TypedBuilder)]
pub struct PostResolver {
account: AccountService,
custom_emoji: CustomEmojiService,
}
pub struct ResolvedPost {
pub mentioned_accounts: Vec<(Uuid, String)>,
pub custom_emojis: Vec<(Uuid, String)>,
pub content: String,
}
impl PostResolver {
@ -17,6 +27,7 @@ impl PostResolver {
&self,
element: Element<'a>,
mentioned_accounts: mpsc::Sender<(Uuid, String)>,
custom_emojis: mpsc::Sender<(Uuid, String)>,
) -> Result<Element<'a>, BoxError> {
let element = match element {
Element::Mention(mention) => {
@ -47,6 +58,19 @@ impl PostResolver {
attributes: vec![(Cow::Borrowed("href"), link.content.clone())],
content: Box::new(Element::Link(link)),
}),
Element::Emote(emote) => {
let get_emoji = GetEmoji::builder()
.shortcode(&emote.shortcode)
.domain(emote.domain.as_deref())
.build();
if let Some(emoji) = self.custom_emoji.get(get_emoji).await? {
let mut emoji_text = String::new();
Element::Emote(emote.clone()).render(&mut emoji_text);
let _ = custom_emojis.send((emoji.id, emoji_text));
}
Element::Emote(emote)
}
elem => elem,
};
@ -60,16 +84,25 @@ impl PostResolver {
/// - List of mentioned accounts, represented as `(Account ID, Mention text)`
/// - Content with the mentions replaced by links
#[instrument(skip_all)]
pub async fn resolve(&self, content: &str) -> Result<(Vec<(Uuid, String)>, String)> {
pub async fn resolve(&self, content: &str) -> Result<ResolvedPost> {
let (mentioned_account_ids_acc, mentioned_account_ids) = mpsc::channel();
let (custom_emoji_ids_sen, custom_emoji_ids_rec) = mpsc::channel();
let content = post_process::transform(content, |elem| {
self.transform(elem, mentioned_account_ids_acc.clone())
self.transform(
elem,
mentioned_account_ids_acc.clone(),
custom_emoji_ids_sen.clone(),
)
})
.await
.map_err(Error::PostProcessing)?;
Ok((mentioned_account_ids.try_iter().collect(), content))
Ok(ResolvedPost {
mentioned_accounts: mentioned_account_ids.try_iter().collect(),
custom_emojis: custom_emoji_ids_rec.try_iter().collect(),
content,
})
}
}
@ -81,8 +114,10 @@ mod test {
job::KitsuneContextRepo,
service::{
account::AccountService, attachment::AttachmentService,
federation_filter::FederationFilterService, job::JobService, url::UrlService,
custom_emoji::CustomEmojiService, federation_filter::FederationFilterService,
job::JobService, url::UrlService,
},
try_join,
webfinger::Webfinger,
};
use athena::JobQueue;
@ -90,24 +125,31 @@ mod test {
use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use hyper::{Body, Request, Response};
use iso8601_timestamp::Timestamp;
use kitsune_cache::NoopCache;
use kitsune_config::instance::FederationFilterConfiguration;
use kitsune_db::{model::account::Account, schema::accounts};
use kitsune_db::{
model::{
account::Account, custom_emoji::CustomEmoji, media_attachment::NewMediaAttachment,
},
schema::{accounts, custom_emojis, media_attachments},
};
use kitsune_http_client::Client;
use kitsune_search::NoopSearchService;
use kitsune_storage::fs::Storage as FsStorage;
use kitsune_test::{build_ap_response, database_test, redis_test};
use pretty_assertions::assert_eq;
use scoped_futures::ScopedFutureExt;
use speedy_uuid::Uuid;
use std::sync::Arc;
use tower::service_fn;
#[tokio::test]
#[serial_test::serial]
async fn parse_mentions() {
async fn parse_post() {
redis_test(|redis_pool| async move {
database_test(|db_pool| async move {
let post = "Hello @0x0@corteximplant.com! How are you doing?";
let post = "Hello @0x0@corteximplant.com! How are you doing? :blobhaj_happy: :blobhaj_sad@example.com:";
let client = service_fn(|req: Request<_>| async move {
match req.uri().path_and_query().unwrap().as_str() {
@ -158,13 +200,13 @@ mod test {
let attachment_service = AttachmentService::builder()
.db_pool(db_pool.clone())
.media_proxy_enabled(false)
.media_proxy_enabled(true)
.storage_backend(FsStorage::new("uploads".into()))
.url_service(url_service.clone())
.build();
let account_service = AccountService::builder()
.attachment_service(attachment_service)
.attachment_service(attachment_service.clone())
.db_pool(db_pool.clone())
.fetcher(fetcher)
.job_service(job_service)
@ -172,19 +214,93 @@ mod test {
.webfinger(webfinger)
.build();
let mention_resolver = PostResolver::builder()
.account(account_service)
let custom_emoji_service = CustomEmojiService::builder()
.attachment_service(attachment_service.clone())
.db_pool(db_pool.clone())
.url_service(url_service.clone())
.build();
let (mentioned_account_ids, content) = mention_resolver
let emoji_ids = (Uuid::now_v7(), Uuid::now_v7());
let media_attachment_ids = (Uuid::now_v7(), Uuid::now_v7());
db_pool
.with_connection(|db_conn| {
async {
let media_fut = diesel::insert_into(media_attachments::table)
.values(NewMediaAttachment {
id: media_attachment_ids.0,
content_type: "image/jpeg",
account_id: None,
description: None,
blurhash: None,
file_path: None,
remote_url: None,
})
.execute(db_conn);
let emoji_fut = diesel::insert_into(custom_emojis::table)
.values(CustomEmoji {
id: emoji_ids.0,
shortcode: String::from("blobhaj_happy"),
domain: None,
remote_id: String::from("https://local.domain/emoji/blobhaj_happy"),
media_attachment_id: media_attachment_ids.0,
endorsed: false,
created_at: Timestamp::now_utc(),
updated_at: Timestamp::now_utc()
})
.execute(db_conn);
try_join!(media_fut, emoji_fut)
}.scoped()
})
.await
.expect("Failed to insert the local emoji");
db_pool
.with_connection(|db_conn| {
async {
let media_fut = diesel::insert_into(media_attachments::table)
.values(NewMediaAttachment {
id: media_attachment_ids.1,
content_type: "image/jpeg",
account_id: None,
description: None,
blurhash: None,
file_path: None,
remote_url: Some("https://media.example.com/emojis/blobhaj.jpeg"),
})
.execute(db_conn);
let emoji_fut = diesel::insert_into(custom_emojis::table)
.values(CustomEmoji {
id: emoji_ids.1,
shortcode: String::from("blobhaj_sad"),
domain: Some(String::from("example.com")),
remote_id: String::from("https://example.com/emojis/1"),
media_attachment_id: media_attachment_ids.1,
endorsed: false,
created_at: Timestamp::now_utc(),
updated_at: Timestamp::now_utc(),
})
.execute(db_conn);
try_join!(media_fut, emoji_fut)
}.scoped()
})
.await
.expect("Failed to insert the remote emoji");
let post_resolver = PostResolver::builder()
.account(account_service)
.custom_emoji(custom_emoji_service)
.build();
let resolved = post_resolver
.resolve(post)
.await
.expect("Failed to resolve mentions");
.expect("Failed to resolve the post");
assert_eq!(content, "Hello <a class=\"mention\" href=\"https://corteximplant.com/users/0x0\">@0x0@corteximplant.com</a>! How are you doing?");
assert_eq!(mentioned_account_ids.len(), 1);
assert_eq!(resolved.content, "Hello <a class=\"mention\" href=\"https://corteximplant.com/users/0x0\">@0x0@corteximplant.com</a>! How are you doing? :blobhaj_happy: :blobhaj_sad__example_com:");
assert_eq!(resolved.mentioned_accounts.len(), 1);
assert_eq!(resolved.custom_emojis.len(), 2);
let (account_id, _mention_text) = &mentioned_account_ids[0];
let (account_id, _mention_text) = &resolved.mentioned_accounts[0];
let mentioned_account = db_pool
.with_connection(|db_conn| {
accounts::table
@ -202,6 +318,9 @@ mod test {
mentioned_account.url,
"https://corteximplant.com/users/0x0"
);
assert_eq!(resolved.custom_emojis[0], (emoji_ids.1, String::from(":blobhaj_sad__example_com:")));
assert_eq!(resolved.custom_emojis[1], (emoji_ids.0, String::from(":blobhaj_happy:")));
}).await;
}).await;
}

View File

@ -54,8 +54,9 @@ pub struct Update {
#[derive(Builder, Validate)]
#[builder(pattern = "owned")]
pub struct Upload<S> {
#[builder(default, setter(strip_option))]
#[garde(skip)]
account_id: Uuid,
account_id: Option<Uuid>,
#[garde(custom(is_allowed_filetype))]
content_type: String,
#[builder(default, setter(strip_option))]
@ -115,10 +116,10 @@ impl AttachmentService {
pub async fn get_url(&self, id: Uuid) -> Result<String> {
let media_attachment = self.get_by_id(id).await?;
if self.media_proxy_enabled || media_attachment.file_path.is_some() {
return Ok(self.url_service.media_url(id));
return Ok(self.url_service.media_url(media_attachment.id));
}
Ok(media_attachment.remote_url.unwrap())
Ok(media_attachment.remote_url.as_ref().unwrap().to_string())
}
/// Return a stream that yields the file's contents
@ -224,8 +225,8 @@ impl AttachmentService {
diesel::insert_into(media_attachments::table)
.values(NewMediaAttachment {
id: Uuid::now_v7(),
account_id: upload.account_id,
content_type: upload.content_type.as_str(),
account_id: upload.account_id,
description: upload.description.as_deref(),
blurhash: None,
file_path: Some(upload.path.as_str()),
@ -324,7 +325,7 @@ mod test {
let attachment = MediaAttachment {
id: Uuid::now_v7(),
account_id,
account_id: Some(account_id),
content_type: String::from("image/jpeg"),
description: None,
blurhash: None,

View File

@ -0,0 +1,188 @@
use crate::{
consts::MAX_EMOJI_SHORTCODE_LENGTH,
error::{BoxError, Error, Result},
};
use bytes::Bytes;
use diesel::{
BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods,
OptionalExtension, QueryDsl, SelectableHelper,
};
use diesel_async::RunQueryDsl;
use futures_util::{Stream, TryStreamExt};
use garde::Validate;
use iso8601_timestamp::Timestamp;
use kitsune_db::{
model::{custom_emoji::CustomEmoji, media_attachment::MediaAttachment},
schema::{custom_emojis, media_attachments, posts, posts_custom_emojis},
PgPool,
};
use scoped_futures::ScopedFutureExt;
use speedy_uuid::Uuid;
use typed_builder::TypedBuilder;
use super::{
attachment::{AttachmentService, Upload},
url::UrlService,
};
const ALLOWED_FILETYPES: &[mime::Name<'_>] = &[mime::IMAGE];
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_allowed_filetype(value: &str, _ctx: &()) -> garde::Result {
let content_type: mime::Mime = value
.parse()
.map_err(|err: mime::FromStrError| garde::Error::new(err.to_string()))?;
if !ALLOWED_FILETYPES.contains(&content_type.type_()) {
return Err(garde::Error::new("Invalid file type"));
}
Ok(())
}
#[derive(TypedBuilder)]
pub struct GetEmoji<'a> {
shortcode: &'a str,
#[builder(default)]
domain: Option<&'a str>,
}
#[derive(TypedBuilder)]
pub struct GetEmojiList {
#[builder(default)]
fetching_account_id: Option<Uuid>,
#[builder(default = 5000)]
limit: i64,
}
#[derive(TypedBuilder, Validate)]
pub struct EmojiUpload<S> {
#[garde(custom(is_allowed_filetype))]
content_type: String,
#[garde(length(max = MAX_EMOJI_SHORTCODE_LENGTH))]
#[garde(pattern("^([a-zA-Z0-9]_?)*[a-zA-Z0-9]$"))]
shortcode: String,
#[garde(skip)]
stream: S,
}
#[derive(Clone, TypedBuilder)]
pub struct CustomEmojiService {
attachment_service: AttachmentService,
db_pool: PgPool,
url_service: UrlService,
}
impl CustomEmojiService {
pub async fn get(&self, get_emoji: GetEmoji<'_>) -> Result<Option<CustomEmoji>> {
let mut query = custom_emojis::table
.filter(custom_emojis::shortcode.eq(get_emoji.shortcode))
.inner_join(media_attachments::table)
.select(CustomEmoji::as_select())
.into_boxed();
if let Some(domain) = get_emoji.domain {
query = query.filter(custom_emojis::domain.eq(domain));
}
self.db_pool
.with_connection(|db_conn| {
async move { query.first(db_conn).await.optional() }.scoped()
})
.await
.map_err(Error::from)
}
pub async fn get_by_id(&self, id: Uuid) -> Result<CustomEmoji> {
let query = custom_emojis::table
.find(id)
.select(CustomEmoji::as_select());
self.db_pool
.with_connection(|db_conn| async move { query.get_result(db_conn).await }.scoped())
.await
.map_err(Error::from)
}
pub async fn get_list(
&self,
get_emoji_list: GetEmojiList,
) -> Result<impl Stream<Item = Result<(CustomEmoji, MediaAttachment, Option<Timestamp>)>> + '_>
{
let query = custom_emojis::table
.left_join(
posts_custom_emojis::table.inner_join(
posts::table.on(posts::account_id
.nullable()
.eq(get_emoji_list.fetching_account_id)),
),
)
.inner_join(media_attachments::table)
.filter(
posts::account_id.is_null().or(posts::account_id
.nullable()
.eq(get_emoji_list.fetching_account_id)),
)
.filter(
custom_emojis::endorsed
.eq(true)
.or(custom_emojis::domain.is_null())
.or(posts::created_at.is_not_null()),
)
.distinct_on(custom_emojis::id)
.select((
CustomEmoji::as_select(),
MediaAttachment::as_select(),
posts::created_at.nullable(),
))
.limit(get_emoji_list.limit);
self.db_pool
.with_connection(|db_conn| {
async move { Ok::<_, Error>(query.load_stream(db_conn).await?.map_err(Error::from)) }
.scoped()
})
.await
.map_err(Error::from)
}
pub async fn add_emoji<S>(&self, emoji_upload: EmojiUpload<S>) -> Result<CustomEmoji>
where
S: Stream<Item = Result<Bytes, BoxError>> + Send + 'static,
{
emoji_upload.validate(&())?;
let attachment_upload = Upload::builder()
.content_type(emoji_upload.content_type)
.stream(emoji_upload.stream)
.build()
.unwrap();
let attachment = self.attachment_service.upload(attachment_upload).await?;
let id = Uuid::now_v7();
let remote_id = self.url_service.custom_emoji_url(id);
let custom_emoji = self
.db_pool
.with_connection(|db_conn| {
diesel::insert_into(custom_emojis::table)
.values(CustomEmoji {
id,
remote_id,
shortcode: emoji_upload.shortcode,
domain: None,
media_attachment_id: attachment.id,
endorsed: false,
created_at: Timestamp::now_utc(),
updated_at: Timestamp::now_utc(),
})
.get_result(db_conn)
.scoped()
})
.await?;
Ok(custom_emoji)
}
}

View File

@ -3,6 +3,7 @@ use crate::consts::API_MAX_LIMIT;
pub mod account;
pub mod attachment;
pub mod captcha;
pub mod custom_emoji;
pub mod federation_filter;
pub mod instance;
pub mod job;

View File

@ -31,6 +31,7 @@ use iso8601_timestamp::Timestamp;
use kitsune_db::{
model::{
account::Account,
custom_emoji::PostCustomEmoji,
favourite::{Favourite, NewFavourite},
media_attachment::NewPostMediaAttachment,
mention::NewMention,
@ -40,8 +41,9 @@ use kitsune_db::{
},
post_permission_check::{PermissionCheck, PostPermissionCheckExt},
schema::{
accounts, accounts_preferences, media_attachments, notifications, posts, posts_favourites,
posts_media_attachments, posts_mentions, users_roles,
accounts, accounts_preferences, media_attachments, notifications, posts,
posts_custom_emojis, posts_favourites, posts_media_attachments, posts_mentions,
users_roles,
},
PgPool,
};
@ -402,11 +404,39 @@ impl PostService {
Ok(())
}
async fn process_custom_emojis(
conn: &mut AsyncPgConnection,
post_id: Uuid,
custom_emojis: Vec<(Uuid, String)>,
) -> Result<()> {
if custom_emojis.is_empty() {
return Ok(());
}
diesel::insert_into(posts_custom_emojis::table)
.values(
custom_emojis
.iter()
.map(|(emoji_id, emoji_text)| PostCustomEmoji {
post_id,
custom_emoji_id: *emoji_id,
emoji_text: emoji_text.to_string(),
})
.collect::<Vec<PostCustomEmoji>>(),
)
.on_conflict_do_nothing()
.execute(conn)
.await?;
Ok(())
}
/// Create a new post and deliver it to the followers
///
/// # Panics
///
/// This should never ever panic. If it does, create a bug report.
#[allow(clippy::too_many_lines)]
pub async fn create(&self, create_post: CreatePost) -> Result<Post> {
create_post.validate(&PostValidationContext {
character_limit: self.instance_service.character_limit(),
@ -432,7 +462,7 @@ impl PostService {
|lang| Language::from_639_1(&lang).unwrap_or_else(|| detect_language(&content)),
);
let (mentioned_account_ids, content) = self.post_resolver.resolve(&content).await?;
let resolved = self.post_resolver.resolve(&content).await?;
let link_preview_url = if let Some(ref embed_client) = self.embed_client {
embed_client
.fetch_embed_for_fragment(&content)
@ -468,7 +498,7 @@ impl PostService {
in_reply_to_id,
reposted_post_id: None,
subject: subject.as_deref(),
content: content.as_str(),
content: resolved.content.as_str(),
content_source: content_source.as_str(),
content_lang: content_lang.into(),
link_preview_url: link_preview_url.as_deref(),
@ -482,8 +512,14 @@ impl PostService {
.get_result(tx)
.await?;
Self::process_mentions(tx, post.account_id, post.id, mentioned_account_ids)
.await?;
Self::process_mentions(
tx,
post.account_id,
post.id,
resolved.mentioned_accounts,
)
.await?;
Self::process_custom_emojis(tx, post.id, resolved.custom_emojis).await?;
Self::process_media_attachments(tx, post.id, &create_post.media_ids).await?;
NotificationService::notify_on_new_post(tx, post.account_id, post.id).await?;
@ -591,12 +627,16 @@ impl PostService {
.map(|c| kitsune_language::detect_language(DetectionBackend::default(), c)),
};
let (mentioned_account_ids, content) = match content.as_ref() {
let (mentioned_account_ids, custom_emojis, content) = match content.as_ref() {
Some(content) => {
let resolved = self.post_resolver.resolve(content).await?;
(resolved.0, Some(resolved.1))
(
resolved.mentioned_accounts,
resolved.custom_emojis,
Some(resolved.content),
)
}
None => (Vec::new(), None),
None => (Vec::new(), Vec::new(), None),
};
let link_preview_url = if let (Some(embed_client), Some(content)) =
@ -631,6 +671,7 @@ impl PostService {
Self::process_mentions(tx, post.account_id, post.id, mentioned_account_ids)
.await?;
Self::process_custom_emojis(tx, post.id, custom_emojis).await?;
Self::process_media_attachments(tx, post.id, &update_post.media_ids).await?;
NotificationService::notify_on_update_post(tx, post.account_id, post.id)
.await?;

View File

@ -32,6 +32,11 @@ impl UrlService {
format!("{}/confirm-account/{token}", self.base_url())
}
#[must_use]
pub fn custom_emoji_url(&self, custom_emoji_id: Uuid) -> String {
format!("{}/emojis/{}", self.base_url(), custom_emoji_id)
}
#[must_use]
pub fn default_avatar_url(&self) -> String {
format!("{}/public/assets/default-avatar.png", self.base_url())

View File

@ -3,9 +3,10 @@ use crate::{
event::PostEventEmitter,
service::{
account::AccountService, attachment::AttachmentService, captcha::CaptchaService,
federation_filter::FederationFilterService, instance::InstanceService, job::JobService,
mailing::MailingService, notification::NotificationService, post::PostService,
search::SearchService, timeline::TimelineService, url::UrlService, user::UserService,
custom_emoji::CustomEmojiService, federation_filter::FederationFilterService,
instance::InstanceService, job::JobService, mailing::MailingService,
notification::NotificationService, post::PostService, search::SearchService,
timeline::TimelineService, url::UrlService, user::UserService,
},
webfinger::Webfinger,
};
@ -30,6 +31,7 @@ pub struct Service {
pub account: AccountService,
pub attachment: AttachmentService,
pub captcha: CaptchaService,
pub custom_emoji: CustomEmojiService,
pub federation_filter: FederationFilterService,
pub job: JobService,
pub mailing: MailingService,

View File

@ -1,6 +1,6 @@
CREATE TABLE media_attachments (
id UUID PRIMARY KEY,
account_id UUID NOT NULL,
account_id UUID,
content_type TEXT NOT NULL,
description TEXT,
blurhash TEXT,

View File

@ -0,0 +1,6 @@
DROP INDEX "idx-custom_emojis-remote_id";
DROP INDEX "idx-custom_emojis-shortcode";
DROP INDEX "idx-custom_emojis-domain";
DROP TABLE posts_custom_emojis;
DROP TABLE custom_emojis;

View File

@ -0,0 +1,32 @@
CREATE TABLE custom_emojis (
id UUID PRIMARY KEY,
shortcode TEXT NOT NULL,
domain TEXT,
remote_id TEXT NOT NULL UNIQUE,
media_attachment_id UUID NOT NULL,
endorsed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- UNIQUE constraints
UNIQUE (shortcode, domain),
-- Foreign key constraints
FOREIGN KEY (media_attachment_id) REFERENCES media_attachments(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE posts_custom_emojis (
post_id UUID NOT NULL,
custom_emoji_id UUID NOT NULL,
emoji_text TEXT NOT NULL,
PRIMARY KEY (post_id, custom_emoji_id),
-- Foreign key constraints
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (custom_emoji_id) REFERENCES custom_emojis(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "idx-custom_emojis-remote_id" ON custom_emojis (remote_id);
CREATE INDEX "idx-custom_emojis-shortcode" ON custom_emojis (shortcode);
CREATE INDEX "idx-custom_emojis-domain" ON custom_emojis (domain);

View File

@ -0,0 +1,42 @@
use super::post::Post;
use crate::schema::posts_custom_emojis;
use diesel::{AsChangeset, Associations, Identifiable, Insertable, Queryable, Selectable};
use iso8601_timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use speedy_uuid::Uuid;
use crate::schema::custom_emojis;
#[derive(Clone, Deserialize, Serialize, Identifiable, Insertable, Selectable, Queryable)]
#[diesel(table_name = custom_emojis)]
pub struct CustomEmoji {
pub id: Uuid,
pub shortcode: String,
pub domain: Option<String>,
pub remote_id: String,
pub media_attachment_id: Uuid,
pub endorsed: bool,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(AsChangeset)]
#[diesel(table_name = custom_emojis)]
pub struct CustomEmojiConflictChangeset {
pub media_attachment_id: Uuid,
}
#[derive(
Associations, Clone, Deserialize, Identifiable, Insertable, Queryable, Selectable, Serialize,
)]
#[diesel(
belongs_to(CustomEmoji),
belongs_to(Post),
primary_key(custom_emoji_id, post_id),
table_name = posts_custom_emojis,
)]
pub struct PostCustomEmoji {
pub post_id: Uuid,
pub custom_emoji_id: Uuid,
pub emoji_text: String,
}

View File

@ -9,7 +9,7 @@ use speedy_uuid::Uuid;
#[diesel(belongs_to(Account), table_name = media_attachments)]
pub struct MediaAttachment {
pub id: Uuid,
pub account_id: Uuid,
pub account_id: Option<Uuid>,
pub content_type: String,
pub description: Option<String>,
pub blurhash: Option<String>,
@ -29,7 +29,7 @@ pub struct UpdateMediaAttachment<'a> {
#[diesel(table_name = media_attachments)]
pub struct NewMediaAttachment<'a> {
pub id: Uuid,
pub account_id: Uuid,
pub account_id: Option<Uuid>,
pub content_type: &'a str,
pub description: Option<&'a str>,
pub blurhash: Option<&'a str>,

View File

@ -1,4 +1,5 @@
pub mod account;
pub mod custom_emoji;
pub mod favourite;
pub mod follower;
pub mod job_context;

View File

@ -267,6 +267,65 @@ diesel::table! {
}
}
diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
/// Representation of the `custom_emojis` table.
///
/// (Automatically generated by Diesel.)
custom_emojis (id) {
/// The `id` column of the `custom_emojis` table.
///
/// Its SQL type is `Uuid`.
///
/// (Automatically generated by Diesel.)
id -> Uuid,
/// The `shortcode` column of the `custom_emojis` table.
///
/// Its SQL type is `Text`.
///
/// (Automatically generated by Diesel.)
shortcode -> Text,
/// The `domain` column of the `custom_emojis` table.
///
/// Its SQL type is `Nullable<Text>`.
///
/// (Automatically generated by Diesel.)
domain -> Nullable<Text>,
/// The `remote_id` column of the `custom_emojis` table.
///
/// Its SQL type is `Text`.
///
/// (Automatically generated by Diesel.)
remote_id -> Text,
/// The `media_attachment_id` column of the `custom_emojis` table.
///
/// Its SQL type is `Uuid`.
///
/// (Automatically generated by Diesel.)
media_attachment_id -> Uuid,
/// The `endorsed` column of the `custom_emojis` table.
///
/// Its SQL type is `Bool`.
///
/// (Automatically generated by Diesel.)
endorsed -> Bool,
/// The `created_at` column of the `custom_emojis` table.
///
/// Its SQL type is `Timestamptz`.
///
/// (Automatically generated by Diesel.)
created_at -> Timestamptz,
/// The `updated_at` column of the `custom_emojis` table.
///
/// Its SQL type is `Timestamptz`.
///
/// (Automatically generated by Diesel.)
updated_at -> Timestamptz,
}
}
diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
@ -359,10 +418,10 @@ diesel::table! {
id -> Uuid,
/// The `account_id` column of the `media_attachments` table.
///
/// Its SQL type is `Uuid`.
/// Its SQL type is `Nullable<Uuid>`.
///
/// (Automatically generated by Diesel.)
account_id -> Uuid,
account_id -> Nullable<Uuid>,
/// The `content_type` column of the `media_attachments` table.
///
/// Its SQL type is `Text`.
@ -751,6 +810,35 @@ diesel::table! {
}
}
diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
/// Representation of the `posts_custom_emojis` table.
///
/// (Automatically generated by Diesel.)
posts_custom_emojis (post_id, custom_emoji_id) {
/// The `post_id` column of the `posts_custom_emojis` table.
///
/// Its SQL type is `Uuid`.
///
/// (Automatically generated by Diesel.)
post_id -> Uuid,
/// The `custom_emoji_id` column of the `posts_custom_emojis` table.
///
/// Its SQL type is `Uuid`.
///
/// (Automatically generated by Diesel.)
custom_emoji_id -> Uuid,
/// The `emoji_text` column of the `posts_custom_emojis` table.
///
/// Its SQL type is `Text`.
///
/// (Automatically generated by Diesel.)
emoji_text -> Text,
}
}
diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
@ -963,6 +1051,7 @@ diesel::table! {
}
diesel::joinable!(accounts_preferences -> accounts (account_id));
diesel::joinable!(custom_emojis -> media_attachments (media_attachment_id));
diesel::joinable!(notifications -> posts (post_id));
diesel::joinable!(oauth2_access_tokens -> oauth2_applications (application_id));
diesel::joinable!(oauth2_access_tokens -> users (user_id));
@ -972,6 +1061,8 @@ diesel::joinable!(oauth2_refresh_tokens -> oauth2_access_tokens (access_token));
diesel::joinable!(oauth2_refresh_tokens -> oauth2_applications (application_id));
diesel::joinable!(posts -> accounts (account_id));
diesel::joinable!(posts -> link_previews (link_preview_url));
diesel::joinable!(posts_custom_emojis -> custom_emojis (custom_emoji_id));
diesel::joinable!(posts_custom_emojis -> posts (post_id));
diesel::joinable!(posts_favourites -> accounts (account_id));
diesel::joinable!(posts_favourites -> posts (post_id));
diesel::joinable!(posts_media_attachments -> media_attachments (media_attachment_id));
@ -985,6 +1076,7 @@ diesel::allow_tables_to_appear_in_same_query!(
accounts,
accounts_follows,
accounts_preferences,
custom_emojis,
job_context,
link_previews,
media_attachments,
@ -994,6 +1086,7 @@ diesel::allow_tables_to_appear_in_same_query!(
oauth2_authorization_codes,
oauth2_refresh_tokens,
posts,
posts_custom_emojis,
posts_favourites,
posts_media_attachments,
posts_mentions,

View File

@ -0,0 +1,24 @@
use super::object::MediaAttachment;
use crate::jsonld::RdfNode;
use iso8601_timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use simd_json::OwnedValue;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Emoji {
#[serde(default, rename = "@context")]
pub context: OwnedValue,
pub id: String,
pub r#type: String,
pub name: String,
pub icon: MediaAttachment,
#[serde(default = "Timestamp::now_utc")]
pub updated: Timestamp,
}
impl RdfNode for Emoji {
fn id(&self) -> Option<&str> {
Some(&self.id)
}
}

View File

@ -12,6 +12,7 @@ pub const PUBLIC_IDENTIFIER: &str = "https://www.w3.org/ns/activitystreams#Publi
pub mod actor;
pub mod collection;
pub mod emoji;
pub mod helper;
pub mod object;
@ -210,6 +211,7 @@ pub enum TagType {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Tag {
pub id: Option<String>,
pub r#type: TagType,
pub name: String,
pub href: Option<String>,

View File

@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Deserialize, Serialize, ToSchema)]
pub struct CustomEmoji {
pub shortcode: String,
pub url: String,
pub static_url: String,
pub visible_in_picker: bool,
pub category: Option<String>,
}

View File

@ -3,6 +3,7 @@ use speedy_uuid::Uuid;
use utoipa::ToSchema;
pub mod account;
pub mod custom_emoji;
pub mod instance;
pub mod media_attachment;
pub mod notification;
@ -12,6 +13,7 @@ pub mod search;
pub mod status;
pub use self::account::Account;
pub use self::custom_emoji::CustomEmoji;
pub use self::instance::Instance;
pub use self::media_attachment::MediaAttachment;
pub use self::notification::Notification;

View File

@ -1,4 +1,4 @@
use super::{Account, MediaAttachment, PreviewCard};
use super::{Account, CustomEmoji, MediaAttachment, PreviewCard};
use iso8601_timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use speedy_uuid::Uuid;
@ -49,6 +49,7 @@ pub struct Status {
pub account: Account,
pub media_attachments: Vec<MediaAttachment>,
pub mentions: Vec<Mention>,
pub emojis: Vec<CustomEmoji>,
pub reblog: Option<Box<Status>>,
pub favourited: bool,
pub reblogged: bool,

View File

@ -44,7 +44,7 @@ impl From<DbMediaAttachment> for MediaAttachment {
fn from(value: DbMediaAttachment) -> Self {
Self {
id: value.id,
account_id: value.account_id,
account_id: value.account_id.unwrap(),
content_type: value.content_type,
description: value.description,
blurhash: value.blurhash,

View File

@ -0,0 +1,21 @@
use crate::{error::Result, http::responder::ActivityPubJson, state::Zustand};
use axum::{debug_handler, extract::Path, extract::State, routing, Router};
use kitsune_core::{mapping::IntoObject, service::custom_emoji::CustomEmojiService};
use kitsune_type::ap::emoji::Emoji;
use speedy_uuid::Uuid;
#[debug_handler(state = Zustand)]
async fn get(
State(state): State<Zustand>,
State(emoji_service): State<CustomEmojiService>,
Path(id): Path<Uuid>,
) -> Result<ActivityPubJson<Emoji>> {
let custom_emoji = emoji_service.get_by_id(id).await?;
Ok(ActivityPubJson(
custom_emoji.into_object(&state.core).await?,
))
}
pub fn routes() -> Router<Zustand> {
Router::new().route("/:id", routing::get(get))
}

View File

@ -0,0 +1,42 @@
use crate::{error::Result, http::extractor::MastodonAuthExtractor, state::Zustand};
use axum::{debug_handler, extract::State, routing, Json, Router};
use futures_util::TryStreamExt;
use kitsune_core::{
mapping::MastodonMapper,
service::custom_emoji::{CustomEmojiService, GetEmojiList},
};
use kitsune_type::mastodon::CustomEmoji;
#[debug_handler(state = crate::state::Zustand)]
#[utoipa::path(
get,
path = "/api/v1/custom_emojis",
security(
("oauth_token" = [])
),
responses(
(status = 200, description = "List of custom emojis available on the server", body = Vec<CustomEmoji>)
),
)]
pub async fn get(
State(custom_emoji_service): State<CustomEmojiService>,
State(mastodon_mapper): State<MastodonMapper>,
user_data: Option<MastodonAuthExtractor>,
) -> Result<Json<Vec<CustomEmoji>>> {
let get_emoji_list = GetEmojiList::builder()
.fetching_account_id(user_data.map(|x| x.0.account.id))
.build();
let custom_emojis: Vec<CustomEmoji> = custom_emoji_service
.get_list(get_emoji_list)
.await?
.and_then(|acc| mastodon_mapper.map(acc))
.try_collect()
.await?;
Ok(Json(custom_emojis))
}
pub fn routes() -> Router<Zustand> {
Router::new().route("/", routing::get(get))
}

View File

@ -3,6 +3,7 @@ use axum::Router;
pub mod accounts;
pub mod apps;
pub mod custom_emojis;
pub mod follow_requests;
pub mod instance;
pub mod media;
@ -14,6 +15,7 @@ pub fn routes() -> Router<Zustand> {
Router::new()
.nest("/apps", apps::routes())
.nest("/accounts", accounts::routes())
.nest("/custom_emojis", custom_emojis::routes())
.nest("/follow_requests", follow_requests::routes())
.nest("/instance", instance::routes())
.nest("/media", media::routes())

View File

@ -1,4 +1,5 @@
pub mod confirm_account;
pub mod custom_emojis;
#[cfg(feature = "mastodon-api")]
pub mod mastodon;
pub mod media;

View File

@ -1,5 +1,5 @@
use self::{
handler::{confirm_account, media, nodeinfo, oauth, posts, users, well_known},
handler::{confirm_account, custom_emojis, media, nodeinfo, oauth, posts, users, well_known},
openapi::api_docs,
};
use crate::state::Zustand;
@ -44,6 +44,7 @@ pub fn create_router(
#[allow(unused_mut)]
let mut router = Router::new()
.nest("/confirm-account", confirm_account::routes())
.nest("/emojis", custom_emojis::routes())
.nest("/media", media::routes())
.nest("/nodeinfo", nodeinfo::routes())
.nest(

View File

@ -4,7 +4,7 @@ use kitsune_core::{
activitypub::Fetcher,
event::PostEventEmitter,
service::{
account::AccountService, attachment::AttachmentService,
account::AccountService, attachment::AttachmentService, custom_emoji::CustomEmojiService,
federation_filter::FederationFilterService, instance::InstanceService, job::JobService,
notification::NotificationService, post::PostService, search::SearchService,
timeline::TimelineService, url::UrlService, user::UserService,
@ -55,6 +55,7 @@ impl_from_ref! {
[
AccountService => |input: &Zustand| input.core.service.account.clone(),
AttachmentService => |input: &Zustand| input.core.service.attachment.clone(),
CustomEmojiService => |input: &Zustand| input.core.service.custom_emoji.clone(),
FederationFilterService => |input: &Zustand| input.core.service.federation_filter.clone(),
JobService => |input: &Zustand| input.core.service.job.clone(),
NotificationService => |input: &Zustand| input.core.service.notification.clone(),

View File

@ -41,6 +41,19 @@ fn enforce_prefix<'a>(lexer: &Lexer<'a, PostElement<'a>>) -> bool {
}
}
#[inline]
fn emoji_split<'a>(lexer: &Lexer<'a, PostElement<'a>>) -> (&'a str, Option<&'a str>) {
let slice = lexer.slice().trim_matches(':');
let emoji_data = if let Some((shortcode, domain)) = slice.split_once('@') {
(shortcode, Some(domain))
} else {
(slice, None)
};
emoji_data
}
#[inline]
fn mention_split<'a>(lexer: &Lexer<'a, PostElement<'a>>) -> Option<(&'a str, Option<&'a str>)> {
if !enforce_prefix(lexer) || !enforce_postfix(lexer) {
@ -61,11 +74,8 @@ fn mention_split<'a>(lexer: &Lexer<'a, PostElement<'a>>) -> Option<(&'a str, Opt
#[derive(Debug, Logos, PartialEq)]
pub enum PostElement<'a> {
#[regex(
r":[\w\d-]+:",
|lexer| lexer.slice().trim_matches(':'),
)]
Emote(&'a str),
#[regex(r":[\w\d_-]+(@[\w\-_]+\.[\.\w]+)?:", emoji_split)]
Emote((&'a str, Option<&'a str>)),
#[regex(
r"#[\w_-]+",
@ -150,8 +160,9 @@ impl<'a> Element<'a> {
) -> impl Iterator<Item = (Element<'a>, Span)> {
pairs.map(|(item, span)| {
let element = match item {
PostElement::Emote(name) => Self::Emote(Emote {
content: Cow::Borrowed(name),
PostElement::Emote((name, domain)) => Self::Emote(Emote {
shortcode: Cow::Borrowed(name),
domain: domain.map(Cow::Borrowed),
}),
PostElement::Hashtag(content) => Self::Hashtag(Hashtag {
content: Cow::Borrowed(content),
@ -187,12 +198,22 @@ impl Render for Element<'_> {
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Emote<'a> {
/// Name of an emote
pub content: Cow<'a, str>,
pub shortcode: Cow<'a, str>,
/// Domain
pub domain: Option<Cow<'a, str>>,
}
impl Render for Emote<'_> {
fn render(&self, out: &mut impl fmt::Write) {
let _ = write!(out, ":{}:", self.content);
let _ = match &self.domain {
Some(domain) => write!(
out,
":{}__{}:",
self.shortcode,
domain.replace(['.', '-'], "_")
),
None => write!(out, ":{}:", self.shortcode),
};
}
}
@ -229,9 +250,7 @@ impl Render for Html<'_> {
let _ = write!(out, " {name}=\"{value}\"");
}
let _ = out.write_char('>');
self.content.render(out);
let _ = write!(out, "</{}>", self.tag);
}
}

View File

@ -6,14 +6,20 @@ input_file: lib/post-process/tests/input/emote/full_post_1
[
(
Emote(
"blobfoxcoffee",
(
"blobfoxcoffee",
None,
),
),
6..21,
":blobfoxcoffee:",
),
(
Emote(
"blobcatpeek",
(
"blobcatpeek",
None,
),
),
40..53,
":blobcatpeek:",

View File

@ -12,7 +12,7 @@ fn link_transformation() {
tag: Cow::Borrowed("a"),
attributes: vec![(
Cow::Borrowed("href"),
Cow::Owned(format!("https://example.com/emote/{}", emote.content)),
Cow::Owned(format!("https://example.com/emote/{}", emote.shortcode)),
)],
content: Box::new(Element::Emote(emote)),
}),

View File

@ -0,0 +1 @@
{"@context":["https://www.w3.org/ns/activitystreams",{"toot":"http://joinmastodon.org/ns#","Emoji":"toot:Emoji","focalPoint":{"@container":"@list","@id":"toot:focalPoint"}}],"id":"https://corteximplant.com/emojis/7952","type":"Emoji","name":":Blobhaj:","updated":"2022-11-10T18:48:57Z","icon":{"type":"Image","mediaType":"image/png","url":"https://corteximplant.com/system/custom_emojis/images/000/007/952/original/33b7f12bd094b815.png"}}

View File

@ -0,0 +1 @@
{"@context":["https://www.w3.org/ns/activitystreams",{"toot":"http://joinmastodon.org/ns#","Emoji":"toot:Emoji","focalPoint":{"@container":"@list","@id":"toot:focalPoint"}}],"id":"https://corteximplant.com/emojis/8933","type":"Emoji","name":":blobcat:","updated":"2022-11-10T19:10:49Z","icon":{"type":"Image","mediaType":"image/png","url":"https://corteximplant.com/system/custom_emojis/images/000/008/933/original/ee14c9edeec55f08.png"}}