Skip to content

Commit b633701

Browse files
zeeroothaumetra
andauthored
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>
1 parent 2baa300 commit b633701

File tree

38 files changed

+1135
-129
lines changed

38 files changed

+1135
-129
lines changed

crates/kitsune-core/src/activitypub/fetcher.rs

+154-5
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,30 @@ use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
1414
use diesel_async::RunQueryDsl;
1515
use headers::{ContentType, HeaderMapExt};
1616
use http::HeaderValue;
17+
use iso8601_timestamp::Timestamp;
1718
use kitsune_cache::{ArcCache, CacheBackend};
1819
use kitsune_db::{
1920
model::{
2021
account::{Account, AccountConflictChangeset, NewAccount, UpdateAccountMedia},
22+
custom_emoji::CustomEmoji,
23+
media_attachment::{MediaAttachment, NewMediaAttachment},
2124
post::Post,
2225
},
23-
schema::{accounts, posts},
26+
schema::{accounts, custom_emojis, media_attachments, posts},
2427
PgPool,
2528
};
2629
use kitsune_embed::Client as EmbedClient;
2730
use kitsune_http_client::Client;
31+
2832
use kitsune_search::SearchBackend;
2933
use kitsune_type::{
30-
ap::{actor::Actor, Object},
34+
ap::{actor::Actor, emoji::Emoji, Object},
3135
jsonld::RdfNode,
3236
};
3337
use mime::Mime;
3438
use scoped_futures::ScopedFutureExt;
3539
use serde::de::DeserializeOwned;
40+
use speedy_uuid::Uuid;
3641
use typed_builder::TypedBuilder;
3742
use url::Url;
3843

@@ -175,7 +180,7 @@ impl Fetcher {
175180

176181
let mut actor: Actor = self.fetch_ap_resource(url.as_str()).await?;
177182

178-
let mut domain = url.host_str().unwrap();
183+
let mut domain = url.host_str().ok_or(ApiError::MissingHost)?;
179184
let domain_buf;
180185
let fetch_webfinger = opts
181186
.acct
@@ -203,7 +208,7 @@ impl Fetcher {
203208
};
204209
if !used_webfinger && actor.id != url.as_str() {
205210
url = Url::parse(&actor.id)?;
206-
domain = url.host_str().unwrap();
211+
domain = url.host_str().ok_or(ApiError::MissingHost)?;
207212
}
208213

209214
actor.clean_html();
@@ -300,6 +305,88 @@ impl Fetcher {
300305
Ok(account)
301306
}
302307

308+
pub async fn fetch_emoji(&self, url: &str) -> Result<CustomEmoji> {
309+
let existing_emoji = self
310+
.db_pool
311+
.with_connection(|db_conn| {
312+
async move {
313+
custom_emojis::table
314+
.filter(custom_emojis::remote_id.eq(url))
315+
.select(CustomEmoji::as_select())
316+
.first(db_conn)
317+
.await
318+
.optional()
319+
}
320+
.scoped()
321+
})
322+
.await?;
323+
324+
if let Some(emoji) = existing_emoji {
325+
return Ok(emoji);
326+
}
327+
328+
let mut url = Url::parse(url)?;
329+
if !self.federation_filter.is_url_allowed(&url)? {
330+
return Err(ApiError::Unauthorised.into());
331+
}
332+
333+
let emoji: Emoji = self.client.get(url.as_str()).await?.jsonld().await?;
334+
335+
let mut domain = url.host_str().ok_or(ApiError::MissingHost)?;
336+
337+
if emoji.id != url.as_str() {
338+
url = Url::parse(&emoji.id)?;
339+
domain = url.host_str().ok_or(ApiError::MissingHost)?;
340+
}
341+
342+
let content_type = emoji
343+
.icon
344+
.media_type
345+
.as_deref()
346+
.or_else(|| mime_guess::from_path(&emoji.icon.url).first_raw())
347+
.ok_or(ApiError::UnsupportedMediaType)?;
348+
349+
let name_pure = emoji.name.replace(':', "");
350+
351+
let emoji: CustomEmoji = self
352+
.db_pool
353+
.with_transaction(|tx| {
354+
async move {
355+
let media_attachment = diesel::insert_into(media_attachments::table)
356+
.values(NewMediaAttachment {
357+
id: Uuid::now_v7(),
358+
account_id: None,
359+
content_type,
360+
description: None,
361+
blurhash: None,
362+
file_path: None,
363+
remote_url: Some(&emoji.icon.url),
364+
})
365+
.returning(MediaAttachment::as_returning())
366+
.get_result::<MediaAttachment>(tx)
367+
.await?;
368+
let emoji = diesel::insert_into(custom_emojis::table)
369+
.values(CustomEmoji {
370+
id: Uuid::now_v7(),
371+
remote_id: emoji.id,
372+
shortcode: name_pure.to_string(),
373+
domain: Some(domain.to_string()),
374+
media_attachment_id: media_attachment.id,
375+
endorsed: false,
376+
created_at: Timestamp::now_utc(),
377+
updated_at: Timestamp::now_utc(),
378+
})
379+
.returning(CustomEmoji::as_returning())
380+
.get_result::<CustomEmoji>(tx)
381+
.await?;
382+
Ok::<_, Error>(emoji)
383+
}
384+
.scope_boxed()
385+
})
386+
.await?;
387+
Ok(emoji)
388+
}
389+
303390
#[async_recursion]
304391
pub(super) async fn fetch_object_inner(
305392
&self,
@@ -382,7 +469,10 @@ mod test {
382469
use iso8601_timestamp::Timestamp;
383470
use kitsune_cache::NoopCache;
384471
use kitsune_config::instance::FederationFilterConfiguration;
385-
use kitsune_db::{model::account::Account, schema::accounts};
472+
use kitsune_db::{
473+
model::{account::Account, media_attachment::MediaAttachment},
474+
schema::{accounts, media_attachments},
475+
};
386476
use kitsune_http_client::Client;
387477
use kitsune_search::NoopSearchService;
388478
use kitsune_test::{build_ap_response, database_test};
@@ -915,6 +1005,55 @@ mod test {
9151005
.await;
9161006
}
9171007

1008+
#[tokio::test]
1009+
#[serial_test::serial]
1010+
async fn fetch_emoji() {
1011+
database_test(|db_pool| async move {
1012+
let client = Client::builder().service(service_fn(handle));
1013+
1014+
let fetcher = Fetcher::builder()
1015+
.client(client.clone())
1016+
.db_pool(db_pool.clone())
1017+
.embed_client(None)
1018+
.federation_filter(
1019+
FederationFilterService::new(&FederationFilterConfiguration::Deny {
1020+
domains: Vec::new(),
1021+
})
1022+
.unwrap(),
1023+
)
1024+
.search_backend(NoopSearchService)
1025+
.webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into())))
1026+
.post_cache(Arc::new(NoopCache.into()))
1027+
.user_cache(Arc::new(NoopCache.into()))
1028+
.build();
1029+
1030+
let emoji = fetcher
1031+
.fetch_emoji("https://corteximplant.com/emojis/7952")
1032+
.await
1033+
.expect("Fetch emoji");
1034+
assert_eq!(emoji.shortcode, "Blobhaj");
1035+
assert_eq!(emoji.domain, Some(String::from("corteximplant.com")));
1036+
1037+
let media_attachment = db_pool
1038+
.with_connection(|db_conn| {
1039+
media_attachments::table
1040+
.find(emoji.media_attachment_id)
1041+
.select(MediaAttachment::as_select())
1042+
.get_result::<MediaAttachment>(db_conn)
1043+
.scoped()
1044+
})
1045+
.await
1046+
.expect("Get media attachment");
1047+
1048+
assert_eq!(
1049+
media_attachment.remote_url,
1050+
Some(String::from(
1051+
"https://corteximplant.com/system/custom_emojis/images/000/007/952/original/33b7f12bd094b815.png"
1052+
)));
1053+
})
1054+
.await;
1055+
}
1056+
9181057
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
9191058
match req.uri().path_and_query().unwrap().as_str() {
9201059
"/users/0x0" => {
@@ -933,6 +1072,16 @@ mod test {
9331072
);
9341073
Ok::<_, Infallible>(build_ap_response(body))
9351074
}
1075+
"/emojis/7952" => {
1076+
let body =
1077+
include_str!("../../../../test-fixtures/corteximplant.com_emoji_7952.json");
1078+
Ok::<_, Infallible>(build_ap_response(body))
1079+
}
1080+
"/emojis/8933" => {
1081+
let body =
1082+
include_str!("../../../../test-fixtures/corteximplant.com_emoji_8933.json");
1083+
Ok::<_, Infallible>(build_ap_response(body))
1084+
}
9361085
"/.well-known/webfinger?resource=acct:0x0@corteximplant.com" => {
9371086
let body = include_str!("../../../../test-fixtures/0x0_jrd.json");
9381087
Ok::<_, Infallible>(Response::new(Body::from(body)))

crates/kitsune-core/src/activitypub/mod.rs

+48-3
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ use crate::{
55
};
66
use diesel::{ExpressionMethods, SelectableHelper};
77
use diesel_async::{AsyncPgConnection, RunQueryDsl};
8-
use futures_util::FutureExt;
8+
use futures_util::{future::try_join_all, FutureExt, TryFutureExt};
99
use http::Uri;
1010
use iso8601_timestamp::Timestamp;
1111
use kitsune_db::{
1212
model::{
1313
account::Account,
14+
custom_emoji::PostCustomEmoji,
1415
media_attachment::{NewMediaAttachment, NewPostMediaAttachment},
1516
mention::NewMention,
1617
post::{FullPostChangeset, NewPost, Post, PostConflictChangeset, Visibility},
1718
},
18-
schema::{media_attachments, posts, posts_media_attachments, posts_mentions},
19+
schema::{
20+
media_attachments, posts, posts_custom_emojis, posts_media_attachments, posts_mentions,
21+
},
1922
PgPool,
2023
};
2124
use kitsune_embed::Client as EmbedClient;
@@ -64,6 +67,43 @@ async fn handle_mentions(
6467
Ok(())
6568
}
6669

70+
async fn handle_custom_emojis(
71+
db_conn: &mut AsyncPgConnection,
72+
post_id: Uuid,
73+
fetcher: &Fetcher,
74+
tags: &[Tag],
75+
) -> Result<()> {
76+
let emoji_iter = tags.iter().filter(|tag| tag.r#type == TagType::Emoji);
77+
78+
let emoji_count = emoji_iter.clone().count();
79+
if emoji_count == 0 {
80+
return Ok(());
81+
}
82+
83+
let futures = emoji_iter.clone().filter_map(|emoji| {
84+
let remote_id = emoji.id.as_ref()?;
85+
Some(fetcher.fetch_emoji(remote_id).map_ok(move |f| (f, emoji)))
86+
});
87+
88+
let emojis = try_join_all(futures)
89+
.await?
90+
.iter()
91+
.map(|(resolved_emoji, emoji_tag)| PostCustomEmoji {
92+
post_id,
93+
custom_emoji_id: resolved_emoji.id,
94+
emoji_text: emoji_tag.name.to_string(),
95+
})
96+
.collect::<Vec<PostCustomEmoji>>();
97+
98+
diesel::insert_into(posts_custom_emojis::table)
99+
.values(emojis)
100+
.on_conflict_do_nothing()
101+
.execute(db_conn)
102+
.await?;
103+
104+
Ok(())
105+
}
106+
67107
/// Process a bunch of ActivityPub attachments
68108
///
69109
/// # Returns
@@ -92,7 +132,7 @@ pub async fn process_attachments(
92132

93133
Some(NewMediaAttachment {
94134
id: attachment_id,
95-
account_id: author.id,
135+
account_id: Some(author.id),
96136
content_type,
97137
description: attachment.name.as_deref(),
98138
blurhash: attachment.blurhash.as_deref(),
@@ -130,6 +170,7 @@ struct PreprocessedObject<'a> {
130170
content_lang: Language,
131171
db_pool: &'a PgPool,
132172
object: Box<Object>,
173+
fetcher: &'a Fetcher,
133174
search_backend: &'a AnySearchBackend,
134175
}
135176

@@ -201,6 +242,7 @@ async fn preprocess_object(
201242
content_lang,
202243
db_pool,
203244
object,
245+
fetcher,
204246
search_backend,
205247
})
206248
}
@@ -215,6 +257,7 @@ pub async fn process_new_object(process_data: ProcessNewObject<'_>) -> Result<Po
215257
content_lang,
216258
db_pool,
217259
object,
260+
fetcher,
218261
search_backend,
219262
} = preprocess_object(process_data).boxed().await?;
220263

@@ -263,6 +306,7 @@ pub async fn process_new_object(process_data: ProcessNewObject<'_>) -> Result<Po
263306
.await?;
264307

265308
handle_mentions(tx, &user, new_post.id, &object.tag).await?;
309+
handle_custom_emojis(tx, new_post.id, fetcher, &object.tag).await?;
266310

267311
Ok::<_, Error>(new_post)
268312
}
@@ -287,6 +331,7 @@ pub async fn update_object(process_data: ProcessNewObject<'_>) -> Result<Post> {
287331
content_lang,
288332
db_pool,
289333
object,
334+
fetcher: _,
290335
search_backend,
291336
} = preprocess_object(process_data).await?;
292337

crates/kitsune-core/src/consts.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use const_format::concatcp;
22

33
pub const API_MAX_LIMIT: usize = 40;
4+
pub const MAX_EMOJI_SHORTCODE_LENGTH: usize = 64;
45
pub const MAX_MEDIA_DESCRIPTION_LENGTH: usize = 5000;
56
pub const USER_AGENT: &str = concatcp!(env!("CARGO_PKG_NAME"), "/", VERSION);
67
pub const VERSION: &str = concatcp!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA"));

crates/kitsune-core/src/error.rs

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub enum ApiError {
2020
#[error("Invalid captcha")]
2121
InvalidCaptcha,
2222

23+
#[error("Missing host")]
24+
MissingHost,
25+
2326
#[error("Not found")]
2427
NotFound,
2528

crates/kitsune-core/src/lib.rs

+10
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ use kitsune_search::{AnySearchBackend, NoopSearchService, SqlSearchService};
5656
use kitsune_storage::{fs::Storage as FsStorage, s3::Storage as S3Storage, AnyStorageBackend};
5757
use rusty_s3::{Bucket as S3Bucket, Credentials as S3Credentials};
5858
use serde::{de::DeserializeOwned, Serialize};
59+
use service::custom_emoji::CustomEmojiService;
5960
use service::search::SearchService;
61+
6062
use std::{
6163
fmt::Display,
6264
str::FromStr,
@@ -272,6 +274,12 @@ pub async fn prepare_state(
272274
let captcha_backend = config.captcha.as_ref().map(prepare_captcha);
273275
let captcha_service = CaptchaService::builder().backend(captcha_backend).build();
274276

277+
let custom_emoji_service = CustomEmojiService::builder()
278+
.attachment_service(attachment_service.clone())
279+
.db_pool(db_pool.clone())
280+
.url_service(url_service.clone())
281+
.build();
282+
275283
let instance_service = InstanceService::builder()
276284
.db_pool(db_pool.clone())
277285
.name(config.instance.name.as_str())
@@ -292,6 +300,7 @@ pub async fn prepare_state(
292300

293301
let post_resolver = PostResolver::builder()
294302
.account(account_service.clone())
303+
.custom_emoji(custom_emoji_service.clone())
295304
.build();
296305

297306
let post_service = PostService::builder()
@@ -344,6 +353,7 @@ pub async fn prepare_state(
344353
service: Service {
345354
account: account_service,
346355
captcha: captcha_service,
356+
custom_emoji: custom_emoji_service,
347357
federation_filter: federation_filter_service,
348358
instance: instance_service,
349359
job: job_service,

0 commit comments

Comments
 (0)