use crate::{ ap_url, blogs::Blog, db_conn::DbConn, instance::Instance, medias::Media, mentions::Mention, post_authors::*, safe_string::SafeString, schema::posts, tags::*, timeline::*, users::User, Connection, Error, PostEvent::*, Result, CONFIG, POST_CHAN, }; use activitystreams::{ activity::{Create, Delete, Update}, base::{AnyBase, Base}, iri_string::types::IriString, link::{self, kind::MentionType}, object::{kind::ImageType, ApObject, Article, AsApObject, Image, ObjectExt, Tombstone}, prelude::*, time::OffsetDateTime, }; use chrono::{NaiveDateTime, Utc}; use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl}; use once_cell::sync::Lazy; use plume_common::{ activity_pub::{ inbox::{AsActor, AsObject, FromId}, sign::Signer, Hashtag, HashtagType, Id, IntoId, Licensed, LicensedArticle, ToAsString, ToAsUri, PUBLIC_VISIBILITY, }, utils::{iri_percent_encode_seg, md_to_html}, }; use riker::actors::{Publish, Tell}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; static BLOG_FQN_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); #[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)] #[changeset_options(treat_none_as_null = "true")] pub struct Post { pub id: i32, pub blog_id: i32, pub slug: String, pub title: String, pub content: SafeString, pub published: bool, pub license: String, pub creation_date: NaiveDateTime, pub ap_url: String, pub subtitle: String, pub source: String, pub cover_id: Option, } #[derive(Insertable)] #[table_name = "posts"] pub struct NewPost { pub blog_id: i32, pub slug: String, pub title: String, pub content: SafeString, pub published: bool, pub license: String, pub creation_date: Option, pub ap_url: String, pub subtitle: String, pub source: String, pub cover_id: Option, } impl Post { get!(posts); find_by!(posts, find_by_slug, slug as &str, blog_id as i32); find_by!(posts, find_by_ap_url, ap_url as &str); last!(posts); pub fn insert(conn: &Connection, mut new: NewPost) -> Result { if new.ap_url.is_empty() { let blog = Blog::get(conn, new.blog_id)?; new.ap_url = Self::ap_url(blog, &new.slug); } diesel::insert_into(posts::table) .values(new) .execute(conn)?; let post = Self::last(conn)?; if post.published { post.publish_published(); } Ok(post) } pub fn update(&self, conn: &Connection) -> Result { diesel::update(self).set(self).execute(conn)?; let post = Self::get(conn, self.id)?; // TODO: Call publish_published() when newly published if post.published { let blog = post.get_blog(conn); if blog.is_ok() && blog.unwrap().is_local() { self.publish_updated(); } } Ok(post) } pub fn delete(&self, conn: &Connection) -> Result<()> { for m in Mention::list_for_post(conn, self.id)? { m.delete(conn)?; } diesel::delete(self).execute(conn)?; self.publish_deleted(); Ok(()) } pub fn list_by_tag( conn: &Connection, tag: String, (min, max): (i32, i32), ) -> Result> { use crate::schema::tags; let ids = tags::table.filter(tags::tag.eq(tag)).select(tags::post_id); posts::table .filter(posts::id.eq_any(ids)) .filter(posts::published.eq(true)) .order(posts::creation_date.desc()) .offset(min.into()) .limit((max - min).into()) .load(conn) .map_err(Error::from) } pub fn count_for_tag(conn: &Connection, tag: String) -> Result { use crate::schema::tags; let ids = tags::table.filter(tags::tag.eq(tag)).select(tags::post_id); posts::table .filter(posts::id.eq_any(ids)) .filter(posts::published.eq(true)) .count() .load(conn)? .first() .cloned() .ok_or(Error::NotFound) } pub fn count_local(conn: &Connection) -> Result { use crate::schema::post_authors; use crate::schema::users; let local_authors = users::table .filter(users::instance_id.eq(Instance::get_local()?.id)) .select(users::id); let local_posts_id = post_authors::table .filter(post_authors::author_id.eq_any(local_authors)) .select(post_authors::post_id); posts::table .filter(posts::id.eq_any(local_posts_id)) .filter(posts::published.eq(true)) .count() .get_result(conn) .map_err(Error::from) } pub fn count(conn: &Connection) -> Result { posts::table .filter(posts::published.eq(true)) .count() .get_result(conn) .map_err(Error::from) } pub fn list_filtered( conn: &Connection, title: Option, subtitle: Option, content: Option, ) -> Result> { let mut query = posts::table.into_boxed(); if let Some(title) = title { query = query.filter(posts::title.eq(title)); } if let Some(subtitle) = subtitle { query = query.filter(posts::subtitle.eq(subtitle)); } if let Some(content) = content { query = query.filter(posts::content.eq(content)); } query.get_results::(conn).map_err(Error::from) } pub fn get_recents_for_author( conn: &Connection, author: &User, limit: i64, ) -> Result> { use crate::schema::post_authors; let posts = PostAuthor::belonging_to(author).select(post_authors::post_id); posts::table .filter(posts::id.eq_any(posts)) .filter(posts::published.eq(true)) .order(posts::creation_date.desc()) .limit(limit) .load::(conn) .map_err(Error::from) } pub fn get_recents_for_blog(conn: &Connection, blog: &Blog, limit: i64) -> Result> { posts::table .filter(posts::blog_id.eq(blog.id)) .filter(posts::published.eq(true)) .order(posts::creation_date.desc()) .limit(limit) .load::(conn) .map_err(Error::from) } pub fn get_for_blog(conn: &Connection, blog: &Blog) -> Result> { posts::table .filter(posts::blog_id.eq(blog.id)) .filter(posts::published.eq(true)) .load::(conn) .map_err(Error::from) } pub fn count_for_blog(conn: &Connection, blog: &Blog) -> Result { posts::table .filter(posts::blog_id.eq(blog.id)) .filter(posts::published.eq(true)) .count() .get_result(conn) .map_err(Error::from) } pub fn blog_page(conn: &Connection, blog: &Blog, (min, max): (i32, i32)) -> Result> { posts::table .filter(posts::blog_id.eq(blog.id)) .filter(posts::published.eq(true)) .order(posts::creation_date.desc()) .offset(min.into()) .limit((max - min).into()) .load::(conn) .map_err(Error::from) } pub fn drafts_by_author(conn: &Connection, author: &User) -> Result> { use crate::schema::post_authors; let posts = PostAuthor::belonging_to(author).select(post_authors::post_id); posts::table .order(posts::creation_date.desc()) .filter(posts::published.eq(false)) .filter(posts::id.eq_any(posts)) .load::(conn) .map_err(Error::from) } pub fn ap_url(blog: Blog, slug: &str) -> String { ap_url(&format!( "{}/~/{}/{}/", CONFIG.base_url, blog.fqn, iri_percent_encode_seg(slug) )) } // It's better to calc slug in insert and update pub fn slug(title: &str) -> &str { title } pub fn get_authors(&self, conn: &Connection) -> Result> { use crate::schema::post_authors; use crate::schema::users; let author_list = PostAuthor::belonging_to(self).select(post_authors::author_id); users::table .filter(users::id.eq_any(author_list)) .load::(conn) .map_err(Error::from) } pub fn is_author(&self, conn: &Connection, author_id: i32) -> Result { use crate::schema::post_authors; Ok(PostAuthor::belonging_to(self) .filter(post_authors::author_id.eq(author_id)) .count() .get_result::(conn)? > 0) } pub fn get_blog(&self, conn: &Connection) -> Result { use crate::schema::blogs; blogs::table .filter(blogs::id.eq(self.blog_id)) .first(conn) .map_err(Error::from) } /// This method exists for use in templates to reduce database access. /// This should not be used for other purpose. /// /// This caches query result. The best way to cache query result is holding it in `Post`s field /// but Diesel doesn't allow it currently. /// If sometime Diesel allow it, this method should be removed. pub fn get_blog_fqn(&self, conn: &Connection) -> String { if let Some(blog_fqn) = BLOG_FQN_CACHE.lock().unwrap().get(&self.blog_id) { return blog_fqn.to_string(); } let blog_fqn = self.get_blog(conn).unwrap().fqn; BLOG_FQN_CACHE .lock() .unwrap() .insert(self.blog_id, blog_fqn.clone()); blog_fqn } pub fn count_likes(&self, conn: &Connection) -> Result { use crate::schema::likes; likes::table .filter(likes::post_id.eq(self.id)) .count() .get_result(conn) .map_err(Error::from) } pub fn count_reshares(&self, conn: &Connection) -> Result { use crate::schema::reshares; reshares::table .filter(reshares::post_id.eq(self.id)) .count() .get_result(conn) .map_err(Error::from) } pub fn get_receivers_urls(&self, conn: &Connection) -> Result> { Ok(self .get_authors(conn)? .into_iter() .filter_map(|a| a.get_followers(conn).ok()) .fold(vec![], |mut acc, f| { for x in f { acc.push(x.ap_url); } acc })) } pub fn to_activity(&self, conn: &Connection) -> Result { let cc = self.get_receivers_urls(conn)?; let to = vec![PUBLIC_VISIBILITY.to_string()]; let mut mentions_json = Mention::list_for_post(conn, self.id)? .into_iter() .map(|m| json!(m.to_activity(conn).ok())) .collect::>(); let mut tags_json = Tag::for_post(conn, self.id)? .into_iter() .map(|t| json!(t.to_activity().ok())) .collect::>(); mentions_json.append(&mut tags_json); let mut article = ApObject::new(Article::new()); article.set_name(self.title.clone()); article.set_id(self.ap_url.parse::()?); let mut authors = self .get_authors(conn)? .into_iter() .filter_map(|x| x.ap_url.parse::().ok()) .collect::>(); authors.push(self.get_blog(conn)?.ap_url.parse::()?); // add the blog URL here too article.set_many_attributed_tos(authors); article.set_content(self.content.get().clone()); let source = AnyBase::from_arbitrary_json(serde_json::json!({ "content": self.source, "mediaType": "text/markdown", }))?; article.set_source(source); article.set_published( OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos().into()) .expect("OffsetDateTime"), ); article.set_summary(&*self.subtitle); article.set_many_tags( mentions_json .iter() .filter_map(|mention_json| AnyBase::from_arbitrary_json(mention_json).ok()), ); if let Some(media_id) = self.cover_id { let media = Media::get(conn, media_id)?; let mut cover = Image::new(); cover.set_url(media.url()?); if media.sensitive { cover.set_summary(media.content_warning.unwrap_or_default()); } cover.set_content(media.alt_text); cover.set_many_attributed_tos(vec![User::get(conn, media.owner_id)? .ap_url .parse::()?]); article.set_icon(cover.into_any_base()?); } article.set_url(self.ap_url.parse::()?); article.set_many_tos( to.into_iter() .filter_map(|to| to.parse::().ok()) .collect::>(), ); article.set_many_ccs( cc.into_iter() .filter_map(|cc| cc.parse::().ok()) .collect::>(), ); let license = Licensed { license: Some(self.license.clone()), }; Ok(LicensedArticle::new(article, license)) } pub fn create_activity(&self, conn: &Connection) -> Result { let article = self.to_activity(conn)?; let to = article.to().ok_or(Error::MissingApProperty)?.clone(); let cc = article.cc().ok_or(Error::MissingApProperty)?.clone(); let mut act = Create::new( self.get_authors(conn)?[0].ap_url.parse::()?, Base::retract(article)?.into_generic()?, ); act.set_id(format!("{}/activity", self.ap_url).parse::()?); act.set_many_tos(to); act.set_many_ccs(cc); Ok(act) } pub fn update_activity(&self, conn: &Connection) -> Result { let article = self.to_activity(conn)?; let to = article.to().ok_or(Error::MissingApProperty)?.clone(); let cc = article.cc().ok_or(Error::MissingApProperty)?.clone(); let mut act = Update::new( self.get_authors(conn)?[0].ap_url.parse::()?, Base::retract(article)?.into_generic()?, ); act.set_id( format!("{}/update-{}", self.ap_url, Utc::now().timestamp()).parse::()?, ); act.set_many_tos(to); act.set_many_ccs(cc); Ok(act) } pub fn update_mentions(&self, conn: &Connection, mentions: Vec) -> Result<()> { let mentions = mentions .into_iter() .map(|m| { ( m.href() .and_then(|ap_url| User::find_by_ap_url(conn, ap_url.as_ref()).ok()) .map(|u| u.id), m, ) }) .filter_map(|(id, m)| id.map(|id| (m, id))) .collect::>(); let old_mentions = Mention::list_for_post(conn, self.id)?; let old_user_mentioned = old_mentions .iter() .map(|m| m.mentioned_id) .collect::>(); for (m, id) in &mentions { if !old_user_mentioned.contains(id) { Mention::from_activity(conn, m, self.id, true, true)?; } } let new_mentions = mentions .into_iter() .map(|(_m, id)| id) .collect::>(); for m in old_mentions .iter() .filter(|m| !new_mentions.contains(&m.mentioned_id)) { m.delete(conn)?; } Ok(()) } pub fn update_tags(&self, conn: &Connection, tags: Vec) -> Result<()> { let tags_name = tags .iter() .filter_map(|t| t.name.as_ref().map(|name| name.as_str().to_string())) .collect::>(); let old_tags = Tag::for_post(conn, self.id)?; let old_tags_name = old_tags .iter() .filter_map(|tag| { if !tag.is_hashtag { Some(tag.tag.clone()) } else { None } }) .collect::>(); for t in tags { if !t .name .as_ref() .map(|n| old_tags_name.contains(n.as_str())) .unwrap_or(true) { Tag::from_activity(conn, &t, self.id, false)?; } } for ot in old_tags.iter().filter(|t| !t.is_hashtag) { if !tags_name.contains(&ot.tag) { ot.delete(conn)?; } } Ok(()) } pub fn update_hashtags(&self, conn: &Connection, tags: Vec) -> Result<()> { let tags_name = tags .iter() .filter_map(|t| t.name.as_ref().map(|name| name.as_str().to_string())) .collect::>(); let old_tags = Tag::for_post(conn, self.id)?; let old_tags_name = old_tags .iter() .filter_map(|tag| { if tag.is_hashtag { Some(tag.tag.clone()) } else { None } }) .collect::>(); for t in tags { if !t .name .as_ref() .map(|n| old_tags_name.contains(n.as_str())) .unwrap_or(true) { Tag::from_activity(conn, &t, self.id, true)?; } } for ot in old_tags.into_iter().filter(|t| t.is_hashtag) { if !tags_name.contains(&ot.tag) { ot.delete(conn)?; } } Ok(()) } pub fn url(&self, conn: &Connection) -> Result { let blog = self.get_blog(conn)?; Ok(format!("/~/{}/{}", blog.fqn, self.slug)) } pub fn cover_url(&self, conn: &Connection) -> Option { self.cover_id .and_then(|i| Media::get(conn, i).ok()) .and_then(|c| c.url().ok()) } pub fn build_delete(&self, conn: &Connection) -> Result { let mut tombstone = Tombstone::new(); tombstone.set_id(self.ap_url.parse()?); let mut act = Delete::new( self.get_authors(conn)?[0] .clone() .into_id() .parse::()?, Base::retract(tombstone)?.into_generic()?, ); act.set_id(format!("{}#delete", self.ap_url).parse()?); act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::()?]); Ok(act) } fn publish_published(&self) { POST_CHAN.tell( Publish { msg: PostPublished(Arc::new(self.clone())), topic: "post.published".into(), }, None, ) } fn publish_updated(&self) { POST_CHAN.tell( Publish { msg: PostUpdated(Arc::new(self.clone())), topic: "post.updated".into(), }, None, ) } fn publish_deleted(&self) { POST_CHAN.tell( Publish { msg: PostDeleted(Arc::new(self.clone())), topic: "post.deleted".into(), }, None, ) } } impl FromId for Post { type Error = Error; type Object = LicensedArticle; fn from_db(conn: &DbConn, id: &str) -> Result { Self::find_by_ap_url(conn, id) } fn from_activity(conn: &DbConn, article: LicensedArticle) -> Result { let license = article.ext_one.license.unwrap_or_default(); let article = article.inner; let (blog, authors) = article .ap_object_ref() .attributed_to() .ok_or(Error::MissingApProperty)? .iter() .fold((None, vec![]), |(blog, mut authors), link| { if let Some(url) = link.id() { match User::from_id(conn, url.as_str(), None, CONFIG.proxy()) { Ok(u) => { authors.push(u); (blog, authors) } Err(_) => ( blog.or_else(|| { Blog::from_id(conn, url.as_str(), None, CONFIG.proxy()).ok() }), authors, ), } } else { // logically, url possible to be an object without id proprty like {"type":"Person", "name":"Sally"} but we ignore the case (blog, authors) } }); let cover = article.icon().and_then(|icon| { icon.iter().next().and_then(|img| { let image = img.to_owned().extend::().ok()??; Media::from_activity(conn, &image).ok().map(|m| m.id) }) }); let title = article .name() .and_then(|name| name.to_as_string()) .ok_or(Error::MissingApProperty)?; let id = AnyBase::from_extended(article.clone()) // FIXME: Don't clone .ok() .ok_or(Error::MissingApProperty)? .id() .map(|id| id.to_string()); let ap_url = article .url() .and_then(|url| url.to_as_uri().or(id)) .ok_or(Error::MissingApProperty)?; let source = article .source() .and_then(|s| { serde_json::to_value(s).ok().and_then(|obj| { if !obj.is_object() { return None; } obj.get("content") .and_then(|content| content.as_str().map(|c| c.to_string())) }) }) .unwrap_or_default(); let post = Post::from_db(conn, &ap_url) .and_then(|mut post| { let mut updated = false; let slug = Self::slug(&title); let content = SafeString::new( &article .content() .and_then(|content| content.to_as_string()) .ok_or(Error::MissingApProperty)?, ); let subtitle = article .summary() .and_then(|summary| summary.to_as_string()) .ok_or(Error::MissingApProperty)?; if post.slug != slug { post.slug = slug.to_string(); updated = true; } if post.title != title { post.title = title.clone(); updated = true; } if post.content != content { post.content = content; updated = true; } if post.license != license { post.license = license.clone(); updated = true; } if post.subtitle != subtitle { post.subtitle = subtitle; updated = true; } if post.source != source { post.source = source.clone(); updated = true; } if post.cover_id != cover { post.cover_id = cover; updated = true; } if updated { post.update(conn)?; } Ok(post) }) .or_else(|_| { Post::insert( conn, NewPost { blog_id: blog.ok_or(Error::NotFound)?.id, slug: Self::slug(&title).to_string(), title, content: SafeString::new( &article .content() .and_then(|content| content.to_as_string()) .ok_or(Error::MissingApProperty)?, ), published: true, license, // FIXME: This is wrong: with this logic, we may use the display URL as the AP ID. We need two different fields ap_url, creation_date: article.published().map(|published| { let timestamp_secs = published.unix_timestamp(); let timestamp_nanos = published.unix_timestamp_nanos() - (timestamp_secs as i128) * 1000i128 * 1000i128 * 1000i128; NaiveDateTime::from_timestamp(timestamp_secs, timestamp_nanos as u32) }), subtitle: article .summary() .and_then(|summary| summary.to_as_string()) .ok_or(Error::MissingApProperty)?, source, cover_id: cover, }, ) .and_then(|post| { for author in authors { PostAuthor::insert( conn, NewPostAuthor { post_id: post.id, author_id: author.id, }, )?; } Ok(post) }) })?; // save mentions and tags let mut hashtags = md_to_html(&post.source, None, false, None) .2 .into_iter() .collect::>(); if let Some(tags) = article.tag() { for tag in tags.iter() { tag.clone() .extend::() // FIXME: Don't clone .map(|mention| { mention.map(|m| Mention::from_activity(conn, &m, post.id, true, true)) }) .ok(); tag.clone() .extend::() // FIXME: Don't clone .map(|hashtag| { hashtag.and_then(|t| { let tag_name = t.name.clone()?.as_str().to_string(); Tag::from_activity(conn, &t, post.id, hashtags.remove(&tag_name)).ok() }) }) .ok(); } } Timeline::add_to_all_timelines(conn, &post, Kind::Original)?; Ok(post) } fn get_sender() -> &'static dyn Signer { Instance::get_local_instance_user().expect("Failed to get local instance user") } } impl AsObject for Post { type Error = Error; type Output = Self; fn activity(self, _conn: &DbConn, _actor: User, _id: &str) -> Result { // TODO: check that _actor is actually one of the author? Ok(self) } } impl AsObject for Post { type Error = Error; type Output = (); fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result { let can_delete = self .get_authors(conn)? .into_iter() .any(|a| actor.id == a.id); if can_delete { self.delete(conn).map(|_| ()) } else { Err(Error::Unauthorized) } } } pub struct PostUpdate { pub ap_url: String, pub title: Option, pub subtitle: Option, pub content: Option, pub cover: Option, pub source: Option, pub license: Option, pub tags: Option, } impl FromId for PostUpdate { type Error = Error; type Object = LicensedArticle; fn from_db(_: &DbConn, _: &str) -> Result { // Always fail because we always want to deserialize the AP object Err(Error::NotFound) } fn from_activity(conn: &DbConn, updated: Self::Object) -> Result { let mut post_update = PostUpdate { ap_url: updated .ap_object_ref() .id_unchecked() .ok_or(Error::MissingApProperty)? .to_string(), title: updated .ap_object_ref() .name() .and_then(|name| name.to_as_string()), subtitle: updated .ap_object_ref() .summary() .and_then(|summary| summary.to_as_string()), content: updated .ap_object_ref() .content() .and_then(|content| content.to_as_string()), cover: None, source: updated.source().and_then(|s| { serde_json::to_value(s).ok().and_then(|obj| { if !obj.is_object() { return None; } obj.get("content") .and_then(|content| content.as_str().map(|c| c.to_string())) }) }), license: None, tags: updated .tag() .and_then(|tags| serde_json::to_value(tags).ok()), }; post_update.cover = updated.ap_object_ref().icon().and_then(|img| { img.iter() .next() .and_then(|img| { img.clone() .extend::() .map(|img| img.and_then(|img| Media::from_activity(conn, &img).ok())) .ok() }) .and_then(|m| m.map(|m| m.id)) }); post_update.license = updated.ext_one.license; Ok(post_update) } fn get_sender() -> &'static dyn Signer { Instance::get_local_instance_user().expect("Failed to local instance user") } } impl AsObject for PostUpdate { type Error = Error; type Output = (); fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> { let mut post = Post::from_id(conn, &self.ap_url, None, CONFIG.proxy()).map_err(|(_, e)| e)?; if !post.is_author(conn, actor.id)? { // TODO: maybe the author was added in the meantime return Err(Error::Unauthorized); } if let Some(title) = self.title { post.slug = Post::slug(&title).to_string(); post.title = title; } if let Some(content) = self.content { post.content = SafeString::new(&content); } if let Some(subtitle) = self.subtitle { post.subtitle = subtitle; } post.cover_id = self.cover; if let Some(source) = self.source { post.source = source; } if let Some(license) = self.license { post.license = license; } let mut txt_hashtags = md_to_html(&post.source, None, false, None) .2 .into_iter() .collect::>(); if let Some(serde_json::Value::Array(mention_tags)) = self.tags { let mut mentions = vec![]; let mut tags = vec![]; let mut hashtags = vec![]; for tag in mention_tags { serde_json::from_value::(tag.clone()) .map(|m| mentions.push(m)) .ok(); serde_json::from_value::(tag.clone()) .map_err(Error::from) .and_then(|t| { let tag_name = t.name.as_ref().ok_or(Error::MissingApProperty)?; let tag_name_str = tag_name .as_xsd_string() .or_else(|| tag_name.as_rdf_lang_string().map(|rls| &*rls.value)) .ok_or(Error::MissingApProperty)?; if txt_hashtags.remove(tag_name_str) { hashtags.push(t); } else { tags.push(t); } Ok(()) }) .ok(); } post.update_mentions(conn, mentions)?; post.update_tags(conn, tags)?; post.update_hashtags(conn, hashtags)?; } post.update(conn)?; Ok(()) } } impl IntoId for Post { fn into_id(self) -> Id { Id::new(self.ap_url) } } #[derive(Clone, Debug)] pub enum PostEvent { PostPublished(Arc), PostUpdated(Arc), PostDeleted(Arc), } impl From for Arc { fn from(event: PostEvent) -> Self { use PostEvent::*; match event { PostPublished(post) => post, PostUpdated(post) => post, PostDeleted(post) => post, } } } #[cfg(test)] mod tests { use super::*; use crate::inbox::{inbox, tests::fill_database, InboxResult}; use crate::mentions::{Mention, NewMention}; use crate::safe_string::SafeString; use crate::tests::{db, format_datetime}; use assert_json_diff::assert_json_eq; use diesel::Connection; use serde_json::{json, to_value}; fn prepare_activity(conn: &DbConn) -> (Post, Mention, Vec, Vec, Vec) { let (posts, users, blogs) = fill_database(conn); let post = &posts[0]; let mentioned = &users[1]; let mention = Mention::insert( conn, NewMention { mentioned_id: mentioned.id, post_id: Some(post.id), comment_id: None, }, ) .unwrap(); (post.to_owned(), mention, posts, users, blogs) } // creates a post, get it's Create activity, delete the post, // "send" the Create to the inbox, and check it works #[test] fn self_federation() { let conn = &db(); conn.test_transaction::<_, (), _>(|| { let (_, users, blogs) = fill_database(conn); let post = Post::insert( conn, NewPost { blog_id: blogs[0].id, slug: "yo".into(), title: "Yo".into(), content: SafeString::new("Hello"), published: true, license: "WTFPL".to_string(), creation_date: None, ap_url: String::new(), // automatically updated when inserting subtitle: "Testing".into(), source: "Hello".into(), cover_id: None, }, ) .unwrap(); PostAuthor::insert( conn, NewPostAuthor { post_id: post.id, author_id: users[0].id, }, ) .unwrap(); let create = post.create_activity(conn).unwrap(); post.delete(conn).unwrap(); match inbox(conn, serde_json::to_value(create).unwrap()).unwrap() { InboxResult::Post(p) => { assert!(p.is_author(conn, users[0].id).unwrap()); assert_eq!(p.source, "Hello".to_owned()); assert_eq!(p.blog_id, blogs[0].id); assert_eq!(p.content, SafeString::new("Hello")); assert_eq!(p.subtitle, "Testing".to_owned()); assert_eq!(p.title, "Yo".to_owned()); } _ => panic!("Unexpected result"), }; Ok(()) }); } #[test] fn to_activity() { let conn = db(); conn.test_transaction::<_, Error, _>(|| { let (post, _mention, _posts, _users, _blogs) = prepare_activity(&conn); let act = post.to_activity(&conn)?; let expected = json!({ "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "cc": [], "content": "Hello", "id": "https://plu.me/~/BlogName/testing", "license": "WTFPL", "name": "Testing", "published": format_datetime(&post.creation_date), "source": { "content": "Hello", "mediaType": "text/markdown" }, "summary": "Bye", "tag": [ { "href": "https://plu.me/@/user/", "name": "@user", "type": "Mention" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Article", "url": "https://plu.me/~/BlogName/testing" }); assert_json_eq!(to_value(act)?, expected); Ok(()) }); } #[test] fn create_activity() { let conn = db(); conn.test_transaction::<_, Error, _>(|| { let (post, _mention, _posts, _users, _blogs) = prepare_activity(&conn); let act = post.create_activity(&conn)?; let expected = json!({ "actor": "https://plu.me/@/admin/", "cc": [], "id": "https://plu.me/~/BlogName/testing/activity", "object": { "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "cc": [], "content": "Hello", "id": "https://plu.me/~/BlogName/testing", "license": "WTFPL", "name": "Testing", "published": format_datetime(&post.creation_date), "source": { "content": "Hello", "mediaType": "text/markdown" }, "summary": "Bye", "tag": [ { "href": "https://plu.me/@/user/", "name": "@user", "type": "Mention" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Article", "url": "https://plu.me/~/BlogName/testing" }, "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Create" }); assert_json_eq!(to_value(act)?, expected); Ok(()) }); } #[test] fn update_activity() { let conn = db(); conn.test_transaction::<_, Error, _>(|| { let (post, _mention, _posts, _users, _blogs) = prepare_activity(&conn); let act = post.update_activity(&conn)?; let expected = json!({ "actor": "https://plu.me/@/admin/", "cc": [], "id": "https://plu.me/~/BlogName/testing/update-", "object": { "attributedTo": ["https://plu.me/@/admin/", "https://plu.me/~/BlogName/"], "cc": [], "content": "Hello", "id": "https://plu.me/~/BlogName/testing", "license": "WTFPL", "name": "Testing", "published": format_datetime(&post.creation_date), "source": { "content": "Hello", "mediaType": "text/markdown" }, "summary": "Bye", "tag": [ { "href": "https://plu.me/@/user/", "name": "@user", "type": "Mention" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Article", "url": "https://plu.me/~/BlogName/testing" }, "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Update" }); let actual = to_value(act)?; let id = actual["id"].to_string(); let (id_pre, id_post) = id.rsplit_once('-').unwrap(); assert_eq!(post.ap_url, "https://plu.me/~/BlogName/testing"); assert_eq!( id_pre, to_value("\"https://plu.me/~/BlogName/testing/update") .unwrap() .as_str() .unwrap() ); assert_eq!(id_post.len(), 11); assert_eq!( id_post.matches(char::is_numeric).collect::().len(), 10 ); for (key, value) in actual.as_object().unwrap().into_iter() { if key == "id" { continue; } assert_json_eq!(value, expected.get(key).unwrap()); } Ok(()) }); } #[test] fn build_delete() { let conn = db(); conn.test_transaction::<_, Error, _>(|| { let (post, _mention, _posts, _users, _blogs) = prepare_activity(&conn); let act = post.build_delete(&conn)?; let expected = json!({ "actor": "https://plu.me/@/admin/", "id": "https://plu.me/~/BlogName/testing#delete", "object": { "id": "https://plu.me/~/BlogName/testing", "type": "Tombstone" }, "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "type": "Delete" }); assert_json_eq!(to_value(act)?, expected); Ok(()) }); } }