diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index 130f6d635..919027636 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -840,6 +840,10 @@ pub fn generate_outbox_url(actor_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{actor_id}/outbox"))?.into()) } +pub fn generate_featured_url(actor_id: &DbUrl) -> Result { + Ok(Url::parse(&format!("{actor_id}/featured"))?.into()) +} + pub fn generate_moderators_url(community_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{community_id}/moderators"))?.into()) } diff --git a/crates/apub/assets/lemmy/activities/community/add_featured_post.json b/crates/apub/assets/lemmy/activities/community/add_featured_post.json new file mode 100644 index 000000000..f7e46ef58 --- /dev/null +++ b/crates/apub/assets/lemmy/activities/community/add_featured_post.json @@ -0,0 +1,14 @@ +{ + "cc": [ + "https://ds9.lemmy.ml/c/main" + ], + "id": "https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Add", + "actor": "https://ds9.lemmy.ml/u/lemmy_alpha", + "object": "https://ds9.lemmy.ml/post/2", + "target": "https://ds9.lemmy.ml/c/main/featured", + "audience": "https://ds9.lemmy.ml/c/main" +} \ No newline at end of file diff --git a/crates/apub/assets/lemmy/activities/community/lock_page.json b/crates/apub/assets/lemmy/activities/community/lock_page.json new file mode 100644 index 000000000..887d0f7e9 --- /dev/null +++ b/crates/apub/assets/lemmy/activities/community/lock_page.json @@ -0,0 +1,13 @@ +{ + "id": "http://lemmy-alpha:8541/activities/lock/cb48761d-9e8c-42ce-aacb-b4bbe6408db2", + "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": "http://lemmy-alpha:8541/post/2", + "cc": [ + "http://lemmy-alpha:8541/c/main" + ], + "type": "Lock", + "audience": "http://lemmy-alpha:8541/c/main" +} \ No newline at end of file diff --git a/crates/apub/assets/lemmy/activities/community/remove_featured_post.json b/crates/apub/assets/lemmy/activities/community/remove_featured_post.json new file mode 100644 index 000000000..762258202 --- /dev/null +++ b/crates/apub/assets/lemmy/activities/community/remove_featured_post.json @@ -0,0 +1,14 @@ +{ + "cc": [ + "https://ds9.lemmy.ml/c/main" + ], + "id": "https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Remove", + "actor": "https://ds9.lemmy.ml/u/lemmy_alpha", + "object": "https://ds9.lemmy.ml/post/2", + "target": "https://ds9.lemmy.ml/c/main/featured", + "audience": "https://ds9.lemmy.ml/c/main" +} diff --git a/crates/apub/assets/lemmy/activities/community/undo_lock_page.json b/crates/apub/assets/lemmy/activities/community/undo_lock_page.json new file mode 100644 index 000000000..91e6a4631 --- /dev/null +++ b/crates/apub/assets/lemmy/activities/community/undo_lock_page.json @@ -0,0 +1,25 @@ +{ + "id": "http://lemmy-alpha:8541/activities/undo/d6066719-d277-4964-9190-4d6faffac286", + "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": "http://lemmy-alpha:8541/post/2", + "cc": [ + "http://lemmy-alpha:8541/c/main" + ], + "type": "Lock", + "id": "http://lemmy-alpha:8541/activities/lock/08b6fd3e-9ef3-4358-a987-8bb641f3e2c3", + "audience": "http://lemmy-alpha:8541/c/main" + }, + "cc": [ + "http://lemmy-alpha:8541/c/main" + ], + "type": "Undo", + "audience": "http://lemmy-alpha:8541/c/main" +} diff --git a/crates/apub/assets/lemmy/collections/group_featured_posts.json b/crates/apub/assets/lemmy/collections/group_featured_posts.json new file mode 100644 index 000000000..c5bc5fd9f --- /dev/null +++ b/crates/apub/assets/lemmy/collections/group_featured_posts.json @@ -0,0 +1,51 @@ +{ + "type": "OrderedCollection", + "id": "https://ds9.lemmy.ml/c/main/featured", + "totalItems": 2, + "orderedItems": [ + { + "type": "Page", + "id": "https://ds9.lemmy.ml/post/2", + "attributedTo": "https://ds9.lemmy.ml/u/lemmy_alpha", + "to": [ + "https://ds9.lemmy.ml/c/main", + "https://www.w3.org/ns/activitystreams#Public" + ], + "name": "test 2", + "cc": [], + "mediaType": "text/html", + "attachment": [], + "commentsEnabled": true, + "sensitive": false, + "stickied": true, + "published": "2023-02-06T06:42:41.939437+00:00", + "language": { + "identifier": "de", + "name": "Deutsch" + }, + "audience": "https://ds9.lemmy.ml/c/main" + }, + { + "type": "Page", + "id": "https://ds9.lemmy.ml/post/1", + "attributedTo": "https://ds9.lemmy.ml/u/lemmy_alpha", + "to": [ + "https://ds9.lemmy.ml/c/main", + "https://www.w3.org/ns/activitystreams#Public" + ], + "name": "test 1", + "cc": [], + "mediaType": "text/html", + "attachment": [], + "commentsEnabled": true, + "sensitive": false, + "stickied": true, + "published": "2023-02-06T06:42:37.119567+00:00", + "language": { + "identifier": "de", + "name": "Deutsch" + }, + "audience": "https://ds9.lemmy.ml/c/main" + } + ] +} diff --git a/crates/apub/assets/lemmy/objects/group.json b/crates/apub/assets/lemmy/objects/group.json index d44cc79bd..10c46640c 100644 --- a/crates/apub/assets/lemmy/objects/group.json +++ b/crates/apub/assets/lemmy/objects/group.json @@ -21,6 +21,7 @@ "followers": "https://enterprise.lemmy.ml/c/tenforward/followers", "moderators": "https://enterprise.lemmy.ml/c/tenforward/moderators", "attributedTo": "https://enterprise.lemmy.ml/c/tenforward/moderators", + "featured": "https://enterprise.lemmy.ml/c/tenforward//featured", "postingRestrictedToMods": false, "endpoints": { "sharedInbox": "https://enterprise.lemmy.ml/inbox" diff --git a/crates/apub/assets/mastodon/collections/featured.json b/crates/apub/assets/mastodon/collections/featured.json new file mode 100644 index 000000000..fecc7bbed --- /dev/null +++ b/crates/apub/assets/mastodon/collections/featured.json @@ -0,0 +1,73 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "Hashtag": "as:Hashtag" + } + ], + "id": "https://mastodon.social/users/LemmyDev/collections/featured", + "type": "OrderedCollection", + "totalItems": 1, + "orderedItems": [ + { + "id": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2020-05-28T14:52:14Z", + "url": "https://mastodon.social/@LemmyDev/104246642906910728", + "attributedTo": "https://mastodon.social/users/LemmyDev", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://mastodon.social/users/LemmyDev/followers" + ], + "sensitive": false, + "atomUri": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728", + "inReplyToAtomUri": null, + "conversation": "tag:mastodon.social,2020-05-28:objectId=175451535:objectType=Conversation", + "content": "

Inaugural Post for Lemmy, a decentralized, easily self-hostable #reddit / link aggregator alternative,intended to work in the #fediverse:

https://github.com/LemmyNet/lemmy/

#activitypub

", + "contentMap": { + "en": "

Inaugural Post for Lemmy, a decentralized, easily self-hostable #reddit / link aggregator alternative, intended to work in the #fediverse:

https://github.com/LemmyNet/lemmy/

#activitypub

" + }, + "attachment": [], + "tag": [ + { + "type": "Hashtag", + "href": "https://mastodon.social/tags/reddit", + "name": "#reddit" + }, + { + "type": "Hashtag", + "href": "https://mastodon.social/tags/fediverse", + "name": "#fediverse" + }, + { + "type": "Hashtag", + "href": "https://mastodon.social/tags/activitypub", + "name": "#activitypub" + } + ], + "replies": { + "id": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies?min_id=104246644059085152&page=true", + "partOf": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies", + "items": [ + "https://mastodon.social/users/LemmyDev/statuses/104246644059085152" + ] + } + } + } + ] +} diff --git a/crates/apub/src/activities/community/add_mod.rs b/crates/apub/src/activities/community/add_mod.rs deleted file mode 100644 index 1bc31b48c..000000000 --- a/crates/apub/src/activities/community/add_mod.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::{ - activities::{ - community::send_activity_in_community, - generate_activity_id, - verify_add_remove_moderator_target, - verify_is_public, - verify_mod_action, - verify_person_in_community, - }, - activity_lists::AnnouncableActivities, - local_instance, - objects::{community::ApubCommunity, person::ApubPerson}, - protocol::{ - activities::community::{add_mod::AddMod, remove_mod::RemoveMod}, - InCommunity, - }, - ActorType, - SendActivity, -}; -use activitypub_federation::{ - core::object_id::ObjectId, - data::Data, - traits::{ActivityHandler, Actor}, -}; -use activitystreams_kinds::{activity::AddType, public}; -use lemmy_api_common::{ - community::{AddModToCommunity, AddModToCommunityResponse}, - context::LemmyContext, - utils::{generate_moderators_url, get_local_user_view_from_jwt}, -}; -use lemmy_db_schema::{ - source::{ - community::{Community, CommunityModerator, CommunityModeratorForm}, - moderator::{ModAddCommunity, ModAddCommunityForm}, - person::Person, - }, - traits::{Crud, Joinable}, -}; -use lemmy_utils::error::LemmyError; -use url::Url; - -impl AddMod { - #[tracing::instrument(skip_all)] - pub async fn send( - community: &ApubCommunity, - added_mod: &ApubPerson, - actor: &ApubPerson, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let id = generate_activity_id( - AddType::Add, - &context.settings().get_protocol_and_hostname(), - )?; - let add = AddMod { - actor: ObjectId::new(actor.actor_id()), - to: vec![public()], - object: ObjectId::new(added_mod.actor_id()), - target: generate_moderators_url(&community.actor_id)?.into(), - cc: vec![community.actor_id()], - kind: AddType::Add, - id: id.clone(), - audience: Some(ObjectId::new(community.actor_id())), - }; - - let activity = AnnouncableActivities::AddMod(add); - let inboxes = vec![added_mod.shared_inbox_or_inbox()]; - send_activity_in_community(activity, actor, community, inboxes, true, context).await - } -} - -#[async_trait::async_trait(?Send)] -impl ActivityHandler for AddMod { - type DataType = LemmyContext; - type Error = LemmyError; - - fn id(&self) -> &Url { - &self.id - } - - fn actor(&self) -> &Url { - self.actor.inner() - } - - #[tracing::instrument(skip_all)] - async fn verify( - &self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - verify_is_public(&self.to, &self.cc)?; - let community = self.community(context, request_counter).await?; - verify_person_in_community(&self.actor, &community, context, request_counter).await?; - verify_mod_action( - &self.actor, - self.object.inner(), - community.id, - context, - request_counter, - ) - .await?; - verify_add_remove_moderator_target(&self.target, &community)?; - Ok(()) - } - - #[tracing::instrument(skip_all)] - async fn receive( - self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - let community = self.community(context, request_counter).await?; - let new_mod = self - .object - .dereference(context, local_instance(context).await, request_counter) - .await?; - - // If we had to refetch the community while parsing the activity, then the new mod has already - // been added. Skip it here as it would result in a duplicate key error. - let new_mod_id = new_mod.id; - let moderated_communities = - CommunityModerator::get_person_moderated_communities(context.pool(), new_mod_id).await?; - if !moderated_communities.contains(&community.id) { - let form = CommunityModeratorForm { - community_id: community.id, - person_id: new_mod.id, - }; - CommunityModerator::join(context.pool(), &form).await?; - - // write mod log - let actor = self - .actor - .dereference(context, local_instance(context).await, request_counter) - .await?; - let form = ModAddCommunityForm { - mod_person_id: actor.id, - other_person_id: new_mod.id, - community_id: community.id, - removed: Some(false), - }; - ModAddCommunity::create(context.pool(), &form).await?; - } - // TODO: send websocket notification about added mod - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl SendActivity for AddModToCommunity { - type Response = AddModToCommunityResponse; - - async fn send_activity( - request: &Self, - _response: &Self::Response, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let local_user_view = - get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; - let community: ApubCommunity = Community::read(context.pool(), request.community_id) - .await? - .into(); - let updated_mod: ApubPerson = Person::read(context.pool(), request.person_id) - .await? - .into(); - if request.added { - AddMod::send( - &community, - &updated_mod, - &local_user_view.person.into(), - context, - ) - .await - } else { - RemoveMod::send( - &community, - &updated_mod, - &local_user_view.person.into(), - context, - ) - .await - } - } -} diff --git a/crates/apub/src/activities/community/collection_add.rs b/crates/apub/src/activities/community/collection_add.rs new file mode 100644 index 000000000..b45fa278e --- /dev/null +++ b/crates/apub/src/activities/community/collection_add.rs @@ -0,0 +1,256 @@ +use crate::{ + activities::{ + community::send_activity_in_community, + generate_activity_id, + verify_is_public, + verify_mod_action, + verify_person_in_community, + }, + activity_lists::AnnouncableActivities, + local_instance, + objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, + protocol::{ + activities::{ + community::{collection_add::CollectionAdd, collection_remove::CollectionRemove}, + create_or_update::page::CreateOrUpdatePage, + CreateOrUpdateType, + }, + InCommunity, + }, + ActorType, + SendActivity, +}; +use activitypub_federation::{ + core::object_id::ObjectId, + data::Data, + traits::{ActivityHandler, Actor}, +}; +use activitystreams_kinds::{activity::AddType, public}; +use lemmy_api_common::{ + community::{AddModToCommunity, AddModToCommunityResponse}, + context::LemmyContext, + post::{FeaturePost, PostResponse}, + utils::{generate_featured_url, generate_moderators_url, get_local_user_view_from_jwt}, +}; +use lemmy_db_schema::{ + impls::community::CollectionType, + source::{ + community::{Community, CommunityModerator, CommunityModeratorForm}, + moderator::{ModAddCommunity, ModAddCommunityForm}, + person::Person, + post::{Post, PostUpdateForm}, + }, + traits::{Crud, Joinable}, +}; +use lemmy_utils::error::LemmyError; +use url::Url; + +impl CollectionAdd { + #[tracing::instrument(skip_all)] + pub async fn send_add_mod( + community: &ApubCommunity, + added_mod: &ApubPerson, + actor: &ApubPerson, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let id = generate_activity_id( + AddType::Add, + &context.settings().get_protocol_and_hostname(), + )?; + let add = CollectionAdd { + actor: ObjectId::new(actor.actor_id()), + to: vec![public()], + object: added_mod.actor_id(), + target: generate_moderators_url(&community.actor_id)?.into(), + cc: vec![community.actor_id()], + kind: AddType::Add, + id: id.clone(), + audience: Some(ObjectId::new(community.actor_id())), + }; + + let activity = AnnouncableActivities::CollectionAdd(add); + let inboxes = vec![added_mod.shared_inbox_or_inbox()]; + send_activity_in_community(activity, actor, community, inboxes, true, context).await + } + + pub async fn send_add_featured_post( + community: &ApubCommunity, + featured_post: &ApubPost, + actor: &ApubPerson, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let id = generate_activity_id( + AddType::Add, + &context.settings().get_protocol_and_hostname(), + )?; + let add = CollectionAdd { + actor: ObjectId::new(actor.actor_id()), + to: vec![public()], + object: featured_post.ap_id.clone().into(), + target: generate_featured_url(&community.actor_id)?.into(), + cc: vec![community.actor_id()], + kind: AddType::Add, + id: id.clone(), + audience: Some(ObjectId::new(community.actor_id())), + }; + let activity = AnnouncableActivities::CollectionAdd(add); + send_activity_in_community(activity, actor, community, vec![], true, context).await + } +} + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for CollectionAdd { + type DataType = LemmyContext; + type Error = LemmyError; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + #[tracing::instrument(skip_all)] + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + verify_is_public(&self.to, &self.cc)?; + let community = self.community(context, request_counter).await?; + verify_person_in_community(&self.actor, &community, context, request_counter).await?; + verify_mod_action( + &self.actor, + &self.object, + community.id, + context, + request_counter, + ) + .await?; + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + let (community, collection_type) = + Community::get_by_collection_url(context.pool(), &self.target.into()).await?; + match collection_type { + CollectionType::Moderators => { + let new_mod = ObjectId::::new(self.object) + .dereference(context, local_instance(context).await, request_counter) + .await?; + + // If we had to refetch the community while parsing the activity, then the new mod has already + // been added. Skip it here as it would result in a duplicate key error. + let new_mod_id = new_mod.id; + let moderated_communities = + CommunityModerator::get_person_moderated_communities(context.pool(), new_mod_id).await?; + if !moderated_communities.contains(&community.id) { + let form = CommunityModeratorForm { + community_id: community.id, + person_id: new_mod.id, + }; + CommunityModerator::join(context.pool(), &form).await?; + + // write mod log + let actor = self + .actor + .dereference(context, local_instance(context).await, request_counter) + .await?; + let form = ModAddCommunityForm { + mod_person_id: actor.id, + other_person_id: new_mod.id, + community_id: community.id, + removed: Some(false), + }; + ModAddCommunity::create(context.pool(), &form).await?; + } + // TODO: send websocket notification about added mod + } + CollectionType::Featured => { + let post = ObjectId::::new(self.object) + .dereference(context, local_instance(context).await, request_counter) + .await?; + let form = PostUpdateForm::builder() + .featured_community(Some(true)) + .build(); + Post::update(context.pool(), post.id, &form).await?; + } + } + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl SendActivity for AddModToCommunity { + type Response = AddModToCommunityResponse; + + async fn send_activity( + request: &Self, + _response: &Self::Response, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let local_user_view = + get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; + let community: ApubCommunity = Community::read(context.pool(), request.community_id) + .await? + .into(); + let updated_mod: ApubPerson = Person::read(context.pool(), request.person_id) + .await? + .into(); + if request.added { + CollectionAdd::send_add_mod( + &community, + &updated_mod, + &local_user_view.person.into(), + context, + ) + .await + } else { + CollectionRemove::send_remove_mod( + &community, + &updated_mod, + &local_user_view.person.into(), + context, + ) + .await + } + } +} + +#[async_trait::async_trait(?Send)] +impl SendActivity for FeaturePost { + type Response = PostResponse; + + async fn send_activity( + request: &Self, + response: &Self::Response, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let local_user_view = + get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; + // Deprecated, for backwards compatibility with 0.17 + CreateOrUpdatePage::send( + &response.post_view.post, + local_user_view.person.id, + CreateOrUpdateType::Update, + context, + ) + .await?; + let community = Community::read(context.pool(), response.post_view.community.id) + .await? + .into(); + let post = response.post_view.post.clone().into(); + let person = local_user_view.person.into(); + if request.featured { + CollectionAdd::send_add_featured_post(&community, &post, &person, context).await + } else { + CollectionRemove::send_remove_featured_post(&community, &post, &person, context).await + } + } +} diff --git a/crates/apub/src/activities/community/collection_remove.rs b/crates/apub/src/activities/community/collection_remove.rs new file mode 100644 index 000000000..d804e65db --- /dev/null +++ b/crates/apub/src/activities/community/collection_remove.rs @@ -0,0 +1,171 @@ +use crate::{ + activities::{ + community::send_activity_in_community, + generate_activity_id, + verify_is_public, + verify_mod_action, + verify_person_in_community, + }, + activity_lists::AnnouncableActivities, + local_instance, + objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, + protocol::{activities::community::collection_remove::CollectionRemove, InCommunity}, + ActorType, +}; +use activitypub_federation::{ + core::object_id::ObjectId, + data::Data, + traits::{ActivityHandler, Actor}, +}; +use activitystreams_kinds::{activity::RemoveType, public}; +use lemmy_api_common::{ + context::LemmyContext, + utils::{generate_featured_url, generate_moderators_url}, +}; +use lemmy_db_schema::{ + impls::community::CollectionType, + source::{ + community::{Community, CommunityModerator, CommunityModeratorForm}, + moderator::{ModAddCommunity, ModAddCommunityForm}, + post::{Post, PostUpdateForm}, + }, + traits::{Crud, Joinable}, +}; +use lemmy_utils::error::LemmyError; +use url::Url; + +impl CollectionRemove { + #[tracing::instrument(skip_all)] + pub async fn send_remove_mod( + community: &ApubCommunity, + removed_mod: &ApubPerson, + actor: &ApubPerson, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let id = generate_activity_id( + RemoveType::Remove, + &context.settings().get_protocol_and_hostname(), + )?; + let remove = CollectionRemove { + actor: ObjectId::new(actor.actor_id()), + to: vec![public()], + object: ObjectId::new(removed_mod.actor_id()), + target: generate_moderators_url(&community.actor_id)?.into(), + id: id.clone(), + cc: vec![community.actor_id()], + kind: RemoveType::Remove, + audience: Some(ObjectId::new(community.actor_id())), + }; + + let activity = AnnouncableActivities::CollectionRemove(remove); + let inboxes = vec![removed_mod.shared_inbox_or_inbox()]; + send_activity_in_community(activity, actor, community, inboxes, true, context).await + } + + pub async fn send_remove_featured_post( + community: &ApubCommunity, + featured_post: &ApubPost, + actor: &ApubPerson, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let id = generate_activity_id( + RemoveType::Remove, + &context.settings().get_protocol_and_hostname(), + )?; + let remove = CollectionRemove { + actor: ObjectId::new(actor.actor_id()), + to: vec![public()], + object: featured_post.ap_id.clone().into(), + target: generate_featured_url(&community.actor_id)?.into(), + cc: vec![community.actor_id()], + kind: RemoveType::Remove, + id: id.clone(), + audience: Some(ObjectId::new(community.actor_id())), + }; + let activity = AnnouncableActivities::CollectionRemove(remove); + send_activity_in_community(activity, actor, community, vec![], true, context).await + } +} + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for CollectionRemove { + type DataType = LemmyContext; + type Error = LemmyError; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + #[tracing::instrument(skip_all)] + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + verify_is_public(&self.to, &self.cc)?; + let community = self.community(context, request_counter).await?; + verify_person_in_community(&self.actor, &community, context, request_counter).await?; + verify_mod_action( + &self.actor, + self.object.inner(), + community.id, + context, + request_counter, + ) + .await?; + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + let (community, collection_type) = + Community::get_by_collection_url(context.pool(), &self.target.into()).await?; + match collection_type { + CollectionType::Moderators => { + let remove_mod = self + .object + .dereference(context, local_instance(context).await, request_counter) + .await?; + + let form = CommunityModeratorForm { + community_id: community.id, + person_id: remove_mod.id, + }; + CommunityModerator::leave(context.pool(), &form).await?; + + // write mod log + let actor = self + .actor + .dereference(context, local_instance(context).await, request_counter) + .await?; + let form = ModAddCommunityForm { + mod_person_id: actor.id, + other_person_id: remove_mod.id, + community_id: community.id, + removed: Some(true), + }; + ModAddCommunity::create(context.pool(), &form).await?; + + // TODO: send websocket notification about removed mod + } + CollectionType::Featured => { + let post = ObjectId::::new(self.object) + .dereference(context, local_instance(context).await, request_counter) + .await?; + let form = PostUpdateForm::builder() + .featured_community(Some(false)) + .build(); + Post::update(context.pool(), post.id, &form).await?; + } + } + Ok(()) + } +} diff --git a/crates/apub/src/activities/community/lock_page.rs b/crates/apub/src/activities/community/lock_page.rs new file mode 100644 index 000000000..8caf3bfb1 --- /dev/null +++ b/crates/apub/src/activities/community/lock_page.rs @@ -0,0 +1,200 @@ +use crate::{ + activities::{ + check_community_deleted_or_removed, + community::send_activity_in_community, + generate_activity_id, + verify_is_public, + verify_mod_action, + verify_person_in_community, + }, + activity_lists::AnnouncableActivities, + local_instance, + protocol::{ + activities::{ + community::lock_page::{LockPage, LockType, UndoLockPage}, + create_or_update::page::CreateOrUpdatePage, + CreateOrUpdateType, + }, + InCommunity, + }, + SendActivity, +}; +use activitypub_federation::{core::object_id::ObjectId, data::Data, traits::ActivityHandler}; +use activitystreams_kinds::{activity::UndoType, public}; +use lemmy_api_common::{ + context::LemmyContext, + post::{LockPost, PostResponse}, + utils::get_local_user_view_from_jwt, +}; +use lemmy_db_schema::{ + source::{ + community::Community, + post::{Post, PostUpdateForm}, + }, + traits::Crud, +}; +use lemmy_utils::error::LemmyError; +use url::Url; + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for LockPage { + type DataType = LemmyContext; + type Error = LemmyError; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), Self::Error> { + verify_is_public(&self.to, &self.cc)?; + let community = self.community(context, request_counter).await?; + verify_person_in_community(&self.actor, &community, context, request_counter).await?; + check_community_deleted_or_removed(&community)?; + verify_mod_action( + &self.actor, + self.object.inner(), + community.id, + context, + request_counter, + ) + .await?; + Ok(()) + } + + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), Self::Error> { + let form = PostUpdateForm::builder().locked(Some(true)).build(); + let post = self + .object + .dereference(context, local_instance(context).await, request_counter) + .await?; + Post::update(context.pool(), post.id, &form).await?; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for UndoLockPage { + type DataType = LemmyContext; + type Error = LemmyError; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), Self::Error> { + verify_is_public(&self.to, &self.cc)?; + let community = self.community(context, request_counter).await?; + verify_person_in_community(&self.actor, &community, context, request_counter).await?; + check_community_deleted_or_removed(&community)?; + verify_mod_action( + &self.actor, + self.object.object.inner(), + community.id, + context, + request_counter, + ) + .await?; + Ok(()) + } + + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), Self::Error> { + let form = PostUpdateForm::builder().locked(Some(false)).build(); + let post = self + .object + .object + .dereference(context, local_instance(context).await, request_counter) + .await?; + Post::update(context.pool(), post.id, &form).await?; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl SendActivity for LockPost { + type Response = PostResponse; + + async fn send_activity( + request: &Self, + response: &Self::Response, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let local_user_view = + get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; + // For backwards compat with 0.17 + CreateOrUpdatePage::send( + &response.post_view.post, + local_user_view.person.id, + CreateOrUpdateType::Update, + context, + ) + .await?; + let id = generate_activity_id( + LockType::Lock, + &context.settings().get_protocol_and_hostname(), + )?; + let community_id: Url = response.post_view.community.actor_id.clone().into(); + let actor = ObjectId::new(local_user_view.person.actor_id.clone()); + let lock = LockPage { + actor, + to: vec![public()], + object: ObjectId::new(response.post_view.post.ap_id.clone()), + cc: vec![community_id.clone()], + kind: LockType::Lock, + id, + audience: Some(ObjectId::new(community_id)), + }; + let activity = if request.locked { + AnnouncableActivities::LockPost(lock) + } else { + let id = generate_activity_id( + UndoType::Undo, + &context.settings().get_protocol_and_hostname(), + )?; + let undo = UndoLockPage { + actor: lock.actor.clone(), + to: vec![public()], + cc: lock.cc.clone(), + kind: UndoType::Undo, + id, + audience: lock.audience.clone(), + object: lock, + }; + AnnouncableActivities::UndoLockPost(undo) + }; + let community = Community::read(context.pool(), response.post_view.community.id).await?; + send_activity_in_community( + activity, + &local_user_view.person.into(), + &community.into(), + vec![], + true, + context, + ) + .await?; + Ok(()) + } +} diff --git a/crates/apub/src/activities/community/mod.rs b/crates/apub/src/activities/community/mod.rs index 4d35c9d56..226d5d643 100644 --- a/crates/apub/src/activities/community/mod.rs +++ b/crates/apub/src/activities/community/mod.rs @@ -1,19 +1,19 @@ use crate::{ activities::send_lemmy_activity, activity_lists::AnnouncableActivities, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::activities::community::announce::AnnounceActivity, }; -use activitypub_federation::{core::object_id::ObjectId, traits::Actor}; +use activitypub_federation::traits::Actor; use lemmy_api_common::context::LemmyContext; use lemmy_db_schema::source::person::PersonFollower; use lemmy_utils::error::LemmyError; use url::Url; -pub mod add_mod; pub mod announce; -pub mod remove_mod; +pub mod collection_add; +pub mod collection_remove; +pub mod lock_page; pub mod report; pub mod update; @@ -62,15 +62,3 @@ pub(crate) async fn send_activity_in_community( Ok(()) } - -#[tracing::instrument(skip_all)] -pub(crate) async fn get_community_from_moderators_url( - moderators: &Url, - context: &LemmyContext, - request_counter: &mut i32, -) -> Result { - let community_id = Url::parse(&moderators.to_string().replace("/moderators", ""))?; - ObjectId::new(community_id) - .dereference(context, local_instance(context).await, request_counter) - .await -} diff --git a/crates/apub/src/activities/community/remove_mod.rs b/crates/apub/src/activities/community/remove_mod.rs deleted file mode 100644 index 9bd8c4ec2..000000000 --- a/crates/apub/src/activities/community/remove_mod.rs +++ /dev/null @@ -1,130 +0,0 @@ -use crate::{ - activities::{ - community::send_activity_in_community, - generate_activity_id, - verify_add_remove_moderator_target, - verify_is_public, - verify_mod_action, - verify_person_in_community, - }, - activity_lists::AnnouncableActivities, - local_instance, - objects::{community::ApubCommunity, person::ApubPerson}, - protocol::{activities::community::remove_mod::RemoveMod, InCommunity}, - ActorType, -}; -use activitypub_federation::{ - core::object_id::ObjectId, - data::Data, - traits::{ActivityHandler, Actor}, -}; -use activitystreams_kinds::{activity::RemoveType, public}; -use lemmy_api_common::{context::LemmyContext, utils::generate_moderators_url}; -use lemmy_db_schema::{ - source::{ - community::{CommunityModerator, CommunityModeratorForm}, - moderator::{ModAddCommunity, ModAddCommunityForm}, - }, - traits::{Crud, Joinable}, -}; -use lemmy_utils::error::LemmyError; -use url::Url; - -impl RemoveMod { - #[tracing::instrument(skip_all)] - pub async fn send( - community: &ApubCommunity, - removed_mod: &ApubPerson, - actor: &ApubPerson, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let id = generate_activity_id( - RemoveType::Remove, - &context.settings().get_protocol_and_hostname(), - )?; - let remove = RemoveMod { - actor: ObjectId::new(actor.actor_id()), - to: vec![public()], - object: ObjectId::new(removed_mod.actor_id()), - target: generate_moderators_url(&community.actor_id)?.into(), - id: id.clone(), - cc: vec![community.actor_id()], - kind: RemoveType::Remove, - audience: Some(ObjectId::new(community.actor_id())), - }; - - let activity = AnnouncableActivities::RemoveMod(remove); - let inboxes = vec![removed_mod.shared_inbox_or_inbox()]; - send_activity_in_community(activity, actor, community, inboxes, true, context).await - } -} - -#[async_trait::async_trait(?Send)] -impl ActivityHandler for RemoveMod { - type DataType = LemmyContext; - type Error = LemmyError; - - fn id(&self) -> &Url { - &self.id - } - - fn actor(&self) -> &Url { - self.actor.inner() - } - - #[tracing::instrument(skip_all)] - async fn verify( - &self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - verify_is_public(&self.to, &self.cc)?; - let community = self.community(context, request_counter).await?; - verify_person_in_community(&self.actor, &community, context, request_counter).await?; - verify_mod_action( - &self.actor, - self.object.inner(), - community.id, - context, - request_counter, - ) - .await?; - verify_add_remove_moderator_target(&self.target, &community)?; - Ok(()) - } - - #[tracing::instrument(skip_all)] - async fn receive( - self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - let community = self.community(context, request_counter).await?; - let remove_mod = self - .object - .dereference(context, local_instance(context).await, request_counter) - .await?; - - let form = CommunityModeratorForm { - community_id: community.id, - person_id: remove_mod.id, - }; - CommunityModerator::leave(context.pool(), &form).await?; - - // write mod log - let actor = self - .actor - .dereference(context, local_instance(context).await, request_counter) - .await?; - let form = ModAddCommunityForm { - mod_person_id: actor.id, - other_person_id: remove_mod.id, - community_id: community.id, - removed: Some(true), - }; - ModAddCommunity::create(context.pool(), &form).await?; - - // TODO: send websocket notification about removed mod - Ok(()) - } -} diff --git a/crates/apub/src/activities/create_or_update/post.rs b/crates/apub/src/activities/create_or_update/post.rs index 69eae583c..d2e8e76c7 100644 --- a/crates/apub/src/activities/create_or_update/post.rs +++ b/crates/apub/src/activities/create_or_update/post.rs @@ -25,8 +25,7 @@ use activitypub_federation::{ use activitystreams_kinds::public; use lemmy_api_common::{ context::LemmyContext, - post::{CreatePost, EditPost, FeaturePost, LockPost, PostResponse}, - utils::get_local_user_view_from_jwt, + post::{CreatePost, EditPost, PostResponse}, websocket::{send::send_post_ws_message, UserOperationCrud}, }; use lemmy_db_schema::{ @@ -79,48 +78,6 @@ impl SendActivity for EditPost { } } -#[async_trait::async_trait(?Send)] -impl SendActivity for LockPost { - type Response = PostResponse; - - async fn send_activity( - request: &Self, - response: &Self::Response, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let local_user_view = - get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; - CreateOrUpdatePage::send( - &response.post_view.post, - local_user_view.person.id, - CreateOrUpdateType::Update, - context, - ) - .await - } -} - -#[async_trait::async_trait(?Send)] -impl SendActivity for FeaturePost { - type Response = PostResponse; - - async fn send_activity( - request: &Self, - response: &Self::Response, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let local_user_view = - get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; - CreateOrUpdatePage::send( - &response.post_view.post, - local_user_view.person.id, - CreateOrUpdateType::Update, - context, - ) - .await - } -} - impl CreateOrUpdatePage { pub(crate) async fn new( post: ApubPost, @@ -145,7 +102,7 @@ impl CreateOrUpdatePage { } #[tracing::instrument(skip_all)] - async fn send( + pub(crate) async fn send( post: &Post, person_id: PersonId, kind: CreateOrUpdateType, diff --git a/crates/apub/src/activities/deletion/mod.rs b/crates/apub/src/activities/deletion/mod.rs index dae70dc3d..c17dcbfc2 100644 --- a/crates/apub/src/activities/deletion/mod.rs +++ b/crates/apub/src/activities/deletion/mod.rs @@ -76,7 +76,7 @@ impl SendActivity for DeletePost { let local_user_view = get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; let community = Community::read(context.pool(), response.post_view.community.id).await?; - let deletable = DeletableObjects::Post(Box::new(response.post_view.post.clone().into())); + let deletable = DeletableObjects::Post(response.post_view.post.clone().into()); send_apub_delete_in_community( local_user_view.person, community, @@ -101,7 +101,7 @@ impl SendActivity for RemovePost { let local_user_view = get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; let community = Community::read(context.pool(), response.post_view.community.id).await?; - let deletable = DeletableObjects::Post(Box::new(response.post_view.post.clone().into())); + let deletable = DeletableObjects::Post(response.post_view.post.clone().into()); send_apub_delete_in_community( local_user_view.person, community, @@ -126,8 +126,7 @@ impl SendActivity for DeleteComment { let community_id = response.comment_view.community.id; let community = Community::read(context.pool(), community_id).await?; let person = Person::read(context.pool(), response.comment_view.creator.id).await?; - let deletable = - DeletableObjects::Comment(Box::new(response.comment_view.comment.clone().into())); + let deletable = DeletableObjects::Comment(response.comment_view.comment.clone().into()); send_apub_delete_in_community(person, community, deletable, None, request.deleted, context) .await } @@ -146,7 +145,7 @@ impl SendActivity for RemoveComment { get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; let comment = Comment::read(context.pool(), request.comment_id).await?; let community = Community::read(context.pool(), response.comment_view.community.id).await?; - let deletable = DeletableObjects::Comment(Box::new(comment.into())); + let deletable = DeletableObjects::Comment(comment.into()); send_apub_delete_in_community( local_user_view.person, community, @@ -192,7 +191,7 @@ impl SendActivity for DeleteCommunity { let local_user_view = get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; let community = Community::read(context.pool(), request.community_id).await?; - let deletable = DeletableObjects::Community(Box::new(community.clone().into())); + let deletable = DeletableObjects::Community(community.clone().into()); send_apub_delete_in_community( local_user_view.person, community, @@ -217,7 +216,7 @@ impl SendActivity for RemoveCommunity { let local_user_view = get_local_user_view_from_jwt(&request.auth, context.pool(), context.secret()).await?; let community = Community::read(context.pool(), request.community_id).await?; - let deletable = DeletableObjects::Community(Box::new(community.clone().into())); + let deletable = DeletableObjects::Community(community.clone().into()); send_apub_delete_in_community( local_user_view.person, community, @@ -271,7 +270,7 @@ async fn send_apub_delete_private_message( let recipient_id = pm.recipient_id; let recipient: ApubPerson = Person::read(context.pool(), recipient_id).await?.into(); - let deletable = DeletableObjects::PrivateMessage(Box::new(pm.into())); + let deletable = DeletableObjects::PrivateMessage(pm.into()); let inbox = vec![recipient.shared_inbox_or_inbox()]; if deleted { let delete = Delete::new(actor, deletable, recipient.actor_id(), None, None, context)?; @@ -284,10 +283,10 @@ async fn send_apub_delete_private_message( } pub enum DeletableObjects { - Community(Box), - Comment(Box), - Post(Box), - PrivateMessage(Box), + Community(ApubCommunity), + Comment(ApubComment), + Post(ApubPost), + PrivateMessage(ApubPrivateMessage), } impl DeletableObjects { @@ -297,16 +296,16 @@ impl DeletableObjects { context: &LemmyContext, ) -> Result { if let Some(c) = ApubCommunity::read_from_apub_id(ap_id.clone(), context).await? { - return Ok(DeletableObjects::Community(Box::new(c))); + return Ok(DeletableObjects::Community(c)); } if let Some(p) = ApubPost::read_from_apub_id(ap_id.clone(), context).await? { - return Ok(DeletableObjects::Post(Box::new(p))); + return Ok(DeletableObjects::Post(p)); } if let Some(c) = ApubComment::read_from_apub_id(ap_id.clone(), context).await? { - return Ok(DeletableObjects::Comment(Box::new(c))); + return Ok(DeletableObjects::Comment(c)); } if let Some(p) = ApubPrivateMessage::read_from_apub_id(ap_id.clone(), context).await? { - return Ok(DeletableObjects::PrivateMessage(Box::new(p))); + return Ok(DeletableObjects::PrivateMessage(p)); } Err(diesel::NotFound.into()) } diff --git a/crates/apub/src/activities/mod.rs b/crates/apub/src/activities/mod.rs index 2fb5808b7..2896959fc 100644 --- a/crates/apub/src/activities/mod.rs +++ b/crates/apub/src/activities/mod.rs @@ -12,7 +12,7 @@ use activitypub_federation::{ }; use activitystreams_kinds::public; use anyhow::anyhow; -use lemmy_api_common::{context::LemmyContext, utils::generate_moderators_url}; +use lemmy_api_common::context::LemmyContext; use lemmy_db_schema::{ newtypes::CommunityId, source::{community::Community, local_site::LocalSite}, @@ -111,18 +111,6 @@ pub(crate) async fn verify_mod_action( Err(LemmyError::from_message("Not a mod")) } -/// For Add/Remove community moderator activities, check that the target field actually contains -/// /c/community/moderators. Any different values are unsupported. -fn verify_add_remove_moderator_target( - target: &Url, - community: &ApubCommunity, -) -> Result<(), LemmyError> { - if target != &generate_moderators_url(&community.actor_id)?.into() { - return Err(LemmyError::from_message("Unkown target url")); - } - Ok(()) -} - pub(crate) fn verify_is_public(to: &[Url], cc: &[Url]) -> Result<(), LemmyError> { if ![to, cc].iter().any(|set| set.contains(&public())) { return Err(LemmyError::from_message("Object is not public")); @@ -130,11 +118,15 @@ pub(crate) fn verify_is_public(to: &[Url], cc: &[Url]) -> Result<(), LemmyError> Ok(()) } -pub(crate) fn verify_community_matches( - a: &ApubCommunity, - b: CommunityId, -) -> Result<(), LemmyError> { - if a.id != b { +pub(crate) fn verify_community_matches( + a: &ObjectId, + b: T, +) -> Result<(), LemmyError> +where + T: Into>, +{ + let b: ObjectId = b.into(); + if a != &b { return Err(LemmyError::from_message("Invalid community")); } Ok(()) diff --git a/crates/apub/src/activity_lists.rs b/crates/apub/src/activity_lists.rs index 037717d84..70ae1bb8f 100644 --- a/crates/apub/src/activity_lists.rs +++ b/crates/apub/src/activity_lists.rs @@ -4,9 +4,10 @@ use crate::{ activities::{ block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, community::{ - add_mod::AddMod, announce::{AnnounceActivity, RawAnnouncableActivities}, - remove_mod::RemoveMod, + collection_add::CollectionAdd, + collection_remove::CollectionRemove, + lock_page::{LockPage, UndoLockPage}, report::Report, update::UpdateCommunity, }, @@ -85,8 +86,10 @@ pub enum AnnouncableActivities { UpdateCommunity(UpdateCommunity), BlockUser(BlockUser), UndoBlockUser(UndoBlockUser), - AddMod(AddMod), - RemoveMod(RemoveMod), + CollectionAdd(CollectionAdd), + CollectionRemove(CollectionRemove), + LockPost(LockPage), + UndoLockPost(UndoLockPage), // For compatibility with Pleroma/Mastodon (send only) Page(Page), } @@ -120,8 +123,10 @@ impl InCommunity for AnnouncableActivities { UpdateCommunity(a) => a.community(context, request_counter).await, BlockUser(a) => a.community(context, request_counter).await, UndoBlockUser(a) => a.community(context, request_counter).await, - AddMod(a) => a.community(context, request_counter).await, - RemoveMod(a) => a.community(context, request_counter).await, + CollectionAdd(a) => a.community(context, request_counter).await, + CollectionRemove(a) => a.community(context, request_counter).await, + LockPost(a) => a.community(context, request_counter).await, + UndoLockPost(a) => a.community(context, request_counter).await, Page(_) => unimplemented!(), } } diff --git a/crates/apub/src/collections/community_featured.rs b/crates/apub/src/collections/community_featured.rs new file mode 100644 index 000000000..7e1941ffa --- /dev/null +++ b/crates/apub/src/collections/community_featured.rs @@ -0,0 +1,103 @@ +use crate::{ + collections::CommunityContext, + objects::post::ApubPost, + protocol::collections::group_featured::GroupFeatured, +}; +use activitypub_federation::{ + data::Data, + traits::{ActivityHandler, ApubObject}, + utils::verify_domains_match, +}; +use activitystreams_kinds::collection::OrderedCollectionType; +use futures::future::{join_all, try_join_all}; +use lemmy_api_common::utils::generate_featured_url; +use lemmy_db_schema::{source::post::Post, utils::FETCH_LIMIT_MAX}; +use lemmy_utils::error::LemmyError; +use url::Url; + +#[derive(Clone, Debug)] +pub(crate) struct ApubCommunityFeatured(Vec); + +#[async_trait::async_trait(?Send)] +impl ApubObject for ApubCommunityFeatured { + type DataType = CommunityContext; + type ApubType = GroupFeatured; + type DbType = (); + type Error = LemmyError; + + async fn read_from_apub_id( + _object_id: Url, + data: &Self::DataType, + ) -> Result, Self::Error> + where + Self: Sized, + { + // Only read from database if its a local community, otherwise fetch over http + if data.0.local { + let community_id = data.0.id; + let post_list: Vec = Post::list_featured_for_community(data.1.pool(), community_id) + .await? + .into_iter() + .map(Into::into) + .collect(); + Ok(Some(ApubCommunityFeatured(post_list))) + } else { + Ok(None) + } + } + + async fn into_apub(self, data: &Self::DataType) -> Result { + let ordered_items = try_join_all(self.0.into_iter().map(|p| p.into_apub(&data.1))).await?; + Ok(GroupFeatured { + r#type: OrderedCollectionType::OrderedCollection, + id: generate_featured_url(&data.0.actor_id)?.into(), + total_items: ordered_items.len() as i32, + ordered_items, + }) + } + + async fn verify( + apub: &Self::ApubType, + expected_domain: &Url, + _data: &Self::DataType, + _request_counter: &mut i32, + ) -> Result<(), Self::Error> { + verify_domains_match(expected_domain, &apub.id)?; + Ok(()) + } + + async fn from_apub( + apub: Self::ApubType, + data: &Self::DataType, + _request_counter: &mut i32, + ) -> Result + where + Self: Sized, + { + let mut posts = apub.ordered_items; + if posts.len() as i64 > FETCH_LIMIT_MAX { + posts = posts[0..(FETCH_LIMIT_MAX as usize)].to_vec(); + } + + // We intentionally ignore errors here. This is because the outbox might contain posts from old + // Lemmy versions, or from other software which we cant parse. In that case, we simply skip the + // item and only parse the ones that work. + let data = Data::new(data.1.clone()); + // process items in parallel, to avoid long delay from fetch_site_metadata() and other processing + join_all(posts.into_iter().map(|post| { + async { + // use separate request counter for each item, otherwise there will be problems with + // parallel processing + let request_counter = &mut 0; + let verify = post.verify(&data, request_counter).await; + if verify.is_ok() { + post.receive(&data, request_counter).await.ok(); + } + } + })) + .await; + + // This return value is unused, so just set an empty vec + Ok(ApubCommunityFeatured(Vec::new())) + } +} diff --git a/crates/apub/src/collections/community_outbox.rs b/crates/apub/src/collections/community_outbox.rs index a16fbd02b..49140aab8 100644 --- a/crates/apub/src/collections/community_outbox.rs +++ b/crates/apub/src/collections/community_outbox.rs @@ -23,6 +23,7 @@ use lemmy_api_common::utils::generate_outbox_url; use lemmy_db_schema::{ source::{person::Person, post::Post}, traits::Crud, + utils::FETCH_LIMIT_MAX, }; use lemmy_utils::error::LemmyError; use url::Url; @@ -35,6 +36,7 @@ impl ApubObject for ApubCommunityOutbox { type DataType = CommunityContext; type ApubType = GroupOutbox; type Error = LemmyError; + type DbType = (); fn last_refreshed_at(&self) -> Option { None @@ -59,11 +61,6 @@ impl ApubObject for ApubCommunityOutbox { } } - async fn delete(self, _data: &Self::DataType) -> Result<(), LemmyError> { - // do nothing (it gets deleted automatically with the community) - Ok(()) - } - #[tracing::instrument(skip_all)] async fn into_apub(self, data: &Self::DataType) -> Result { let mut ordered_items = vec![]; @@ -103,8 +100,8 @@ impl ApubObject for ApubCommunityOutbox { _request_counter: &mut i32, ) -> Result { let mut outbox_activities = apub.ordered_items; - if outbox_activities.len() > 20 { - outbox_activities = outbox_activities[0..20].to_vec(); + if outbox_activities.len() as i64 > FETCH_LIMIT_MAX { + outbox_activities = outbox_activities[0..(FETCH_LIMIT_MAX as usize)].to_vec(); } // We intentionally ignore errors here. This is because the outbox might contain posts from old @@ -128,6 +125,4 @@ impl ApubObject for ApubCommunityOutbox { // This return value is unused, so just set an empty vec Ok(ApubCommunityOutbox(Vec::new())) } - - type DbType = (); } diff --git a/crates/apub/src/collections/mod.rs b/crates/apub/src/collections/mod.rs index 40bdf1206..a8d5e136d 100644 --- a/crates/apub/src/collections/mod.rs +++ b/crates/apub/src/collections/mod.rs @@ -1,6 +1,7 @@ use crate::objects::community::ApubCommunity; use lemmy_api_common::context::LemmyContext; +pub(crate) mod community_featured; pub(crate) mod community_moderators; pub(crate) mod community_outbox; diff --git a/crates/apub/src/fetcher/deletable_apub_object.rs b/crates/apub/src/fetcher/deletable_apub_object.rs deleted file mode 100644 index 0eae410c6..000000000 --- a/crates/apub/src/fetcher/deletable_apub_object.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::fetcher::post_or_comment::PostOrComment; -use lemmy_db_queries::source::{ - comment::Comment_, - community::Community_, - person::Person_, - post::Post_, -}; -use lemmy_db_schema::source::{ - comment::Comment, - community::Community, - person::Person, - post::Post, - site::Site, -}; -use lemmy_utils::LemmyError; -use lemmy_api_common::LemmyContext; - -// TODO: merge this trait with ApubObject (means that db_schema needs to depend on apub_lib) -#[async_trait::async_trait(?Send)] -pub trait DeletableApubObject { - // TODO: pass in tombstone with summary field, to decide between remove/delete - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError>; -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for Community { - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { - let id = self.id; - Community::update_deleted(context.pool(), id, true) - .await?; - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for Person { - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { - let id = self.id; - Person::delete_account(context.pool(), id).await?; - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for Post { - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { - let id = self.id; - Post::update_deleted(context.pool(), id, true) - .await?; - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for Comment { - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { - let id = self.id; - Comment::update_deleted(context.pool(), id, true) - .await?; - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for PostOrComment { - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { - match self { - PostOrComment::Comment(c) => { - Comment::update_deleted(context.pool(), c.id, true) - .await?; - } - PostOrComment::Post(p) => { - Post::update_deleted(context.pool(), p.id, true) - .await?; - } - } - - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl DeletableApubObject for Site { - async fn delete(self, _context: &LemmyContext) -> Result<(), LemmyError> { - // not implemented, ignore - Ok(()) - } -} diff --git a/crates/apub/src/fetcher/post_or_comment.rs b/crates/apub/src/fetcher/post_or_comment.rs index f8a260b98..c0a42301c 100644 --- a/crates/apub/src/fetcher/post_or_comment.rs +++ b/crates/apub/src/fetcher/post_or_comment.rs @@ -18,8 +18,8 @@ use url::Url; #[derive(Clone, Debug)] pub enum PostOrComment { - Post(Box), - Comment(Box), + Post(ApubPost), + Comment(ApubComment), } #[derive(Deserialize)] @@ -40,7 +40,6 @@ impl ApubObject for PostOrComment { None } - // TODO: this can probably be implemented using a single sql query #[tracing::instrument(skip_all)] async fn read_from_apub_id( object_id: Url, @@ -48,10 +47,10 @@ impl ApubObject for PostOrComment { ) -> Result, LemmyError> { let post = ApubPost::read_from_apub_id(object_id.clone(), data).await?; Ok(match post { - Some(o) => Some(PostOrComment::Post(Box::new(o))), + Some(o) => Some(PostOrComment::Post(o)), None => ApubComment::read_from_apub_id(object_id, data) .await? - .map(|c| PostOrComment::Comment(Box::new(c))), + .map(PostOrComment::Comment), }) } @@ -87,12 +86,12 @@ impl ApubObject for PostOrComment { request_counter: &mut i32, ) -> Result { Ok(match apub { - PageOrNote::Page(p) => PostOrComment::Post(Box::new( - ApubPost::from_apub(*p, context, request_counter).await?, - )), - PageOrNote::Note(n) => PostOrComment::Comment(Box::new( - ApubComment::from_apub(n, context, request_counter).await?, - )), + PageOrNote::Page(p) => { + PostOrComment::Post(ApubPost::from_apub(*p, context, request_counter).await?) + } + PageOrNote::Note(n) => { + PostOrComment::Comment(ApubComment::from_apub(n, context, request_counter).await?) + } }) } } diff --git a/crates/apub/src/http/community.rs b/crates/apub/src/http/community.rs index 74809509f..9a0d1f02b 100644 --- a/crates/apub/src/http/community.rs +++ b/crates/apub/src/http/community.rs @@ -1,6 +1,7 @@ use crate::{ activity_lists::GroupInboxActivities, collections::{ + community_featured::ApubCommunityFeatured, community_moderators::ApubCommunityModerators, community_outbox::ApubCommunityOutbox, CommunityContext, @@ -16,7 +17,10 @@ use activitypub_federation::{ traits::ApubObject, }; use actix_web::{web, HttpRequest, HttpResponse}; -use lemmy_api_common::{context::LemmyContext, utils::generate_outbox_url}; +use lemmy_api_common::{ + context::LemmyContext, + utils::{generate_featured_url, generate_outbox_url}, +}; use lemmy_db_schema::{source::community::Community, traits::ApubActor}; use lemmy_utils::error::LemmyError; use serde::Deserialize; @@ -106,3 +110,20 @@ pub(crate) async fn get_apub_community_moderators( &moderators.into_apub(&outbox_data).await?, )) } + +/// Returns collection of featured (stickied) posts. +pub(crate) async fn get_apub_community_featured( + info: web::Path, + context: web::Data, +) -> Result { + let community = Community::read_from_name(context.pool(), &info.community_name, false).await?; + if community.deleted || community.removed { + return Err(LemmyError::from_message("deleted")); + } + let id = ObjectId::new(generate_featured_url(&community.actor_id)?); + let data = CommunityContext(community.into(), context.get_ref().clone()); + let featured: ApubCommunityFeatured = id + .dereference(&data, local_instance(&context).await, &mut 0) + .await?; + Ok(create_apub_response(&featured.into_apub(&data).await?)) +} diff --git a/crates/apub/src/http/routes.rs b/crates/apub/src/http/routes.rs index a588b3127..4d4941f53 100644 --- a/crates/apub/src/http/routes.rs +++ b/crates/apub/src/http/routes.rs @@ -2,6 +2,7 @@ use crate::http::{ comment::get_apub_comment, community::{ community_inbox, + get_apub_community_featured, get_apub_community_followers, get_apub_community_http, get_apub_community_moderators, @@ -37,6 +38,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { "/c/{community_name}/outbox", web::get().to(get_apub_community_outbox), ) + .route( + "/c/{community_name}/featured", + web::get().to(get_apub_community_featured), + ) .route( "/c/{community_name}/moderators", web::get().to(get_apub_community_moderators), diff --git a/crates/apub/src/objects/community.rs b/crates/apub/src/objects/community.rs index e23e0743c..9f8cfae60 100644 --- a/crates/apub/src/objects/community.rs +++ b/crates/apub/src/objects/community.rs @@ -1,6 +1,6 @@ use crate::{ check_apub_id_valid_with_strictness, - collections::{community_moderators::ApubCommunityModerators, CommunityContext}, + collections::CommunityContext, fetch_local_site_data, local_instance, objects::instance::fetch_instance_actor_for_object, @@ -20,7 +20,7 @@ use chrono::NaiveDateTime; use itertools::Itertools; use lemmy_api_common::{ context::LemmyContext, - utils::{generate_moderators_url, generate_outbox_url}, + utils::{generate_featured_url, generate_moderators_url, generate_outbox_url}, }; use lemmy_db_schema::{ source::{ @@ -90,9 +90,6 @@ impl ApubObject for ApubCommunity { let community_id = self.id; let langs = CommunityLanguage::read(data.pool(), community_id).await?; let language = LanguageTag::new_multiple(langs, data.pool()).await?; - let attributed_to = Some(ObjectId::::new( - generate_moderators_url(&self.actor_id)?, - )); let group = Group { kind: GroupType::Group, @@ -104,7 +101,8 @@ impl ApubObject for ApubCommunity { icon: self.icon.clone().map(ImageObject::new), image: self.banner.clone().map(ImageObject::new), sensitive: Some(self.nsfw), - moderators: attributed_to.clone(), + moderators: Some(generate_moderators_url(&self.actor_id)?.into()), + featured: Some(generate_featured_url(&self.actor_id)?.into()), inbox: self.inbox_url.clone().into(), outbox: ObjectId::new(generate_outbox_url(&self.actor_id)?), followers: self.followers_url.clone().into(), @@ -116,7 +114,7 @@ impl ApubObject for ApubCommunity { published: Some(convert_datetime(self.published)), updated: self.updated.map(convert_datetime), posting_restricted_to_mods: Some(self.posting_restricted_to_mods), - attributed_to, + attributed_to: Some(generate_moderators_url(&self.actor_id)?.into()), }; Ok(group) } diff --git a/crates/apub/src/protocol/activities/block/block_user.rs b/crates/apub/src/protocol/activities/block/block_user.rs index 3ac040ced..0812489b6 100644 --- a/crates/apub/src/protocol/activities/block/block_user.rs +++ b/crates/apub/src/protocol/activities/block/block_user.rs @@ -49,18 +49,13 @@ impl InCommunity for BlockUser { .target .dereference(context, local_instance(context).await, request_counter) .await?; - let target_community = match target { + let community = match target { SiteOrCommunity::Community(c) => c, SiteOrCommunity::Site(_) => return Err(anyhow!("activity is not in community").into()), }; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, target_community.id)?; - Ok(audience) - } else { - Ok(target_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/block/undo_block_user.rs b/crates/apub/src/protocol/activities/block/undo_block_user.rs index d818af9de..9466fedc9 100644 --- a/crates/apub/src/protocol/activities/block/undo_block_user.rs +++ b/crates/apub/src/protocol/activities/block/undo_block_user.rs @@ -1,6 +1,5 @@ use crate::{ activities::verify_community_matches, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{activities::block::block_user::BlockUser, InCommunity}, }; @@ -35,15 +34,10 @@ impl InCommunity for UndoBlockUser { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let object_community = self.object.community(context, request_counter).await?; + let community = self.object.community(context, request_counter).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/community/add_mod.rs b/crates/apub/src/protocol/activities/community/collection_add.rs similarity index 63% rename from crates/apub/src/protocol/activities/community/add_mod.rs rename to crates/apub/src/protocol/activities/community/collection_add.rs index 22fc07fcb..a91244189 100644 --- a/crates/apub/src/protocol/activities/community/add_mod.rs +++ b/crates/apub/src/protocol/activities/community/collection_add.rs @@ -1,23 +1,23 @@ use crate::{ - activities::{community::get_community_from_moderators_url, verify_community_matches}, - local_instance, + activities::verify_community_matches, objects::{community::ApubCommunity, person::ApubPerson}, protocol::InCommunity, }; use activitypub_federation::{core::object_id::ObjectId, deser::helpers::deserialize_one_or_many}; use activitystreams_kinds::activity::AddType; use lemmy_api_common::context::LemmyContext; +use lemmy_db_schema::source::community::Community; use lemmy_utils::error::LemmyError; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AddMod { +pub struct CollectionAdd { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, - pub(crate) object: ObjectId, + pub(crate) object: Url, pub(crate) target: Url, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, @@ -28,22 +28,17 @@ pub struct AddMod { } #[async_trait::async_trait(?Send)] -impl InCommunity for AddMod { +impl InCommunity for CollectionAdd { async fn community( &self, context: &LemmyContext, - request_counter: &mut i32, + _request_counter: &mut i32, ) -> Result { - let mod_community = - get_community_from_moderators_url(&self.target, context, request_counter).await?; + let (community, _) = + Community::get_by_collection_url(context.pool(), &self.clone().target.into()).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, mod_community.id)?; - Ok(audience) - } else { - Ok(mod_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community.into()) } } diff --git a/crates/apub/src/protocol/activities/community/remove_mod.rs b/crates/apub/src/protocol/activities/community/collection_remove.rs similarity index 66% rename from crates/apub/src/protocol/activities/community/remove_mod.rs rename to crates/apub/src/protocol/activities/community/collection_remove.rs index ce46fb920..6ca8a2392 100644 --- a/crates/apub/src/protocol/activities/community/remove_mod.rs +++ b/crates/apub/src/protocol/activities/community/collection_remove.rs @@ -1,19 +1,19 @@ use crate::{ - activities::{community::get_community_from_moderators_url, verify_community_matches}, - local_instance, + activities::verify_community_matches, objects::{community::ApubCommunity, person::ApubPerson}, protocol::InCommunity, }; use activitypub_federation::{core::object_id::ObjectId, deser::helpers::deserialize_one_or_many}; use activitystreams_kinds::activity::RemoveType; use lemmy_api_common::context::LemmyContext; +use lemmy_db_schema::source::community::Community; use lemmy_utils::error::LemmyError; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct RemoveMod { +pub struct CollectionRemove { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, @@ -28,22 +28,17 @@ pub struct RemoveMod { } #[async_trait::async_trait(?Send)] -impl InCommunity for RemoveMod { +impl InCommunity for CollectionRemove { async fn community( &self, context: &LemmyContext, - request_counter: &mut i32, + _request_counter: &mut i32, ) -> Result { - let mod_community = - get_community_from_moderators_url(&self.target, context, request_counter).await?; + let (community, _) = + Community::get_by_collection_url(context.pool(), &self.clone().target.into()).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, mod_community.id)?; - Ok(audience) - } else { - Ok(mod_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community.into()) } } diff --git a/crates/apub/src/protocol/activities/community/lock_page.rs b/crates/apub/src/protocol/activities/community/lock_page.rs new file mode 100644 index 000000000..f7b5554c6 --- /dev/null +++ b/crates/apub/src/protocol/activities/community/lock_page.rs @@ -0,0 +1,83 @@ +use crate::{ + activities::verify_community_matches, + local_instance, + objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, + protocol::InCommunity, +}; +use activitypub_federation::{core::object_id::ObjectId, deser::helpers::deserialize_one_or_many}; +use activitystreams_kinds::activity::UndoType; +use lemmy_api_common::context::LemmyContext; +use lemmy_db_schema::{source::community::Community, traits::Crud}; +use lemmy_utils::error::LemmyError; +use serde::{Deserialize, Serialize}; +use strum_macros::Display; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize, Display)] +pub enum LockType { + Lock, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LockPage { + pub(crate) actor: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + pub(crate) object: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) cc: Vec, + #[serde(rename = "type")] + pub(crate) kind: LockType, + pub(crate) id: Url, + pub(crate) audience: Option>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UndoLockPage { + pub(crate) actor: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + pub(crate) object: LockPage, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) cc: Vec, + #[serde(rename = "type")] + pub(crate) kind: UndoType, + pub(crate) id: Url, + pub(crate) audience: Option>, +} + +#[async_trait::async_trait(?Send)] +impl InCommunity for LockPage { + async fn community( + &self, + context: &LemmyContext, + request_counter: &mut i32, + ) -> Result { + let post = self + .object + .dereference(context, local_instance(context).await, request_counter) + .await?; + let community = Community::read(context.pool(), post.community_id).await?; + if let Some(audience) = &self.audience { + verify_community_matches(audience, community.actor_id.clone())?; + } + Ok(community.into()) + } +} + +#[async_trait::async_trait(?Send)] +impl InCommunity for UndoLockPage { + async fn community( + &self, + context: &LemmyContext, + request_counter: &mut i32, + ) -> Result { + let community = self.object.community(context, request_counter).await?; + if let Some(audience) = &self.audience { + verify_community_matches(audience, community.actor_id.clone())?; + } + Ok(community) + } +} diff --git a/crates/apub/src/protocol/activities/community/mod.rs b/crates/apub/src/protocol/activities/community/mod.rs index 47771891f..d43e111e0 100644 --- a/crates/apub/src/protocol/activities/community/mod.rs +++ b/crates/apub/src/protocol/activities/community/mod.rs @@ -1,6 +1,7 @@ -pub mod add_mod; pub mod announce; -pub mod remove_mod; +pub mod collection_add; +pub mod collection_remove; +pub mod lock_page; pub mod report; pub mod update; @@ -8,9 +9,10 @@ pub mod update; mod tests { use crate::protocol::{ activities::community::{ - add_mod::AddMod, announce::AnnounceActivity, - remove_mod::RemoveMod, + collection_add::CollectionAdd, + collection_remove::CollectionRemove, + lock_page::{LockPage, UndoLockPage}, report::Report, update::UpdateCommunity, }, @@ -24,8 +26,22 @@ mod tests { ) .unwrap(); - test_parse_lemmy_item::("assets/lemmy/activities/community/add_mod.json").unwrap(); - test_parse_lemmy_item::("assets/lemmy/activities/community/remove_mod.json") + test_parse_lemmy_item::("assets/lemmy/activities/community/add_mod.json") + .unwrap(); + test_parse_lemmy_item::("assets/lemmy/activities/community/remove_mod.json") + .unwrap(); + + test_parse_lemmy_item::( + "assets/lemmy/activities/community/add_featured_post.json", + ) + .unwrap(); + test_parse_lemmy_item::( + "assets/lemmy/activities/community/remove_featured_post.json", + ) + .unwrap(); + + test_parse_lemmy_item::("assets/lemmy/activities/community/lock_page.json").unwrap(); + test_parse_lemmy_item::("assets/lemmy/activities/community/undo_lock_page.json") .unwrap(); test_parse_lemmy_item::( diff --git a/crates/apub/src/protocol/activities/community/report.rs b/crates/apub/src/protocol/activities/community/report.rs index 0a1ef650f..4d786b395 100644 --- a/crates/apub/src/protocol/activities/community/report.rs +++ b/crates/apub/src/protocol/activities/community/report.rs @@ -33,17 +33,12 @@ impl InCommunity for Report { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let to_community = self.to[0] + let community = self.to[0] .dereference(context, local_instance(context).await, request_counter) .await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, to_community.id)?; - Ok(audience) - } else { - Ok(to_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/community/update.rs b/crates/apub/src/protocol/activities/community/update.rs index 9a2f1f481..a8934717f 100644 --- a/crates/apub/src/protocol/activities/community/update.rs +++ b/crates/apub/src/protocol/activities/community/update.rs @@ -36,17 +36,12 @@ impl InCommunity for UpdateCommunity { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let object_community: ApubCommunity = ObjectId::new(self.object.id.clone()) + let community: ApubCommunity = ObjectId::new(self.object.id.clone()) .dereference(context, local_instance(context).await, request_counter) .await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/create_or_update/note.rs b/crates/apub/src/protocol/activities/create_or_update/note.rs index dfa1dbe9f..2cfcd2d8a 100644 --- a/crates/apub/src/protocol/activities/create_or_update/note.rs +++ b/crates/apub/src/protocol/activities/create_or_update/note.rs @@ -1,6 +1,5 @@ use crate::{ activities::verify_community_matches, - local_instance, mentions::MentionOrValue, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{activities::CreateOrUpdateType, objects::note::Note, InCommunity}, @@ -37,15 +36,10 @@ impl InCommunity for CreateOrUpdateNote { request_counter: &mut i32, ) -> Result { let post = self.object.get_parents(context, request_counter).await?.0; + let community = Community::read(context.pool(), post.community_id).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, post.community_id)?; - Ok(audience) - } else { - let community = Community::read(context.pool(), post.community_id).await?; - Ok(community.into()) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community.into()) } } diff --git a/crates/apub/src/protocol/activities/create_or_update/page.rs b/crates/apub/src/protocol/activities/create_or_update/page.rs index 2c15d9f90..ad17d7383 100644 --- a/crates/apub/src/protocol/activities/create_or_update/page.rs +++ b/crates/apub/src/protocol/activities/create_or_update/page.rs @@ -1,6 +1,5 @@ use crate::{ activities::verify_community_matches, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{activities::CreateOrUpdateType, objects::page::Page, InCommunity}, }; @@ -32,15 +31,10 @@ impl InCommunity for CreateOrUpdatePage { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let object_community = self.object.community(context, request_counter).await?; + let community = self.object.community(context, request_counter).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/deletion/delete.rs b/crates/apub/src/protocol/activities/deletion/delete.rs index d92ac2456..162e595f1 100644 --- a/crates/apub/src/protocol/activities/deletion/delete.rs +++ b/crates/apub/src/protocol/activities/deletion/delete.rs @@ -1,6 +1,5 @@ use crate::{ activities::{deletion::DeletableObjects, verify_community_matches}, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{objects::tombstone::Tombstone, IdOrNestedObject, InCommunity}, }; @@ -44,7 +43,7 @@ impl InCommunity for Delete { async fn community( &self, context: &LemmyContext, - request_counter: &mut i32, + _request_counter: &mut i32, ) -> Result { let community_id = match DeletableObjects::read_from_db(self.object.id(), context).await? { DeletableObjects::Community(c) => c.id, @@ -57,15 +56,10 @@ impl InCommunity for Delete { return Err(anyhow!("Private message is not part of community").into()) } }; + let community = Community::read(context.pool(), community_id).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, community_id)?; - Ok(audience) - } else { - let community = Community::read(context.pool(), community_id).await?; - Ok(community.into()) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community.into()) } } diff --git a/crates/apub/src/protocol/activities/deletion/undo_delete.rs b/crates/apub/src/protocol/activities/deletion/undo_delete.rs index d5249ba9a..6c584ccf5 100644 --- a/crates/apub/src/protocol/activities/deletion/undo_delete.rs +++ b/crates/apub/src/protocol/activities/deletion/undo_delete.rs @@ -1,6 +1,5 @@ use crate::{ activities::verify_community_matches, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{activities::deletion::delete::Delete, InCommunity}, }; @@ -37,15 +36,10 @@ impl InCommunity for UndoDelete { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let object_community = self.object.community(context, request_counter).await?; + let community = self.object.community(context, request_counter).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/voting/undo_vote.rs b/crates/apub/src/protocol/activities/voting/undo_vote.rs index 0973c76a8..a8432c827 100644 --- a/crates/apub/src/protocol/activities/voting/undo_vote.rs +++ b/crates/apub/src/protocol/activities/voting/undo_vote.rs @@ -1,6 +1,5 @@ use crate::{ activities::verify_community_matches, - local_instance, objects::{community::ApubCommunity, person::ApubPerson}, protocol::{activities::voting::vote::Vote, InCommunity}, }; @@ -29,16 +28,10 @@ impl InCommunity for UndoVote { context: &LemmyContext, request_counter: &mut i32, ) -> Result { - let local_instance = local_instance(context).await; - let object_community = self.object.community(context, request_counter).await?; + let community = self.object.community(context, request_counter).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/activities/voting/vote.rs b/crates/apub/src/protocol/activities/voting/vote.rs index 2a09a45ea..cf79fc9ad 100644 --- a/crates/apub/src/protocol/activities/voting/vote.rs +++ b/crates/apub/src/protocol/activities/voting/vote.rs @@ -59,20 +59,15 @@ impl InCommunity for Vote { request_counter: &mut i32, ) -> Result { let local_instance = local_instance(context).await; - let object_community = self + let community = self .object .dereference(context, local_instance, request_counter) .await? .community(context, request_counter) .await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance, request_counter) - .await?; - verify_community_matches(&audience, object_community.id)?; - Ok(audience) - } else { - Ok(object_community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/apub/src/protocol/collections/group_featured.rs b/crates/apub/src/protocol/collections/group_featured.rs new file mode 100644 index 000000000..cbd7dce4b --- /dev/null +++ b/crates/apub/src/protocol/collections/group_featured.rs @@ -0,0 +1,13 @@ +use crate::protocol::objects::page::Page; +use activitystreams_kinds::collection::OrderedCollectionType; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupFeatured { + pub(crate) r#type: OrderedCollectionType, + pub(crate) id: Url, + pub(crate) total_items: i32, + pub(crate) ordered_items: Vec, +} diff --git a/crates/apub/src/protocol/collections/mod.rs b/crates/apub/src/protocol/collections/mod.rs index 0e251a1bb..41b4a9f58 100644 --- a/crates/apub/src/protocol/collections/mod.rs +++ b/crates/apub/src/protocol/collections/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod empty_outbox; +pub(crate) mod group_featured; pub(crate) mod group_followers; pub(crate) mod group_moderators; pub(crate) mod group_outbox; @@ -8,11 +9,12 @@ mod tests { use crate::protocol::{ collections::{ empty_outbox::EmptyOutbox, + group_featured::GroupFeatured, group_followers::GroupFollowers, group_moderators::GroupModerators, group_outbox::GroupOutbox, }, - tests::test_parse_lemmy_item, + tests::{test_json, test_parse_lemmy_item}, }; #[test] @@ -22,8 +24,15 @@ mod tests { let outbox = test_parse_lemmy_item::("assets/lemmy/collections/group_outbox.json").unwrap(); assert_eq!(outbox.ordered_items.len() as i32, outbox.total_items); + test_parse_lemmy_item::("assets/lemmy/collections/group_featured_posts.json") + .unwrap(); test_parse_lemmy_item::("assets/lemmy/collections/group_moderators.json") .unwrap(); test_parse_lemmy_item::("assets/lemmy/collections/person_outbox.json").unwrap(); } + + #[test] + fn test_parse_mastodon_collections() { + test_json::("assets/mastodon/collections/featured.json").unwrap(); + } } diff --git a/crates/apub/src/protocol/objects/group.rs b/crates/apub/src/protocol/objects/group.rs index a09f9b118..9c4aa35d0 100644 --- a/crates/apub/src/protocol/objects/group.rs +++ b/crates/apub/src/protocol/objects/group.rs @@ -1,6 +1,7 @@ use crate::{ check_apub_id_valid_with_strictness, collections::{ + community_featured::ApubCommunityFeatured, community_moderators::ApubCommunityModerators, community_outbox::ApubCommunityOutbox, }, @@ -65,6 +66,7 @@ pub struct Group { pub(crate) posting_restricted_to_mods: Option, pub(crate) outbox: ObjectId, pub(crate) endpoints: Option, + pub(crate) featured: Option>, #[serde(default)] pub(crate) language: Vec, pub(crate) published: Option>, @@ -117,8 +119,10 @@ impl Group { followers_url: Some(self.followers.into()), inbox_url: Some(self.inbox.into()), shared_inbox_url: self.endpoints.map(|e| e.shared_inbox.into()), + moderators_url: self.moderators.map(Into::into), posting_restricted_to_mods: self.posting_restricted_to_mods, instance_id, + featured_url: self.featured.map(Into::into), } } @@ -146,7 +150,9 @@ impl Group { followers_url: Some(self.followers.into()), inbox_url: Some(self.inbox.into()), shared_inbox_url: Some(self.endpoints.map(|e| e.shared_inbox.into())), + moderators_url: self.moderators.map(Into::into), posting_restricted_to_mods: self.posting_restricted_to_mods, + featured_url: self.featured.map(Into::into), } } } diff --git a/crates/apub/src/protocol/objects/note.rs b/crates/apub/src/protocol/objects/note.rs index f561c313d..f93a41ef8 100644 --- a/crates/apub/src/protocol/objects/note.rs +++ b/crates/apub/src/protocol/objects/note.rs @@ -67,15 +67,11 @@ impl Note { .await?, ); match parent.deref() { - PostOrComment::Post(p) => { - let post = p.deref().clone(); - Ok((post, None)) - } + PostOrComment::Post(p) => Ok((p.clone(), None)), PostOrComment::Comment(c) => { let post_id = c.post_id; let post = Post::read(context.pool(), post_id).await?; - let comment = c.deref().clone(); - Ok((post.into(), Some(comment))) + Ok((post.into(), Some(c.clone()))) } } } @@ -89,15 +85,10 @@ impl InCommunity for Note { request_counter: &mut i32, ) -> Result { let (post, _) = self.get_parents(context, request_counter).await?; - let community_id = post.community_id; + let community = Community::read(context.pool(), post.community_id).await?; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, local_instance(context).await, request_counter) - .await?; - verify_community_matches(&audience, community_id)?; - Ok(audience) - } else { - Ok(Community::read(context.pool(), community_id).await?.into()) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community.into()) } } diff --git a/crates/apub/src/protocol/objects/page.rs b/crates/apub/src/protocol/objects/page.rs index 9055b1fcc..65a164ecd 100644 --- a/crates/apub/src/protocol/objects/page.rs +++ b/crates/apub/src/protocol/objects/page.rs @@ -64,6 +64,7 @@ pub struct Page { pub(crate) image: Option, pub(crate) comments_enabled: Option, pub(crate) sensitive: Option, + /// Deprecated, for compatibility with Lemmy 0.17 pub(crate) stickied: Option, pub(crate) published: Option>, pub(crate) updated: Option>, @@ -252,14 +253,9 @@ impl InCommunity for Page { } }; if let Some(audience) = &self.audience { - let audience = audience - .dereference(context, instance, request_counter) - .await?; - verify_community_matches(&audience, community.id)?; - Ok(audience) - } else { - Ok(community) + verify_community_matches(audience, community.actor_id.clone())?; } + Ok(community) } } diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index 935bfa05e..d1a4f2b6c 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -194,6 +194,38 @@ impl DeleteableOrRemoveable for Community { } } +pub enum CollectionType { + Moderators, + Featured, +} + +impl Community { + /// Get the community which has a given moderators or featured url, also return the collection type + pub async fn get_by_collection_url( + pool: &DbPool, + url: &DbUrl, + ) -> Result<(Community, CollectionType), Error> { + use crate::schema::community::dsl::{featured_url, moderators_url}; + use CollectionType::*; + let conn = &mut get_conn(pool).await?; + let res = community + .filter(moderators_url.eq(url)) + .first::(conn) + .await; + if let Ok(c) = res { + return Ok((c, Moderators)); + } + let res = community + .filter(featured_url.eq(url)) + .first::(conn) + .await; + if let Ok(c) = res { + return Ok((c, Featured)); + } + Err(diesel::NotFound) + } +} + impl CommunityModerator { pub async fn delete_for_community( pool: &DbPool, @@ -430,6 +462,8 @@ mod tests { followers_url: inserted_community.followers_url.clone(), inbox_url: inserted_community.inbox_url.clone(), shared_inbox_url: None, + moderators_url: None, + featured_url: None, hidden: false, posting_restricted_to_mods: false, instance_id: inserted_instance.id, diff --git a/crates/db_schema/src/impls/post.rs b/crates/db_schema/src/impls/post.rs index 91e7bc1e3..9f5c64389 100644 --- a/crates/db_schema/src/impls/post.rs +++ b/crates/db_schema/src/impls/post.rs @@ -89,6 +89,22 @@ impl Post { .await } + pub async fn list_featured_for_community( + pool: &DbPool, + the_community_id: CommunityId, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + post + .filter(community_id.eq(the_community_id)) + .filter(deleted.eq(false)) + .filter(removed.eq(false)) + .filter(featured_community.eq(true)) + .then_order_by(published.desc()) + .limit(FETCH_LIMIT_MAX) + .load::(conn) + .await + } + pub async fn permadelete_for_creator( pool: &DbPool, for_creator_id: PersonId, diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index b8e85a1b3..7e2465bb7 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -1,4 +1,6 @@ #[cfg(feature = "full")] +use activitypub_federation::{core::object_id::ObjectId, traits::ApubObject}; +#[cfg(feature = "full")] use diesel_ltree::Ltree; use serde::{Deserialize, Serialize}; use std::{ @@ -110,7 +112,7 @@ pub struct LocalSiteId(i32); #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[cfg_attr(feature = "full", derive(AsExpression, FromSqlRow))] #[cfg_attr(feature = "full", diesel(sql_type = diesel::sql_types::Text))] -pub struct DbUrl(pub(crate) Url); +pub struct DbUrl(pub(crate) Box); #[cfg(feature = "full")] #[derive(Serialize, Deserialize)] @@ -128,13 +130,23 @@ impl Display for DbUrl { #[allow(clippy::from_over_into)] impl Into for Url { fn into(self) -> DbUrl { - DbUrl(self) + DbUrl(Box::new(self)) } } #[allow(clippy::from_over_into)] impl Into for DbUrl { fn into(self) -> Url { - self.0 + *self.0 + } +} +#[cfg(feature = "full")] +impl From for ObjectId +where + T: ApubObject + Send, + for<'de2> ::ApubType: Deserialize<'de2>, +{ + fn from(value: DbUrl) -> Self { + ObjectId::new(value) } } diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 8c893cf92..a177139ce 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -98,6 +98,8 @@ table! { followers_url -> Varchar, inbox_url -> Varchar, shared_inbox_url -> Nullable, + moderators_url -> Nullable, + featured_url -> Nullable, hidden -> Bool, posting_restricted_to_mods -> Bool, instance_id -> Int4, diff --git a/crates/db_schema/src/source/community.rs b/crates/db_schema/src/source/community.rs index 2630737a2..664e29b4c 100644 --- a/crates/db_schema/src/source/community.rs +++ b/crates/db_schema/src/source/community.rs @@ -27,6 +27,12 @@ pub struct Community { pub followers_url: DbUrl, pub inbox_url: DbUrl, pub shared_inbox_url: Option, + /// Url where moderators collection is served over Activitypub + #[serde(skip)] + pub moderators_url: Option, + /// Url where featured posts collection is served over Activitypub + #[serde(skip)] + pub featured_url: Option, pub hidden: bool, pub posting_restricted_to_mods: bool, pub instance_id: InstanceId, @@ -80,6 +86,8 @@ pub struct CommunityInsertForm { pub followers_url: Option, pub inbox_url: Option, pub shared_inbox_url: Option, + pub moderators_url: Option, + pub featured_url: Option, pub hidden: Option, pub posting_restricted_to_mods: Option, #[builder(!default)] @@ -108,6 +116,8 @@ pub struct CommunityUpdateForm { pub followers_url: Option, pub inbox_url: Option, pub shared_inbox_url: Option>, + pub moderators_url: Option, + pub featured_url: Option, pub hidden: Option, pub posting_restricted_to_mods: Option, } diff --git a/crates/db_schema/src/utils.rs b/crates/db_schema/src/utils.rs index 76d1f7950..71a0875d3 100644 --- a/crates/db_schema/src/utils.rs +++ b/crates/db_schema/src/utils.rs @@ -227,7 +227,7 @@ where { fn from_sql(value: diesel::backend::RawValue<'_, DB>) -> diesel::deserialize::Result { let str = String::from_sql(value)?; - Ok(DbUrl(Url::parse(&str)?)) + Ok(DbUrl(Box::new(Url::parse(&str)?))) } } @@ -237,7 +237,7 @@ where for<'de2> ::ApubType: serde::Deserialize<'de2>, { fn from(id: ObjectId) -> Self { - DbUrl(id.into()) + DbUrl(Box::new(id.into())) } } diff --git a/docker/federation/docker-compose.yml b/docker/federation/docker-compose.yml index f581aa4f4..ab512f49f 100644 --- a/docker/federation/docker-compose.yml +++ b/docker/federation/docker-compose.yml @@ -44,6 +44,7 @@ services: - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" depends_on: - postgres_alpha + restart: always ports: - "8541:8541" postgres_alpha: @@ -73,6 +74,7 @@ services: - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" depends_on: - postgres_beta + restart: always ports: - "8551:8551" postgres_beta: @@ -102,6 +104,7 @@ services: - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" depends_on: - postgres_gamma + restart: always ports: - "8561:8561" postgres_gamma: @@ -132,6 +135,7 @@ services: - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" depends_on: - postgres_delta + restart: always ports: - "8571:8571" postgres_delta: @@ -162,6 +166,7 @@ services: - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" depends_on: - postgres_epsilon + restart: always ports: - "8581:8581" postgres_epsilon: diff --git a/migrations/2023-02-07-030958_community-collections/down.sql b/migrations/2023-02-07-030958_community-collections/down.sql new file mode 100644 index 000000000..8f7b531f0 --- /dev/null +++ b/migrations/2023-02-07-030958_community-collections/down.sql @@ -0,0 +1,2 @@ +alter table community drop column moderators_url; +alter table community drop column featured_url; \ No newline at end of file diff --git a/migrations/2023-02-07-030958_community-collections/up.sql b/migrations/2023-02-07-030958_community-collections/up.sql new file mode 100644 index 000000000..78e7e52b8 --- /dev/null +++ b/migrations/2023-02-07-030958_community-collections/up.sql @@ -0,0 +1,2 @@ +alter table community add column moderators_url varchar(255) unique; +alter table community add column featured_url varchar(255) unique; \ No newline at end of file