From 31bc2f7c1014ee29e02ec7d968ec192886678aad Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 19 Oct 2020 09:26:49 +0200 Subject: [PATCH 01/10] Fix redirection after deleting a post Signed-off-by: Thomas Citharel --- js/src/views/Posts/Edit.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/views/Posts/Edit.vue b/js/src/views/Posts/Edit.vue index 7d6428e7..957ac2dc 100644 --- a/js/src/views/Posts/Edit.vue +++ b/js/src/views/Posts/Edit.vue @@ -97,7 +97,7 @@ import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql import { IPost, PostVisibility } from "../../types/post.model"; import Editor from "../../components/Editor.vue"; -import { IActor, IGroup } from "../../types/actor"; +import { IActor, IGroup, usernameWithDomain } from "../../types/actor"; import TagInput from "../../components/Event/TagInput.vue"; import RouteName from "../../router/name"; import Subtitle from "../../components/Utils/Subtitle.vue"; @@ -233,7 +233,7 @@ export default class EditPost extends Vue { if (data && this.post.attributedTo) { this.$router.push({ name: RouteName.POSTS, - params: { preferredUsername: this.post.attributedTo.preferredUsername }, + params: { preferredUsername: usernameWithDomain(this.post.attributedTo) }, }); } } From 4e028856923bab64ecf9103063d66b1ea65d7ad6 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 19 Oct 2020 09:27:13 +0200 Subject: [PATCH 02/10] Add title information to post list page Signed-off-by: Thomas Citharel --- js/src/views/Posts/List.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/js/src/views/Posts/List.vue b/js/src/views/Posts/List.vue index 30d51582..21470411 100644 --- a/js/src/views/Posts/List.vue +++ b/js/src/views/Posts/List.vue @@ -120,6 +120,16 @@ const POSTS_PAGE_LIMIT = 10; components: { PostElementItem, }, + metaInfo() { + return { + // if no subcomponents specify a metaInfo.title, this title will be used + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + title: this.$t("My groups") as string, + // all titles will be injected into this template + titleTemplate: "%s | Mobilizon", + }; + }, }) export default class PostList extends Vue { @Prop({ required: true, type: String }) preferredUsername!: string; From 0c4a7e02165ceabb071c66f6899e1473be6a0359 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 19 Oct 2020 09:30:20 +0200 Subject: [PATCH 03/10] Fix listing group public events Signed-off-by: Thomas Citharel --- lib/mobilizon/events/events.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 92d40e9b..7270c6d6 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -406,7 +406,7 @@ defmodule Mobilizon.Events do def list_public_events_for_actor(actor, page \\ nil, limit \\ nil) def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit), - do: list_organized_events_for_group(group, :public, nil, page, limit) + do: list_organized_events_for_group(group, :public, nil, nil, page, limit) def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do actor_id From fc1d3922117cf8ada34b214ab39ea03a95a7442b Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 19 Oct 2020 09:32:37 +0200 Subject: [PATCH 04/10] Couple of fixes for groups - Fix posts update federation and add tests - Fix posts deletion federation and add tests Signed-off-by: Thomas Citharel --- lib/federation/activity_pub/activity_pub.ex | 5 + lib/federation/activity_pub/federator.ex | 2 +- lib/federation/activity_pub/fetcher.ex | 4 + lib/federation/activity_pub/preloader.ex | 6 +- lib/federation/activity_pub/refresher.ex | 24 +- lib/federation/activity_pub/transmogrifier.ex | 67 ++++- lib/federation/activity_pub/types/posts.ex | 14 +- lib/federation/activity_pub/utils.ex | 7 +- .../transmogrifier/delete_test.exs | 212 ++++++++++++++++ .../transmogrifier/update_test.exs | 181 ++++++++++++++ .../activity_pub/transmogrifier_test.exs | 230 +----------------- .../group_post_update_activities.json | 24 ++ 12 files changed, 537 insertions(+), 239 deletions(-) create mode 100644 test/federation/activity_pub/transmogrifier/delete_test.exs create mode 100644 test/federation/activity_pub/transmogrifier/update_test.exs create mode 100644 test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex index 5791a47c..4332bdcb 100644 --- a/lib/federation/activity_pub/activity_pub.ex +++ b/lib/federation/activity_pub/activity_pub.ex @@ -15,6 +15,7 @@ defmodule Mobilizon.Federation.ActivityPub do Config, Discussions, Events, + Posts, Resources, Share, Users @@ -88,6 +89,7 @@ defmodule Mobilizon.Federation.ActivityPub do {:existing, Discussions.get_discussion_by_url(url)}, {:existing, nil} <- {:existing, Discussions.get_comment_from_url(url)}, {:existing, nil} <- {:existing, Resources.get_resource_by_url(url)}, + {:existing, nil} <- {:existing, Posts.get_post_by_url(url)}, {:existing, nil} <- {:existing, Actors.get_actor_by_url_2(url)}, {:existing, nil} <- {:existing, Actors.get_member_by_url(url)}, @@ -109,6 +111,9 @@ defmodule Mobilizon.Federation.ActivityPub do {:error, "Gone"} -> {:error, "Gone", entity} + + {:error, "Not found"} -> + {:error, "Not found", entity} end else {:ok, entity} diff --git a/lib/federation/activity_pub/federator.ex b/lib/federation/activity_pub/federator.ex index a419834e..74b947af 100644 --- a/lib/federation/activity_pub/federator.ex +++ b/lib/federation/activity_pub/federator.ex @@ -50,7 +50,7 @@ defmodule Mobilizon.Federation.ActivityPub.Federator do def handle(:incoming_ap_doc, params) do Logger.info("Handling incoming AP activity") - Logger.debug(inspect(params)) + Logger.debug(inspect(Map.drop(params, ["@context"]))) case Transmogrifier.handle_incoming(params) do {:ok, activity, _data} -> diff --git a/lib/federation/activity_pub/fetcher.ex b/lib/federation/activity_pub/fetcher.ex index 97ff31fd..a6ece6a3 100644 --- a/lib/federation/activity_pub/fetcher.ex +++ b/lib/federation/activity_pub/fetcher.ex @@ -32,6 +32,10 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do Logger.warn("Resource at #{url} is 410 Gone") {:error, "Gone"} + {:ok, %Tesla.Env{status: 404}} -> + Logger.warn("Resource at #{url} is 404 Gone") + {:error, "Not found"} + {:ok, %Tesla.Env{} = res} -> {:error, res} end diff --git a/lib/federation/activity_pub/preloader.ex b/lib/federation/activity_pub/preloader.ex index dedcb097..79db7a65 100644 --- a/lib/federation/activity_pub/preloader.ex +++ b/lib/federation/activity_pub/preloader.ex @@ -4,10 +4,11 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do """ # TODO: Move me in a more appropriate place - alias Mobilizon.{Actors, Discussions, Events, Resources} + alias Mobilizon.{Actors, Discussions, Events, Posts, Resources} alias Mobilizon.Actors.{Actor, Member} alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post alias Mobilizon.Resources.Resource alias Mobilizon.Tombstone @@ -23,6 +24,9 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do def maybe_preload(%Resource{url: url}), do: {:ok, Resources.get_resource_by_url_with_preloads(url)} + def maybe_preload(%Post{url: url}), + do: {:ok, Posts.get_post_by_url_with_preloads(url)} + def maybe_preload(%Actor{url: url}), do: {:ok, Actors.get_actor_by_url!(url, true)} def maybe_preload(%Member{} = member), do: {:ok, member} diff --git a/lib/federation/activity_pub/refresher.ex b/lib/federation/activity_pub/refresher.ex index d825199d..2169e521 100644 --- a/lib/federation/activity_pub/refresher.ex +++ b/lib/federation/activity_pub/refresher.ex @@ -118,7 +118,9 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do defp process_collection(_, _), do: :error - defp handling_element(data) when is_map(data) do + # If we're handling an activity + defp handling_element(%{"type" => activity_type} = data) + when activity_type in ["Create", "Update", "Delete"] do object = get_in(data, ["object"]) if object do @@ -128,6 +130,26 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do Transmogrifier.handle_incoming(data) end + # If we're handling directly an object + defp handling_element(data) when is_map(data) do + object = get_in(data, ["object"]) + + if object do + object |> Utils.get_url() |> Mobilizon.Tombstone.delete_uri_tombstone() + end + + activity = %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => data["actor"] || data["attributedTo"], + "attributedTo" => data["attributedTo"] || data["actor"], + "object" => data + } + + Transmogrifier.handle_incoming(activity) + end + defp handling_element(uri) when is_binary(uri) do ActivityPub.fetch_object_from_url(uri) end diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex index fb083089..544f9a51 100644 --- a/lib/federation/activity_pub/transmogrifier.ex +++ b/lib/federation/activity_pub/transmogrifier.ex @@ -418,6 +418,50 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do end end + def handle_incoming( + %{"type" => "Update", "object" => %{"type" => "Article"} = object, "actor" => _actor} = + update_data + ) do + with actor <- Utils.get_actor(update_data), + {:ok, %Actor{url: actor_url, suspended: false} = actor} <- + ActivityPub.get_or_fetch_actor_by_url(actor), + {:ok, %Post{} = old_post} <- + object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), + object_data <- Converter.Post.as_to_model_data(object), + {:origin_check, true} <- + {:origin_check, + Utils.origin_check?(actor_url, update_data["object"]) || + Utils.activity_actor_is_group_member?(actor, old_post)}, + {:ok, %Activity{} = activity, %Post{} = new_post} <- + ActivityPub.update(old_post, object_data, false) do + {:ok, activity, new_post} + else + _e -> + :error + end + end + + def handle_incoming( + %{"type" => "Update", "object" => %{"type" => type} = object, "actor" => _actor} = + update_data + ) + when type in ["ResourceCollection", "Document"] do + with actor <- Utils.get_actor(update_data), + {:ok, %Actor{url: actor_url, suspended: false}} <- + ActivityPub.get_or_fetch_actor_by_url(actor), + {:ok, %Resource{} = old_resource} <- + object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), + object_data <- Converter.Resource.as_to_model_data(object), + {:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)}, + {:ok, %Activity{} = activity, %Resource{} = new_resource} <- + ActivityPub.update(old_resource, object_data, false) do + {:ok, activity, new_resource} + else + _e -> + :error + end + end + def handle_incoming( %{"type" => "Update", "object" => %{"type" => "Member"} = object, "actor" => _actor} = update_data @@ -505,7 +549,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do with actor_url <- Utils.get_actor(data), {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url), object_id <- Utils.get_url(object), - {:error, "Gone", object} <- ActivityPub.fetch_object_from_url(object_id, force: true), + {:ok, object} <- can_delete_group_object(object_id), {:origin_check, true} <- {:origin_check, Utils.origin_check_from_id?(actor_url, object_id) || @@ -975,4 +1019,25 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do fetch_object_optionnally_authenticated(url, actor) end end + + defp can_delete_group_object(object_id) do + case ActivityPub.fetch_object_from_url(object_id, force: true) do + {:error, error_message, object} when error_message in ["Gone", "Not found"] -> + {:ok, object} + + {:ok, %{url: url} = object} -> + if Utils.are_same_origin?(url, Endpoint.url()), + do: {:ok, object}, + else: {:error, "Group object URL remote"} + + {:error, {:error, err}} -> + {:error, err} + + {:error, err} -> + {:error, err} + + err -> + err + end + end end diff --git a/lib/federation/activity_pub/types/posts.ex b/lib/federation/activity_pub/types/posts.ex index f7cd1104..00921ada 100644 --- a/lib/federation/activity_pub/types/posts.ex +++ b/lib/federation/activity_pub/types/posts.ex @@ -1,6 +1,6 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do @moduledoc false - alias Mobilizon.{Actors, Posts} + alias Mobilizon.{Actors, Posts, Tombstone} alias Mobilizon.Actors.Actor alias Mobilizon.Federation.ActivityPub.Types.Entity alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils @@ -11,6 +11,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do @behaviour Entity + @public_ap "https://www.w3.org/ns/activitystreams#Public" + @impl Entity def create(args, additional) do with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1), @@ -66,7 +68,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do def delete( %Post{ url: url, - attributed_to: %Actor{url: group_url} + attributed_to: %Actor{url: group_url, members_url: members_url} } = post, %Actor{url: actor_url} = actor, _local, @@ -77,11 +79,13 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do "type" => "Delete", "object" => Convertible.model_to_as(post), "id" => url <> "/delete", - "to" => [group_url] + "to" => [group_url, @public_ap, members_url] } - with {:ok, _post} <- Posts.delete_post(post), - {:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}") do + with {:ok, %Post{} = post} <- Posts.delete_post(post), + {:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}"), + {:ok, %Tombstone{} = _tombstone} <- + Tombstone.create_tombstone(%{uri: post.url, actor_id: actor.id}) do {:ok, activity_data, actor, post} end end diff --git a/lib/federation/activity_pub/utils.ex b/lib/federation/activity_pub/utils.ex index ee763bb3..4abf339d 100644 --- a/lib/federation/activity_pub/utils.ex +++ b/lib/federation/activity_pub/utils.ex @@ -287,9 +287,14 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id), do: origin_check_from_id?(id, other_id) - def activity_actor_is_group_member?(%Actor{id: actor_id}, object) do + def activity_actor_is_group_member?(%Actor{id: actor_id, url: actor_url}, object) do + Logger.debug( + "Checking if activity actor #{actor_url} is a member from group from #{object.url}" + ) + case Ownable.group_actor(object) do %Actor{type: :Group, id: group_id} -> + Logger.debug("Group object ID is #{group_id}") Actors.is_member?(actor_id, group_id) _ -> diff --git a/test/federation/activity_pub/transmogrifier/delete_test.exs b/test/federation/activity_pub/transmogrifier/delete_test.exs new file mode 100644 index 00000000..96cab59d --- /dev/null +++ b/test/federation/activity_pub/transmogrifier/delete_test.exs @@ -0,0 +1,212 @@ +defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.DeleteTest do + use Mobilizon.DataCase + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Oban.Testing, repo: Mobilizon.Storage.Repo + import Mobilizon.Factory + import ExUnit.CaptureLog + import Mox + + alias Mobilizon.{Actors, Discussions, Events, Posts} + alias Mobilizon.Actors.Actor + alias Mobilizon.Discussions.Comment + alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post + alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} + alias Mobilizon.Federation.ActivityStream.Convertible + alias Mobilizon.Service.HTTP.ActivityPub.Mock + + describe "handle incoming delete activities" do + test "it works for incoming deletes" do + %Actor{url: actor_url} = + actor = insert(:actor, url: "http://mobilizon.tld/@remote", domain: "mobilizon.tld") + + %Comment{url: comment_url} = + insert(:comment, + actor: nil, + actor_id: actor.id, + url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701" + ) + + Mock + |> expect(:call, fn + %{method: :get, url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701"}, + _opts -> + {:ok, %Tesla.Env{status: 410, body: "Gone"}} + end) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("id", comment_url) + + data = + data + |> Map.put("object", object) + |> Map.put("actor", actor_url) + + assert Discussions.get_comment_from_url(comment_url) + assert is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) + + {:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data) + + refute is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) + end + + test "it fails for incoming deletes with spoofed origin" do + comment = insert(:comment) + + announce_data = + File.read!("test/fixtures/mastodon-announce.json") + |> Jason.decode!() + |> Map.put("object", comment.url) + + {:ok, _, _} = Transmogrifier.handle_incoming(announce_data) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("id", comment.url) + + data = + data + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(data) + + assert Discussions.get_comment_from_url(comment.url) + end + + setup :set_mox_from_context + + test "it works for incoming actor deletes" do + %Actor{url: url} = + actor = insert(:actor, url: "https://framapiaf.org/users/admin", domain: "framapiaf.org") + + %Event{url: event1_url} = event1 = insert(:event, organizer_actor: actor) + insert(:event, organizer_actor: actor) + + %Comment{url: comment1_url} = comment1 = insert(:comment, actor: actor) + insert(:comment, actor: actor) + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Jason.decode!() + + Mock + |> expect(:call, fn + %{method: :get, url: "https://framapiaf.org/users/admin"}, _opts -> + {:ok, %Tesla.Env{status: 410, body: "Gone"}} + end) + + {:ok, _activity, _actor} = Transmogrifier.handle_incoming(data) + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :background) + + assert {:error, :actor_not_found} = Actors.get_actor_by_url(url) + assert {:error, :event_not_found} = Events.get_event(event1.id) + # Tombstone are cascade deleted, seems correct for now + # assert %Tombstone{} = Tombstone.find_tombstone(event1_url) + assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) + refute is_nil(deleted_at) + # assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) + end + + test "it fails for incoming actor deletes with spoofed origin" do + %{url: url} = insert(:actor) + deleted_actor_url = "https://framapiaf.org/users/admin" + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Jason.decode!() + |> Map.put("actor", url) + + deleted_actor_data = + File.read!("test/fixtures/mastodon-actor.json") + |> Jason.decode!() + |> Map.put("id", deleted_actor_url) + + Mock + |> expect(:call, fn + %{url: ^deleted_actor_url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: deleted_actor_data}} + end) + + assert capture_log(fn -> + assert :error == Transmogrifier.handle_incoming(data) + end) =~ "Object origin check failed" + + assert Actors.get_actor_by_url(url) + end + end + + describe "handle incoming delete activities for group posts" do + test "works for remote deletions" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(delete_data) + + refute is_nil(Posts.get_post_by_url(data["id"])) + end + + test "doesn't work for remote deletions if the actor is not a group member" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(delete_data) + + refute is_nil(Posts.get_post_by_url(data["id"])) + end + end +end diff --git a/test/federation/activity_pub/transmogrifier/update_test.exs b/test/federation/activity_pub/transmogrifier/update_test.exs new file mode 100644 index 00000000..0971ffb8 --- /dev/null +++ b/test/federation/activity_pub/transmogrifier/update_test.exs @@ -0,0 +1,181 @@ +defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.UpdateTest do + use Mobilizon.DataCase + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Oban.Testing, repo: Mobilizon.Storage.Repo + import Mobilizon.Factory + + alias Mobilizon.{Actors, Events, Posts} + alias Mobilizon.Actors.Actor + alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post + alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} + alias Mobilizon.Federation.ActivityStream.Convertible + + describe "handle incoming update activities" do + test "it works for incoming update activities on actors" do + use_cassette "activity_pub/update_actor_activity" do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + update_data["object"] + |> Map.put("actor", data["actor"]) + |> Map.put("id", data["actor"]) + + update_data = + update_data + |> Map.put("actor", data["actor"]) + |> Map.put("object", object) + + {:ok, %Activity{data: _data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + {:ok, %Actor{} = actor} = Actors.get_actor_by_url(update_data["actor"]) + assert actor.name == "nextsoft" + + assert actor.summary == "

Some bio

" + end + end + + test "it works for incoming update activities on events" do + use_cassette "activity_pub/event_update_activities" do + data = File.read!("test/fixtures/mobilizon-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}, %Event{id: event_id}} = + Transmogrifier.handle_incoming(data) + + assert_enqueued( + worker: Mobilizon.Service.Workers.BuildSearch, + args: %{event_id: event_id, op: :insert_search_event} + ) + + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data["object"] + |> Map.put("actor", data["actor"]) + |> Map.put("name", "My updated event") + |> Map.put("id", data["object"]["id"]) + |> Map.put("type", "Event") + + update_data = + update_data + |> Map.put("actor", data["actor"]) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + %Event{} = event = Events.get_event_by_url(data["object"]["id"]) + + assert_enqueued( + worker: Mobilizon.Service.Workers.BuildSearch, + args: %{event_id: event_id, op: :update_search_event} + ) + + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) + + assert event.title == "My updated event" + + assert event.description == data["object"]["content"] + end + end + + test "it works for incoming update activities on group posts" do + use_cassette "activity_pub/group_post_update_activities" do + %Actor{url: remote_actor_url} = remote_actor = insert(:actor, domain: "remote.domain") + group = insert(:group) + insert(:member, actor: remote_actor, parent: group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data + |> Map.put("actor", remote_actor_url) + |> Map.put("name", "My updated post") + |> Map.put("type", "Article") + + update_data = + update_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + %Post{id: updated_post_id, title: updated_post_title} = + Posts.get_post_by_url(data["object"]["id"]) + + assert updated_post_id == post.id + assert updated_post_title == "My updated post" + end + end + + test "it fails for incoming update activities on group posts when the actor is not a member from the group" do + use_cassette "activity_pub/group_post_update_activities" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data + |> Map.put("name", "My updated post") + |> Map.put("type", "Article") + + update_data = + update_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(update_data) + + %Post{id: updated_post_id, title: updated_post_title} = Posts.get_post_by_url(data["id"]) + + assert updated_post_id == post.id + refute updated_post_title == "My updated post" + end + end + + # test "it works for incoming update activities which lock the account" do + # data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + # update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + # object = + # update_data["object"] + # |> Map.put("actor", data["actor"]) + # |> Map.put("id", data["actor"]) + # |> Map.put("manuallyApprovesFollowers", true) + + # update_data = + # update_data + # |> Map.put("actor", data["actor"]) + # |> Map.put("object", object) + + # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) + + # user = User.get_cached_by_ap_id(data["actor"]) + # assert user.info["locked"] == true + # end + end +end diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs index cfee647c..a6d7c808 100644 --- a/test/federation/activity_pub/transmogrifier_test.exs +++ b/test/federation/activity_pub/transmogrifier_test.exs @@ -10,11 +10,10 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do use Oban.Testing, repo: Mobilizon.Storage.Repo import Mobilizon.Factory - import ExUnit.CaptureLog import Mock import Mox - alias Mobilizon.{Actors, Discussions, Events} + alias Mobilizon.{Actors, Discussions} alias Mobilizon.Actors.Actor alias Mobilizon.Discussions.Comment alias Mobilizon.Events.Event @@ -707,233 +706,6 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do end end - describe "handle incoming update activities" do - test "it works for incoming update activities on actors" do - use_cassette "activity_pub/update_actor_activity" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() - - {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: _data, local: false}, _} = - Transmogrifier.handle_incoming(update_data) - - {:ok, %Actor{} = actor} = Actors.get_actor_by_url(update_data["actor"]) - assert actor.name == "nextsoft" - - assert actor.summary == "

Some bio

" - end - end - - test "it works for incoming update activities on events" do - use_cassette "activity_pub/event_update_activities" do - data = File.read!("test/fixtures/mobilizon-post-activity.json") |> Jason.decode!() - - {:ok, %Activity{data: data, local: false}, %Event{id: event_id}} = - Transmogrifier.handle_incoming(data) - - assert_enqueued( - worker: Mobilizon.Service.Workers.BuildSearch, - args: %{event_id: event_id, op: :insert_search_event} - ) - - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) - - update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - object = - data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("name", "My updated event") - |> Map.put("id", data["object"]["id"]) - |> Map.put("type", "Event") - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}, _} = - Transmogrifier.handle_incoming(update_data) - - %Event{} = event = Events.get_event_by_url(data["object"]["id"]) - - assert_enqueued( - worker: Mobilizon.Service.Workers.BuildSearch, - args: %{event_id: event_id, op: :update_search_event} - ) - - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) - - assert event.title == "My updated event" - - assert event.description == data["object"]["content"] - end - end - - # test "it works for incoming update activities which lock the account" do - # data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() - - # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - # update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - # object = - # update_data["object"] - # |> Map.put("actor", data["actor"]) - # |> Map.put("id", data["actor"]) - # |> Map.put("manuallyApprovesFollowers", true) - - # update_data = - # update_data - # |> Map.put("actor", data["actor"]) - # |> Map.put("object", object) - - # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - # user = User.get_cached_by_ap_id(data["actor"]) - # assert user.info["locked"] == true - # end - end - - describe "handle incoming delete activities" do - test "it works for incoming deletes" do - %Actor{url: actor_url} = - actor = insert(:actor, url: "http://mobilizon.tld/@remote", domain: "mobilizon.tld") - - %Comment{url: comment_url} = - insert(:comment, - actor: nil, - actor_id: actor.id, - url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701" - ) - - Mock - |> expect(:call, fn - %{method: :get, url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701"}, - _opts -> - {:ok, %Tesla.Env{status: 410, body: "Gone"}} - end) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Jason.decode!() - - object = - data["object"] - |> Map.put("id", comment_url) - - data = - data - |> Map.put("object", object) - |> Map.put("actor", actor_url) - - assert Discussions.get_comment_from_url(comment_url) - assert is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) - - {:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data) - - refute is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) - end - - test "it fails for incoming deletes with spoofed origin" do - comment = insert(:comment) - - announce_data = - File.read!("test/fixtures/mastodon-announce.json") - |> Jason.decode!() - |> Map.put("object", comment.url) - - {:ok, _, _} = Transmogrifier.handle_incoming(announce_data) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Jason.decode!() - - object = - data["object"] - |> Map.put("id", comment.url) - - data = - data - |> Map.put("object", object) - - :error = Transmogrifier.handle_incoming(data) - - assert Discussions.get_comment_from_url(comment.url) - end - - setup :set_mox_from_context - - test "it works for incoming actor deletes" do - %Actor{url: url} = - actor = insert(:actor, url: "https://framapiaf.org/users/admin", domain: "framapiaf.org") - - %Event{url: event1_url} = event1 = insert(:event, organizer_actor: actor) - insert(:event, organizer_actor: actor) - - %Comment{url: comment1_url} = comment1 = insert(:comment, actor: actor) - insert(:comment, actor: actor) - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Jason.decode!() - - Mock - |> expect(:call, fn - %{method: :get, url: "https://framapiaf.org/users/admin"}, _opts -> - {:ok, %Tesla.Env{status: 410, body: "Gone"}} - end) - - {:ok, _activity, _actor} = Transmogrifier.handle_incoming(data) - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :background) - - assert {:error, :actor_not_found} = Actors.get_actor_by_url(url) - assert {:error, :event_not_found} = Events.get_event(event1.id) - # Tombstone are cascade deleted, seems correct for now - # assert %Tombstone{} = Tombstone.find_tombstone(event1_url) - assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) - refute is_nil(deleted_at) - # assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) - end - - test "it fails for incoming actor deletes with spoofed origin" do - %{url: url} = insert(:actor) - deleted_actor_url = "https://framapiaf.org/users/admin" - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Jason.decode!() - |> Map.put("actor", url) - - deleted_actor_data = - File.read!("test/fixtures/mastodon-actor.json") - |> Jason.decode!() - |> Map.put("id", deleted_actor_url) - - Mock - |> expect(:call, fn - %{url: ^deleted_actor_url}, _opts -> - {:ok, %Tesla.Env{status: 200, body: deleted_actor_data}} - end) - - assert capture_log(fn -> - assert :error == Transmogrifier.handle_incoming(data) - end) =~ "Object origin check failed" - - assert Actors.get_actor_by_url(url) - end - end - describe "handle tombstones" do setup :verify_on_exit! diff --git a/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json b/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json new file mode 100644 index 00000000..35e46a0b --- /dev/null +++ b/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json @@ -0,0 +1,24 @@ +[ + { + "request": { + "body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://litepub.social/litepub/context.jsonld\",{\"Hashtag\":\"as:Hashtag\",\"PostalAddress\":\"sc:PostalAddress\",\"address\":{\"@id\":\"sc:address\",\"@type\":\"sc:PostalAddress\"},\"addressCountry\":\"sc:addressCountry\",\"addressLocality\":\"sc:addressLocality\",\"addressRegion\":\"sc:addressRegion\",\"anonymousParticipationEnabled\":{\"@id\":\"mz:anonymousParticipationEnabled\",\"@type\":\"sc:Boolean\"},\"category\":\"sc:category\",\"commentsEnabled\":{\"@id\":\"pt:commentsEnabled\",\"@type\":\"sc:Boolean\"},\"discoverable\":\"toot:discoverable\",\"ical\":\"http://www.w3.org/2002/12/cal/ical#\",\"joinMode\":{\"@id\":\"mz:joinMode\",\"@type\":\"mz:joinModeType\"},\"joinModeType\":{\"@id\":\"mz:joinModeType\",\"@type\":\"rdfs:Class\"},\"location\":{\"@id\":\"sc:location\",\"@type\":\"sc:Place\"},\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"maximumAttendeeCapacity\":\"sc:maximumAttendeeCapacity\",\"mz\":\"https://joinmobilizon.org/ns#\",\"participationMessage\":{\"@id\":\"mz:participationMessage\",\"@type\":\"sc:Text\"},\"postalCode\":\"sc:postalCode\",\"pt\":\"https://joinpeertube.org/ns#\",\"repliesModerationOption\":{\"@id\":\"mz:repliesModerationOption\",\"@type\":\"mz:repliesModerationOptionType\"},\"repliesModerationOptionType\":{\"@id\":\"mz:repliesModerationOptionType\",\"@type\":\"rdfs:Class\"},\"sc\":\"http://schema.org#\",\"streetAddress\":\"sc:streetAddress\",\"toot\":\"http://joinmastodon.org/ns#\",\"uuid\":\"sc:identifier\"}],\"actor\":\"http://mobilizon.test/@myGroup0\",\"cc\":[],\"id\":\"http://mobilizon.test/announces/839e0ffc-f437-48db-afba-9ce1e971e938\",\"object\":{\"actor\":\"http://mobilizon.test/@thomas0\",\"attributedTo\":\"http://mobilizon.test/@myGroup0\",\"content\":\"The HTMLbody for my Article\",\"id\":\"http://mobilizon.test/p/6a482d5f-94fc-446b-84bb-d4d386d5dd45\",\"name\":\"My updated post\",\"published\":\"2020-10-19T08:37:52Z\",\"type\":\"Article\"},\"to\":[\"http://mobilizon.test/@myGroup0/members\"],\"type\":\"Announce\"}", + "headers": { + "Content-Type": "application/activity+json", + "signature": "keyId=\"http://mobilizon.test/@myGroup0#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-length date digest host\",signature=\"P+7rSSUeUBdX74wbvSEe4roG7yh7MfpF6s4tjv5q1kbeVKtXZRyfC1LqgVNCADZYXFqYlMvfF7DiaRQRiMznGWawM/QXK08eXiAVihYK28Pa56BfI68OUakd+FptlwfB4WJ4Jc7xi1z+iarv+EvlFxjkG5pgwL4mW49rvNnigELzypGtp2bj/2BhiBItHutvOju1MwLR1EBQFJBSZDVZZKbHTcV4KbGtbYvkWUbH8fZbe3fgctKlvO/z9kw+yBTTIEE1O18F4HiJ17nYtaaxv3/vl5RxcjYLpf+QQzkaPOsSLZs8zpIZZp3BbLtPh+OGwkyK9PBQsaI0N1ZSLQ5gaQ==\"", + "digest": "SHA-256=EyZ+uZ/Vv2lUK8ozgOHBpnoUWUM5WQHATQb1tEMldNU=", + "date": "Mon, 19 Oct 2020 08:37:52 GMT" + }, + "method": "post", + "options": [], + "request_body": "", + "url": "http://mobilizon.test/inbox" + }, + "response": { + "binary": false, + "body": "nxdomain", + "headers": [], + "status_code": null, + "type": "error" + } + } +] \ No newline at end of file From 23dcb47ce5070d5ff10377451e6c0ce5d8bf9afd Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 19 Oct 2020 19:21:39 +0200 Subject: [PATCH 05/10] Make sure only group moderators can update/delete events, posts Signed-off-by: Thomas Citharel --- js/src/views/Resources/ResourceFolder.vue | 100 +++++++--------- lib/federation/activity_pub/transmogrifier.ex | 47 ++++++-- lib/federation/activity_pub/types/actors.ex | 3 + lib/federation/activity_pub/types/comments.ex | 3 + .../activity_pub/types/discussions.ex | 3 + lib/federation/activity_pub/types/entity.ex | 26 +++++ lib/federation/activity_pub/types/events.ex | 4 + lib/federation/activity_pub/types/posts.ex | 7 +- .../activity_pub/types/resources.ex | 3 + .../activity_pub/types/todo_lists.ex | 3 + lib/federation/activity_pub/types/todos.ex | 3 + .../activity_pub/types/tombstones.ex | 3 + lib/federation/activity_pub/utils.ex | 77 +++++++++++-- lib/graphql/error.ex | 1 + lib/graphql/resolvers/post.ex | 4 +- lib/graphql/resolvers/resource.ex | 16 ++- .../transmogrifier/delete_test.exs | 109 +++++++++++++++++- .../transmogrifier/update_test.exs | 102 +++++++++++----- 18 files changed, 400 insertions(+), 114 deletions(-) diff --git a/js/src/views/Resources/ResourceFolder.vue b/js/src/views/Resources/ResourceFolder.vue index 1dcb8595..8def62dc 100644 --- a/js/src/views/Resources/ResourceFolder.vue +++ b/js/src/views/Resources/ResourceFolder.vue @@ -114,6 +114,7 @@ :resource="localResource" :group="resource.actor" @delete="deleteResource" + @rename="handleRename" @move="handleMove" v-else /> @@ -143,7 +144,7 @@