Repository: LemmyNet/lemmy (language: rust)

Issue:
Full reports federation
### Requirements

- [X] Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support
- [X] Did you check to see if this issue already exists?
- [X] Is this only a feature request? Do not put multiple feature requests in one issue.
- [X] Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
- [X] Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?

### Is your proposal related to a problem?

Reports are only partially federated, especially regarding remote moderators (who are on a different instance from the community). These mods wont receive all reports as they are not forwarded. Also when resolving a report, this action isnt federated to other instances.


Discussion / hints:
related:
- #3781
- #4436
This seems like an important issue, could it get added to a Milestone?
Up to @Nutomic I spose.

Fix patch:
diff --git a/crates/api/src/reports/comment_report/resolve.rs b/crates/api/src/reports/comment_report/resolve.rs
index 0d8e625c97..bed1104830 100644
--- a/crates/api/src/reports/comment_report/resolve.rs
+++ b/crates/api/src/reports/comment_report/resolve.rs
@@ -1,7 +1,9 @@
-use actix_web::web::{Data, Json};
+use activitypub_federation::config::Data;
+use actix_web::web::Json;
 use lemmy_api_common::{
   context::LemmyContext,
   reports::comment::{CommentReportResponse, ResolveCommentReport},
+  send_activity::{ActivityChannel, SendActivityData},
   utils::check_community_mod_action,
 };
 use lemmy_db_schema::{source::comment_report::CommentReport, traits::Reportable};
@@ -41,6 +43,16 @@ pub async fn resolve_comment_report(
   let comment_report_view =
     CommentReportView::read(&mut context.pool(), report_id, person_id).await?;
 
+  ActivityChannel::submit_activity(
+    SendActivityData::SendResolveReport {
+      object_id: comment_report_view.comment.ap_id.inner().clone(),
+      actor: local_user_view.person,
+      report_creator: report.creator,
+      community: comment_report_view.community.clone(),
+    },
+    &context,
+  )?;
+
   Ok(Json(CommentReportResponse {
     comment_report_view,
   }))
diff --git a/crates/api/src/reports/post_report/resolve.rs b/crates/api/src/reports/post_report/resolve.rs
index 37ceb4bea5..fff6187b0b 100644
--- a/crates/api/src/reports/post_report/resolve.rs
+++ b/crates/api/src/reports/post_report/resolve.rs
@@ -1,7 +1,9 @@
-use actix_web::web::{Data, Json};
+use activitypub_federation::config::Data;
+use actix_web::web::Json;
 use lemmy_api_common::{
   context::LemmyContext,
   reports::post::{PostReportResponse, ResolvePostReport},
+  send_activity::{ActivityChannel, SendActivityData},
   utils::check_community_mod_action,
 };
 use lemmy_db_schema::{source::post_report::PostReport, traits::Reportable};
@@ -32,6 +34,7 @@ pub async fn resolve_post_report(
       .await
       .with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
   } else {
+    // TODO: not federated
     PostReport::unresolve(&mut context.pool(), report_id, person_id)
       .await
       .with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
@@ -39,5 +42,15 @@ pub async fn resolve_post_report(
 
   let post_report_view = PostReportView::read(&mut context.pool(), report_id, person_id).await?;
 
+  ActivityChannel::submit_activity(
+    SendActivityData::SendResolveReport {
+      object_id: post_report_view.post.ap_id.inner().clone(),
+      actor: local_user_view.person,
+      report_creator: report.creator,
+      community: post_report_view.community.clone(),
+    },
+    &context,
+  )?;
+
   Ok(Json(PostReportResponse { post_report_view }))
 }
diff --git a/crates/api_common/src/send_activity.rs b/crates/api_common/src/send_activity.rs
index b606c9a901..fdefbaac0f 100644
--- a/crates/api_common/src/send_activity.rs
+++ b/crates/api_common/src/send_activity.rs
@@ -99,6 +99,12 @@ pub enum SendActivityData {
     community: Community,
     reason: String,
   },
+  SendResolveReport {
+    object_id: Url,
+    actor: Person,
+    report_creator: Person,
+    community: Community,
+  },
 }
 
 // TODO: instead of static, move this into LemmyContext. make sure that stopping the process with
diff --git a/crates/apub/assets/lemmy/activities/community/resolve_report_page.json b/crates/apub/assets/lemmy/activities/community/resolve_report_page.json
new file mode 100644
index 0000000000..6fc218c7d2
--- /dev/null
+++ b/crates/apub/assets/lemmy/activities/community/resolve_report_page.json
@@ -0,0 +1,14 @@
+{
+  "actor": "http://ds9.lemmy.ml/u/lemmy_user",
+  "to": ["http://enterprise.lemmy.ml/c/main"],
+  "type": "Resolve",
+  "id": "http://ds9.lemmy.ml/activities/flag/4323412-5e45-4a95-a15f-e0dc86361ba4",
+  "object": {
+    "actor": "http://ds9.lemmy.ml/u/lemmy_alpha",
+    "to": ["http://enterprise.lemmy.ml/c/main"],
+    "object": "http://enterprise.lemmy.ml/post/7",
+    "summary": "report this post",
+    "type": "Flag",
+    "id": "http://ds9.lemmy.ml/activities/flag/98b0933f-5e45-4a95-a15f-e0dc86361ba4"
+  }
+}
diff --git a/crates/apub/src/activities/community/mod.rs b/crates/apub/src/activities/community/mod.rs
index 93c6e5c774..1efabe8e27 100644
--- a/crates/apub/src/activities/community/mod.rs
+++ b/crates/apub/src/activities/community/mod.rs
@@ -1,15 +1,22 @@
 use crate::{
   activities::send_lemmy_activity,
   activity_lists::AnnouncableActivities,
-  objects::{community::ApubCommunity, person::ApubPerson},
+  fetcher::post_or_comment::PostOrComment,
+  objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson},
   protocol::activities::community::announce::AnnounceActivity,
 };
-use activitypub_federation::{config::Data, traits::Actor};
+use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Actor};
 use lemmy_api_common::context::LemmyContext;
 use lemmy_db_schema::{
-  source::{activity::ActivitySendTargets, person::PersonFollower},
+  source::{
+    activity::ActivitySendTargets,
+    person::{Person, PersonFollower},
+    site::Site,
+  },
+  traits::Crud,
   CommunityVisibility,
 };
+use lemmy_db_views::structs::CommunityModeratorView;
 use lemmy_utils::error::LemmyResult;
 
 pub mod announce;
@@ -17,6 +24,7 @@ pub mod collection_add;
 pub mod collection_remove;
 pub mod lock_page;
 pub mod report;
+pub mod resolve_report;
 pub mod update;
 
 /// This function sends all activities which are happening in a community to the right inboxes.
@@ -70,3 +78,37 @@ pub(crate) async fn send_activity_in_community(
   send_lemmy_activity(context, activity.clone(), actor, inboxes, false).await?;
   Ok(())
 }
+
+async fn report_inboxes(
+  object_id: ObjectId<PostOrComment>,
+  community: &ApubCommunity,
+  context: &Data<LemmyContext>,
+) -> LemmyResult<ActivitySendTargets> {
+  // send report to the community where object was posted
+  let mut inboxes = ActivitySendTargets::to_inbox(community.shared_inbox_or_inbox());
+
+  if community.local {
+    // send to all moderators
+    let moderators =
+      CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;
+    for m in moderators {
+      inboxes.add_inbox(m.moderator.inbox_url.into());
+    }
+
+    // also send report to user's home instance if possible
+    let object_creator_id = match object_id.dereference_local(context).await? {
+      PostOrComment::Post(p) => p.creator_id,
+      PostOrComment::Comment(c) => c.creator_id,
+    };
+    let object_creator = Person::read(&mut context.pool(), object_creator_id).await?;
+    let object_creator_site: Option<ApubSite> =
+      Site::read_from_instance_id(&mut context.pool(), object_creator.instance_id)
+        .await
+        .ok()
+        .map(Into::into);
+    if let Some(inbox) = object_creator_site.map(|s| s.shared_inbox_or_inbox()) {
+      inboxes.add_inbox(inbox);
+    }
+  }
+  Ok(inboxes)
+}
diff --git a/crates/apub/src/activities/community/report.rs b/crates/apub/src/activities/community/report.rs
index ef57bb3e26..15597e4a2b 100644
--- a/crates/apub/src/activities/community/report.rs
+++ b/crates/apub/src/activities/community/report.rs
@@ -1,9 +1,14 @@
+use super::report_inboxes;
 use crate::{
   activities::{generate_activity_id, send_lemmy_activity, verify_person_in_community},
+  activity_lists::AnnouncableActivities,
   insert_received_activity,
-  objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson},
+  objects::{community::ApubCommunity, person::ApubPerson},
   protocol::{
-    activities::community::report::{Report, ReportObject},
+    activities::community::{
+      announce::AnnounceActivity,
+      report::{Report, ReportObject},
+    },
     InCommunity,
   },
   PostOrComment,
@@ -20,71 +25,49 @@ use lemmy_api_common::{
 };
 use lemmy_db_schema::{
   source::{
-    activity::ActivitySendTargets,
     comment_report::{CommentReport, CommentReportForm},
-    community::Community,
-    person::Person,
     post_report::{PostReport, PostReportForm},
-    site::Site,
   },
-  traits::{Crud, Reportable},
+  traits::Reportable,
 };
-use lemmy_db_views::structs::CommunityModeratorView;
 use lemmy_utils::error::{LemmyError, LemmyResult};
 use url::Url;
 
 impl Report {
-  #[tracing::instrument(skip_all)]
-  pub(crate) async fn send(
-    object_id: ObjectId<PostOrComment>,
-    actor: Person,
-    community: Community,
-    reason: String,
-    context: Data<LemmyContext>,
-  ) -> LemmyResult<()> {
-    let actor: ApubPerson = actor.into();
-    let community: ApubCommunity = community.into();
+  pub(crate) fn new(
+    object_id: &ObjectId<PostOrComment>,
+    actor: &ApubPerson,
+    community: &ApubCommunity,
+    reason: Option<String>,
+    context: &Data<LemmyContext>,
+  ) -> LemmyResult<Self> {
     let kind = FlagType::Flag;
     let id = generate_activity_id(
       kind.clone(),
       &context.settings().get_protocol_and_hostname(),
     )?;
-    let report = Report {
+    Ok(Report {
       actor: actor.id().into(),
       to: [community.id().into()],
       object: ReportObject::Lemmy(object_id.clone()),
-      summary: Some(reason),
+      summary: reason,
       content: None,
       kind,
       id: id.clone(),
-    };
-
-    // send report to the community where object was posted
-    let mut inboxes = ActivitySendTargets::to_inbox(community.shared_inbox_or_inbox());
-
-    // send to all moderators
-    let moderators =
-      CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;
-    for m in moderators {
-      inboxes.add_inbox(m.moderator.actor_id.into());
-    }
+    })
+  }
 
-    // also send report to user's home instance if possible
-    let object_creator_id = match object_id.dereference_local(&context).await? {
-      PostOrComment::Post(p) => p.creator_id,
-      PostOrComment::Comment(c) => c.creator_id,
-    };
-    let object_creator = Person::read(&mut context.pool(), object_creator_id).await?;
-    let object_creator_site: Option<ApubSite> =
-      Site::read_from_instance_id(&mut context.pool(), object_creator.instance_id)
-        .await
-        .ok()
-        .map(Into::into);
-    if let Some(inbox) = object_creator_site.map(|s| s.shared_inbox_or_inbox()) {
-      inboxes.add_inbox(inbox);
-    }
+  pub(crate) async fn send(
+    object_id: ObjectId<PostOrComment>,
+    actor: &ApubPerson,
+    community: &ApubCommunity,
+    reason: String,
+    context: Data<LemmyContext>,
+  ) -> LemmyResult<()> {
+    let report = Self::new(&object_id, actor, community, Some(reason), &context)?;
+    let inboxes = report_inboxes(object_id, community, &context).await?;
 
-    send_lemmy_activity(&context, report, &actor, inboxes, false).await
+    send_lemmy_activity(&context, report, actor, inboxes, false).await
   }
 }
 
@@ -137,6 +120,17 @@ impl ActivityHandler for Report {
         CommentReport::report(&mut context.pool(), &report_form).await?;
       }
     };
+
+    let community = self.community(context).await?;
+    if community.local {
+      // forward to remote mods
+      let object_id = self.object.object_id(context).await?;
+      let announce = AnnouncableActivities::Report(self);
+      let announce = AnnounceActivity::new(announce.try_into()?, &community, context)?;
+      let inboxes = report_inboxes(object_id, &community, context).await?;
+      send_lemmy_activity(context, announce, &community, inboxes.clone(), false).await?;
+    }
+
     Ok(())
   }
 }
diff --git a/crates/apub/src/activities/community/resolve_report.rs b/crates/apub/src/activities/community/resolve_report.rs
new file mode 100644
index 0000000000..e59e1fea66
--- /dev/null
+++ b/crates/apub/src/activities/community/resolve_report.rs
@@ -0,0 +1,110 @@
+use super::report_inboxes;
+use crate::{
+  activities::{
+    generate_activity_id,
+    send_lemmy_activity,
+    verify_mod_action,
+    verify_person_in_community,
+  },
+  activity_lists::AnnouncableActivities,
+  insert_received_activity,
+  objects::{community::ApubCommunity, person::ApubPerson},
+  protocol::{
+    activities::community::{
+      announce::AnnounceActivity,
+      report::Report,
+      resolve_report::{ResolveReport, ResolveType},
+    },
+    InCommunity,
+  },
+  PostOrComment,
+};
+use activitypub_federation::{
+  config::Data,
+  fetch::object_id::ObjectId,
+  protocol::verification::verify_urls_match,
+  traits::{ActivityHandler, Actor},
+};
+use lemmy_api_common::context::LemmyContext;
+use lemmy_db_schema::{
+  source::{comment_report::CommentReport, post_report::PostReport},
+  traits::Reportable,
+};
+use lemmy_utils::error::{LemmyError, LemmyResult};
+use url::Url;
+
+impl ResolveReport {
+  pub(crate) async fn send(
+    object_id: ObjectId<PostOrComment>,
+    actor: &ApubPerson,
+    report_creator: &ApubPerson,
+    community: &ApubCommunity,
+    context: Data<LemmyContext>,
+  ) -> LemmyResult<()> {
+    let kind = ResolveType::Resolve;
+    let id = generate_activity_id(
+      kind.clone(),
+      &context.settings().get_protocol_and_hostname(),
+    )?;
+    let object = Report::new(&object_id, report_creator, community, None, &context)?;
+    let resolve = ResolveReport {
+      actor: actor.id().into(),
+      to: [community.id().into()],
+      object,
+      kind,
+      id: id.clone(),
+    };
+    let inboxes = report_inboxes(object_id, community, &context).await?;
+
+    send_lemmy_activity(&context, resolve, actor, inboxes, false).await
+  }
+}
+
+#[async_trait::async_trait]
+impl ActivityHandler for ResolveReport {
+  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<Self::DataType>) -> LemmyResult<()> {
+    self.object.verify(context).await?;
+    let community = self.community(context).await?;
+    verify_person_in_community(&self.actor, &community, context).await?;
+    verify_urls_match(self.to[0].inner(), self.object.to[0].inner())?;
+    verify_mod_action(&self.actor, &community, context).await?;
+    Ok(())
+  }
+
+  async fn receive(self, context: &Data<Self::DataType>) -> LemmyResult<()> {
+    insert_received_activity(&self.id, context).await?;
+    let reporter = self.object.actor.dereference(context).await?;
+    let actor = self.actor.dereference(context).await?;
+    match self.object.object.dereference(context).await? {
+      PostOrComment::Post(post) => {
+        PostReport::resolve_apub(&mut context.pool(), post.id, reporter.id, actor.id).await?;
+      }
+      PostOrComment::Comment(comment) => {
+        CommentReport::resolve_apub(&mut context.pool(), comment.id, reporter.id, actor.id).await?;
+      }
+    };
+
+    let community = self.community(context).await?;
+    if community.local {
+      // forward to remote mods
+      let object_id = self.object.object.object_id(context).await?;
+      let announce = AnnouncableActivities::ResolveReport(self);
+      let announce = AnnounceActivity::new(announce.try_into()?, &community, context)?;
+      let inboxes = report_inboxes(object_id, &community, context).await?;
+      send_lemmy_activity(context, announce, &community, inboxes.clone(), false).await?;
+    }
+
+    Ok(())
+  }
+}
diff --git a/crates/apub/src/activities/mod.rs b/crates/apub/src/activities/mod.rs
index 9ab4023c04..4a284aa7ba 100644
--- a/crates/apub/src/activities/mod.rs
+++ b/crates/apub/src/activities/mod.rs
@@ -18,7 +18,7 @@ use crate::{
   },
   objects::{community::ApubCommunity, person::ApubPerson},
   protocol::activities::{
-    community::report::Report,
+    community::{report::Report, resolve_report::ResolveReport},
     create_or_update::{note::CreateOrUpdateNote, page::CreateOrUpdatePage},
     CreateOrUpdateType,
   },
@@ -378,7 +378,31 @@ pub async fn match_outgoing_activities(
         actor,
         community,
         reason,
-      } => Report::send(ObjectId::from(object_id), actor, community, reason, context).await,
+      } => {
+        Report::send(
+          ObjectId::from(object_id),
+          &actor.into(),
+          &community.into(),
+          reason,
+          context,
+        )
+        .await
+      }
+      SendResolveReport {
+        object_id,
+        actor,
+        report_creator,
+        community,
+      } => {
+        ResolveReport::send(
+          ObjectId::from(object_id),
+          &actor.into(),
+          &report_creator.into(),
+          &community.into(),
+          context,
+        )
+        .await
+      }
       AcceptFollower(community_id, person_id) => {
         send_accept_or_reject_follow(community_id, person_id, true, &context).await
       }
diff --git a/crates/apub/src/activity_lists.rs b/crates/apub/src/activity_lists.rs
index 0113d15f8a..68cfeb3771 100644
--- a/crates/apub/src/activity_lists.rs
+++ b/crates/apub/src/activity_lists.rs
@@ -9,6 +9,7 @@ use crate::{
         collection_remove::CollectionRemove,
         lock_page::{LockPage, UndoLockPage},
         report::Report,
+        resolve_report::ResolveReport,
         update::UpdateCommunity,
       },
       create_or_update::{note_wrapper::CreateOrUpdateNoteWrapper, page::CreateOrUpdatePage},
@@ -45,6 +46,7 @@ pub enum SharedInboxActivities {
   RejectFollow(RejectFollow),
   UndoFollow(UndoFollow),
   Report(Report),
+  ResolveReport(ResolveReport),
   AnnounceActivity(AnnounceActivity),
   /// This is a catch-all and needs to be last
   RawAnnouncableActivities(RawAnnouncableActivities),
@@ -67,6 +69,8 @@ pub enum AnnouncableActivities {
   CollectionRemove(CollectionRemove),
   LockPost(LockPage),
   UndoLockPost(UndoLockPage),
+  Report(Report),
+  ResolveReport(ResolveReport),
   // For compatibility with Pleroma/Mastodon (send only)
   Page(Page),
 }
@@ -89,6 +93,8 @@ impl InCommunity for AnnouncableActivities {
       CollectionRemove(a) => a.community(context).await,
       LockPost(a) => a.community(context).await,
       UndoLockPost(a) => a.community(context).await,
+      Report(a) => a.community(context).await,
+      ResolveReport(a) => a.community(context).await,
       Page(_) => Err(LemmyErrorType::NotFound.into()),
     }
   }
diff --git a/crates/apub/src/lib.rs b/crates/apub/src/lib.rs
index 99f781c318..aeb5a8d052 100644
--- a/crates/apub/src/lib.rs
+++ b/crates/apub/src/lib.rs
@@ -17,6 +17,7 @@ use lemmy_utils::{
 use moka::future::Cache;
 use serde_json::Value;
 use std::sync::{Arc, LazyLock};
+use tracing::debug;
 use url::Url;
 
 pub mod activities;
@@ -213,6 +214,7 @@ pub(crate) async fn check_apub_id_valid_with_strictness(
 /// This ensures that the same activity doesn't get received and processed more than once, which
 /// would be a waste of resources.
 async fn insert_received_activity(ap_id: &Url, data: &Data<LemmyContext>) -> LemmyResult<()> {
+  debug!("Received activity {}", ap_id.to_string());
   ReceivedActivity::create(&mut data.pool(), &ap_id.clone().into()).await?;
   Ok(())
 }
diff --git a/crates/apub/src/protocol/activities/community/mod.rs b/crates/apub/src/protocol/activities/community/mod.rs
index 0c52e6e772..ab734fc4fd 100644
--- a/crates/apub/src/protocol/activities/community/mod.rs
+++ b/crates/apub/src/protocol/activities/community/mod.rs
@@ -3,10 +3,12 @@ pub mod collection_add;
 pub mod collection_remove;
 pub mod lock_page;
 pub mod report;
+pub mod resolve_report;
 pub mod update;
 
 #[cfg(test)]
 mod tests {
+  use super::resolve_report::ResolveReport;
   use crate::protocol::{
     activities::community::{
       announce::AnnounceActivity,
@@ -44,6 +46,10 @@ mod tests {
     )?;
 
     test_parse_lemmy_item::<Report>("assets/lemmy/activities/community/report_page.json")?;
+    test_parse_lemmy_item::<ResolveReport>(
+      "assets/lemmy/activities/community/resolve_report_page.json",
+    )?;
+
     Ok(())
   }
 }
diff --git a/crates/apub/src/protocol/activities/community/report.rs b/crates/apub/src/protocol/activities/community/report.rs
index 71ecf4c5ce..5fd3415b18 100644
--- a/crates/apub/src/protocol/activities/community/report.rs
+++ b/crates/apub/src/protocol/activities/community/report.rs
@@ -49,14 +49,17 @@ pub(crate) enum ReportObject {
 }
 
 impl ReportObject {
-  pub async fn dereference(self, context: &Data<LemmyContext>) -> LemmyResult<PostOrComment> {
+  pub(crate) async fn dereference(
+    &self,
+    context: &Data<LemmyContext>,
+  ) -> LemmyResult<PostOrComment> {
     match self {
       ReportObject::Lemmy(l) => l.dereference(context).await,
       ReportObject::Mastodon(objects) => {
         for o in objects {
           // Find the first reported item which can be dereferenced as post or comment (Lemmy can
           // only handle one item per report).
-          let deref = ObjectId::from(o).dereference(context).await;
+          let deref = ObjectId::from(o.clone()).dereference(context).await;
           if deref.is_ok() {
             return deref;
           }
@@ -65,6 +68,27 @@ impl ReportObject {
       }
     }
   }
+
+  pub(crate) async fn object_id(
+    &self,
+    context: &Data<LemmyContext>,
+  ) -> LemmyResult<ObjectId<PostOrComment>> {
+    match self {
+      ReportObject::Lemmy(l) => Ok(l.clone()),
+      ReportObject::Mastodon(objects) => {
+        for o in objects {
+          // Same logic as above, but return the ID and not the object itself.
+          let deref = ObjectId::<PostOrComment>::from(o.clone())
+            .dereference(context)
+            .await;
+          if deref.is_ok() {
+            return Ok(o.clone().into());
+          }
+        }
+        Err(LemmyErrorType::NotFound.into())
+      }
+    }
+  }
 }
 
 #[async_trait::async_trait]
diff --git a/crates/apub/src/protocol/activities/community/resolve_report.rs b/crates/apub/src/protocol/activities/community/resolve_report.rs
new file mode 100644
index 0000000000..6f9c36234a
--- /dev/null
+++ b/crates/apub/src/protocol/activities/community/resolve_report.rs
@@ -0,0 +1,39 @@
+use super::report::Report;
+use crate::{
+  objects::{community::ApubCommunity, person::ApubPerson},
+  protocol::InCommunity,
+};
+use activitypub_federation::{
+  config::Data,
+  fetch::object_id::ObjectId,
+  protocol::helpers::deserialize_one,
+};
+use lemmy_api_common::context::LemmyContext;
+use lemmy_utils::error::LemmyResult;
+use serde::{Deserialize, Serialize};
+use strum::Display;
+use url::Url;
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Display)]
+pub enum ResolveType {
+  Resolve,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ResolveReport {
+  pub(crate) actor: ObjectId<ApubPerson>,
+  #[serde(deserialize_with = "deserialize_one")]
+  pub(crate) to: [ObjectId<ApubCommunity>; 1],
+  pub(crate) object: Report,
+  #[serde(rename = "type")]
+  pub(crate) kind: ResolveType,
+  pub(crate) id: Url,
+}
+
+#[async_trait::async_trait]
+impl InCommunity for ResolveReport {
+  async fn community(&self, context: &Data<LemmyContext>) -> LemmyResult<ApubCommunity> {
+    self.object.community(context).await
+  }
+}
diff --git a/crates/db_schema/src/impls/comment_report.rs b/crates/db_schema/src/impls/comment_report.rs
index 4c6a1e0d0b..0d21c5a8e6 100644
--- a/crates/db_schema/src/impls/comment_report.rs
+++ b/crates/db_schema/src/impls/comment_report.rs
@@ -1,9 +1,6 @@
 use crate::{
   newtypes::{CommentId, CommentReportId, PersonId},
-  schema::comment_report::{
-    comment_id,
-    dsl::{comment_report, resolved, resolver_id, updated},
-  },
+  schema::comment_report,
   source::comment_report::{CommentReport, CommentReportForm},
   traits::Reportable,
   utils::{get_conn, DbPool},
@@ -12,10 +9,12 @@ use chrono::Utc;
 use diesel::{
   dsl::{insert_into, update},
   result::Error,
+  BoolExpressionMethods,
   ExpressionMethods,
   QueryDsl,
 };
 use diesel_async::RunQueryDsl;
+use lemmy_utils::error::LemmyResult;
 
 #[async_trait]
 impl Reportable for CommentReport {
@@ -31,7 +30,7 @@ impl Reportable for CommentReport {
     comment_report_form: &CommentReportForm,
   ) -> Result<Self, Error> {
     let conn = &mut get_conn(pool).await?;
-    insert_into(comment_report)
+    insert_into(comment_report::table)
       .values(comment_report_form)
       .get_result::<Self>(conn)
       .await
@@ -48,27 +47,52 @@ impl Reportable for CommentReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(comment_report.find(report_id_))
+    update(comment_report::table.find(report_id_))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        comment_report::resolved.eq(true),
+        comment_report::resolver_id.eq(by_resolver_id),
+        comment_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
   }
 
+  async fn resolve_apub(
+    pool: &mut DbPool<'_>,
+    object_id: Self::ObjectIdType,
+    report_creator_id: PersonId,
+    resolver_id: PersonId,
+  ) -> LemmyResult<usize> {
+    let conn = &mut get_conn(pool).await?;
+    Ok(
+      update(
+        comment_report::table.filter(
+          comment_report::comment_id
+            .eq(object_id)
+            .and(comment_report::creator_id.eq(report_creator_id)),
+        ),
+      )
+      .set((
+        comment_report::resolved.eq(true),
+        comment_report::resolver_id.eq(resolver_id),
+        comment_report::updated.eq(Utc::now()),
+      ))
+      .execute(conn)
+      .await?,
+    )
+  }
+
   async fn resolve_all_for_object(
     pool: &mut DbPool<'_>,
     comment_id_: CommentId,
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(comment_report.filter(comment_id.eq(comment_id_)))
+    update(comment_report::table.filter(comment_report::comment_id.eq(comment_id_)))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        comment_report::resolved.eq(true),
+        comment_report::resolver_id.eq(by_resolver_id),
+        comment_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
@@ -85,11 +109,11 @@ impl Reportable for CommentReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(comment_report.find(report_id_))
+    update(comment_report::table.find(report_id_))
       .set((
-        resolved.eq(false),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        comment_report::resolved.eq(false),
+        comment_report::resolver_id.eq(by_resolver_id),
+        comment_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
diff --git a/crates/db_schema/src/impls/community_report.rs b/crates/db_schema/src/impls/community_report.rs
index 85c3cc5c0d..f62545072e 100644
--- a/crates/db_schema/src/impls/community_report.rs
+++ b/crates/db_schema/src/impls/community_report.rs
@@ -1,9 +1,6 @@
 use crate::{
   newtypes::{CommunityId, CommunityReportId, PersonId},
-  schema::community_report::{
-    community_id,
-    dsl::{community_report, resolved, resolver_id, updated},
-  },
+  schema::community_report,
   source::community_report::{CommunityReport, CommunityReportForm},
   traits::Reportable,
   utils::{get_conn, DbPool},
@@ -12,10 +9,12 @@ use chrono::Utc;
 use diesel::{
   dsl::{insert_into, update},
   result::Error,
+  BoolExpressionMethods,
   ExpressionMethods,
   QueryDsl,
 };
 use diesel_async::RunQueryDsl;
+use lemmy_utils::error::LemmyResult;
 
 #[async_trait]
 impl Reportable for CommunityReport {
@@ -31,7 +30,7 @@ impl Reportable for CommunityReport {
     community_report_form: &CommunityReportForm,
   ) -> Result<Self, Error> {
     let conn = &mut get_conn(pool).await?;
-    insert_into(community_report)
+    insert_into(community_report::table)
       .values(community_report_form)
       .get_result::<Self>(conn)
       .await
@@ -48,27 +47,52 @@ impl Reportable for CommunityReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(community_report.find(report_id_))
+    update(community_report::table.find(report_id_))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        community_report::resolved.eq(true),
+        community_report::resolver_id.eq(by_resolver_id),
+        community_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
   }
 
+  async fn resolve_apub(
+    pool: &mut DbPool<'_>,
+    object_id: Self::ObjectIdType,
+    report_creator_id: PersonId,
+    resolver_id: PersonId,
+  ) -> LemmyResult<usize> {
+    let conn = &mut get_conn(pool).await?;
+    Ok(
+      update(
+        community_report::table.filter(
+          community_report::community_id
+            .eq(object_id)
+            .and(community_report::creator_id.eq(report_creator_id)),
+        ),
+      )
+      .set((
+        community_report::resolved.eq(true),
+        community_report::resolver_id.eq(resolver_id),
+        community_report::updated.eq(Utc::now()),
+      ))
+      .execute(conn)
+      .await?,
+    )
+  }
+
   async fn resolve_all_for_object(
     pool: &mut DbPool<'_>,
     community_id_: CommunityId,
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(community_report.filter(community_id.eq(community_id_)))
+    update(community_report::table.filter(community_report::community_id.eq(community_id_)))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        community_report::resolved.eq(true),
+        community_report::resolver_id.eq(by_resolver_id),
+        community_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
@@ -85,11 +109,11 @@ impl Reportable for CommunityReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(community_report.find(report_id_))
+    update(community_report::table.find(report_id_))
       .set((
-        resolved.eq(false),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        community_report::resolved.eq(false),
+        community_report::resolver_id.eq(by_resolver_id),
+        community_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
diff --git a/crates/db_schema/src/impls/post_report.rs b/crates/db_schema/src/impls/post_report.rs
index 90ac030c18..70dcf16ad4 100644
--- a/crates/db_schema/src/impls/post_report.rs
+++ b/crates/db_schema/src/impls/post_report.rs
@@ -1,9 +1,7 @@
 use crate::{
+  diesel::BoolExpressionMethods,
   newtypes::{PersonId, PostId, PostReportId},
-  schema::post_report::{
-    dsl::{post_report, resolved, resolver_id, updated},
-    post_id,
-  },
+  schema::post_report,
   source::post_report::{PostReport, PostReportForm},
   traits::Reportable,
   utils::{get_conn, DbPool},
@@ -16,6 +14,7 @@ use diesel::{
   QueryDsl,
 };
 use diesel_async::RunQueryDsl;
+use lemmy_utils::error::LemmyResult;
 
 #[async_trait]
 impl Reportable for PostReport {
@@ -25,7 +24,7 @@ impl Reportable for PostReport {
 
   async fn report(pool: &mut DbPool<'_>, post_report_form: &PostReportForm) -> Result<Self, Error> {
     let conn = &mut get_conn(pool).await?;
-    insert_into(post_report)
+    insert_into(post_report::table)
       .values(post_report_form)
       .get_result::<Self>(conn)
       .await
@@ -37,27 +36,52 @@ impl Reportable for PostReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(post_report.find(report_id))
+    update(post_report::table.find(report_id))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        post_report::resolved.eq(true),
+        post_report::resolver_id.eq(by_resolver_id),
+        post_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
   }
 
+  async fn resolve_apub(
+    pool: &mut DbPool<'_>,
+    object_id: Self::ObjectIdType,
+    report_creator_id: PersonId,
+    resolver_id: PersonId,
+  ) -> LemmyResult<usize> {
+    let conn = &mut get_conn(pool).await?;
+    Ok(
+      update(
+        post_report::table.filter(
+          post_report::post_id
+            .eq(object_id)
+            .and(post_report::creator_id.eq(report_creator_id)),
+        ),
+      )
+      .set((
+        post_report::resolved.eq(true),
+        post_report::resolver_id.eq(resolver_id),
+        post_report::updated.eq(Utc::now()),
+      ))
+      .execute(conn)
+      .await?,
+    )
+  }
+
   async fn resolve_all_for_object(
     pool: &mut DbPool<'_>,
     post_id_: PostId,
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(post_report.filter(post_id.eq(post_id_)))
+    update(post_report::table.filter(post_report::post_id.eq(post_id_)))
       .set((
-        resolved.eq(true),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        post_report::resolved.eq(true),
+        post_report::resolver_id.eq(by_resolver_id),
+        post_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
@@ -69,11 +93,11 @@ impl Reportable for PostReport {
     by_resolver_id: PersonId,
   ) -> Result<usize, Error> {
     let conn = &mut get_conn(pool).await?;
-    update(post_report.find(report_id))
+    update(post_report::table.find(report_id))
       .set((
-        resolved.eq(false),
-        resolver_id.eq(by_resolver_id),
-        updated.eq(Utc::now()),
+        post_report::resolved.eq(false),
+        post_report::resolver_id.eq(by_resolver_id),
+        post_report::updated.eq(Utc::now()),
       ))
       .execute(conn)
       .await
diff --git a/crates/db_schema/src/impls/private_message_report.rs b/crates/db_schema/src/impls/private_message_report.rs
index 0a83bf637a..cc1f41769d 100644
--- a/crates/db_schema/src/impls/private_message_report.rs
+++ b/crates/db_schema/src/impls/private_message_report.rs
@@ -13,6 +13,7 @@ use diesel::{
   QueryDsl,
 };
 use diesel_async::RunQueryDsl;
+use lemmy_utils::error::{FederationError, LemmyResult};
 
 #[async_trait]
 impl Reportable for PrivateMessageReport {
@@ -46,6 +47,14 @@ impl Reportable for PrivateMessageReport {
       .execute(conn)
       .await
   }
+  async fn resolve_apub(
+    _pool: &mut DbPool<'_>,
+    _object_id: Self::ObjectIdType,
+    _report_creator_id: PersonId,
+    _resolver_id: PersonId,
+  ) -> LemmyResult<usize> {
+    Err(FederationError::Unreachable.into())
+  }
 
   // TODO: this is unused because private message doesn't have remove handler
   async fn resolve_all_for_object(
diff --git a/crates/db_schema/src/traits.rs b/crates/db_schema/src/traits.rs
index f010d3d1b6..56bbe1eb84 100644
--- a/crates/db_schema/src/traits.rs
+++ b/crates/db_schema/src/traits.rs
@@ -15,6 +15,7 @@ use diesel_async::{
   AsyncPgConnection,
   RunQueryDsl,
 };
+use lemmy_utils::error::LemmyResult;
 
 /// Returned by `diesel::delete`
 pub type Delete<T> = DeleteStatement<<T as HasTable>::Table, <T as IntoUpdateTarget>::WhereClause>;
@@ -154,6 +155,14 @@ pub trait Reportable {
     report_id: Self::IdType,
     resolver_id: PersonId,
   ) -> Result<usize, Error>
+  where
+    Self: Sized;
+  async fn resolve_apub(
+    pool: &mut DbPool<'_>,
+    object_id: Self::ObjectIdType,
+    report_creator_id: PersonId,
+    resolver_id: PersonId,
+  ) -> LemmyResult<usize>
   where
     Self: Sized;
   async fn resolve_all_for_object(


Test patch:
diff --git a/api_tests/prepare-drone-federation-test.sh b/api_tests/prepare-drone-federation-test.sh
index cb4fcf880d..64a921f529 100755
--- a/api_tests/prepare-drone-federation-test.sh
+++ b/api_tests/prepare-drone-federation-test.sh
@@ -84,13 +84,11 @@ LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \
   target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 &
 
 echo "start delta"
-# An instance with only an allowlist for beta
 LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
   LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \
   target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &
 
 echo "start epsilon"
-# An instance who has a blocklist, with lemmy-alpha blocked
 LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
   LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \
   target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 &
diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts
index 8ac46de222..a1a089d036 100644
--- a/api_tests/src/post.spec.ts
+++ b/api_tests/src/post.spec.ts
@@ -39,16 +39,20 @@ import {
   listReports,
   getMyUser,
   listInbox,
+  allowInstance,
 } from "./shared";
 import { PostView } from "lemmy-js-client/dist/types/PostView";
 import { AdminBlockInstanceParams } from "lemmy-js-client/dist/types/AdminBlockInstanceParams";
 import {
+  AddModToCommunity,
   EditSite,
+  LemmyHttp,
   PersonPostMentionView,
   PostReport,
   PostReportView,
   ReportCombinedView,
   ResolveObject,
+  ResolvePostReport,
 } from "lemmy-js-client";
 
 let betaCommunity: CommunityView | undefined;
@@ -57,6 +61,11 @@ beforeAll(async () => {
   await setupLogins();
   betaCommunity = (await resolveBetaCommunity(alpha)).community;
   expect(betaCommunity).toBeDefined();
+
+  // Hack: Force outgoing federation queue for beta to be created on epsilon,
+  // otherwise report test fails
+  let person = await resolvePerson(epsilon, "@lemmy_beta@lemmy-beta:8551");
+  expect(person.person).toBeDefined();
 });
 
 afterAll(unfollows);
@@ -679,16 +688,26 @@ test("Report a post", async () => {
   // Create post from alpha
   let alphaCommunity = (await resolveBetaCommunity(alpha)).community!;
   await followBeta(alpha);
-  let postRes = await createPost(alpha, alphaCommunity.community.id);
-  expect(postRes.post_view.post).toBeDefined();
+  let alphaPost = await createPost(alpha, alphaCommunity.community.id);
+  expect(alphaPost.post_view.post).toBeDefined();
 
-  let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post;
-  if (!alphaPost) {
-    throw "Missing alpha post";
-  }
+  // add remote mod on epsilon
+  await followBeta(epsilon);
+
+  let betaCommunity = (await resolveBetaCommunity(beta)).community!;
+  let epsilonUser = (
+    await resolvePerson(beta, "@lemmy_epsilon@lemmy-epsilon:8581")
+  ).person!;
+  let mod_params: AddModToCommunity = {
+    community_id: betaCommunity.community.id,
+    person_id: epsilonUser.person.id,
+    added: true,
+  };
+  let res = await beta.addModToCommunity(mod_params);
+  expect(res.moderators.length).toBe(2);
 
   // Send report from gamma
-  let gammaPost = (await resolvePost(gamma, alphaPost.post)).post!;
+  let gammaPost = (await resolvePost(gamma, alphaPost.post_view.post)).post!;
   let gammaReport = (
     await reportPost(gamma, gammaPost.post.id, randomString(10))
   ).post_report_view.post_report;
@@ -732,6 +751,45 @@ test("Report a post", async () => {
   //expect(alphaReport.original_post_url).toBe(gammaReport.original_post_url);
   expect(alphaReport.original_post_body).toBe(gammaReport.original_post_body);
   expect(alphaReport.reason).toBe(gammaReport.reason);
+
+  // Report was federated to remote mod instance
+  let epsilonReport = (
+    (await waitUntil(
+      () =>
+        listReports(epsilon).then(p =>
+          p.reports.find(r => {
+            return checkPostReportName(r, gammaReport);
+          }),
+        ),
+      res => !!res,
+    ))! as PostReportView
+  ).post_report;
+  expect(epsilonReport).toBeDefined();
+  expect(epsilonReport.resolved).toBe(false);
+  expect(epsilonReport.original_post_name).toBe(gammaReport.original_post_name);
+
+  // Resolve report as remote mod
+  let resolve_params: ResolvePostReport = {
+    report_id: epsilonReport.id,
+    resolved: true,
+  };
+  let resolve = await epsilon.resolvePostReport(resolve_params);
+  expect(resolve.post_report_view.post_report.resolved).toBeTruthy();
+
+  // Report should be marked resolved on community instance
+  let resolvedReport = (
+    (await waitUntil(
+      () =>
+        listReports(beta).then(p =>
+          p.reports.find(r => {
+            return checkPostReportName(r, gammaReport) && r.resolver != null;
+          }),
+        ),
+      res => !!res,
+    ))! as PostReportView
+  ).post_report;
+  expect(resolvedReport).toBeDefined();
+  expect(resolvedReport.resolved).toBe(true);
 });
 
 test("Fetch post via redirect", async () => {
@@ -852,7 +910,6 @@ test("Rewrite markdown links", async () => {
     "https://example.com/",
     `[link](${postRes1.post_view.post.ap_id})`,
   );
-  console.log(postRes2.post_view.post.body);
   expect(postRes2.post_view.post).toBeDefined();
 
   // fetch both posts from another instance
diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts
index 88dca842e3..6ea944c200 100644
--- a/api_tests/src/shared.ts
+++ b/api_tests/src/shared.ts
@@ -199,7 +199,7 @@ export async function setupLogins() {
   }
 }
 
-async function allowInstance(api: LemmyHttp, instance: string) {
+export async function allowInstance(api: LemmyHttp, instance: string) {
   const params: AdminAllowInstanceParams = {
     instance,
     allow: true,