From 641129dc74c080880125f7d571c55d595526c95b Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Wed, 14 Aug 2019 17:45:11 +0200 Subject: [PATCH] Federate participations Signed-off-by: Thomas Citharel --- lib/mobilizon/actors/actors.ex | 4 + lib/mobilizon/events/events.ex | 68 +++- lib/mobilizon/events/participant.ex | 49 ++- lib/mobilizon_web/api/events.ex | 14 - lib/mobilizon_web/api/participations.ex | 24 ++ lib/mobilizon_web/resolvers/event.ex | 43 +-- lib/service/activity_pub/activity_pub.ex | 82 ++++- .../activity_pub/converters/comment.ex | 2 - .../activity_pub/converters/participant.ex | 29 ++ lib/service/activity_pub/transmogrifier.ex | 313 +++++++++++++++--- lib/service/activity_pub/utils.ex | 20 +- lib/service/activity_pub/visibility.ex | 1 - ...60207_add_url_and_uuid_to_participants.exs | 43 +++ test/fixtures/mobilizon-join-activity.json | 22 ++ test/fixtures/mobilizon-leave-activity.json | 22 ++ .../activity_pub/transmogrifier_test.exs | 116 ++++++- .../resolvers/participant_resolver_test.exs | 14 +- test/support/factory.ex | 9 +- 18 files changed, 752 insertions(+), 123 deletions(-) create mode 100644 lib/mobilizon_web/api/participations.ex create mode 100644 lib/service/activity_pub/converters/participant.ex create mode 100644 priv/repo/migrations/20190813160207_add_url_and_uuid_to_participants.exs create mode 100644 test/fixtures/mobilizon-join-activity.json create mode 100644 test/fixtures/mobilizon-leave-activity.json diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 5792d9cad..5cd0e490b 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -58,6 +58,10 @@ defmodule Mobilizon.Actors do Repo.get!(Actor, id) end + def get_actor(id) do + Repo.get(Actor, id) + end + # Get actor by ID and preload organized events, followers and followings @spec get_actor_with_everything(integer()) :: Ecto.Query.t() defp do_get_actor_with_everything(id) do diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 6371f06f1..ccb198339 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -214,6 +214,27 @@ defmodule Mobilizon.Events do ]) end + @doc """ + Gets a single event, with all associations loaded. + """ + def get_event_full(id) do + case Repo.get(Event, id) do + %Event{} = event -> + {:ok, + Repo.preload(event, [ + :organizer_actor, + :sessions, + :tracks, + :tags, + :participants, + :physical_address + ])} + + err -> + {:error, err} + end + end + @doc """ Gets an event by it's URL """ @@ -700,17 +721,28 @@ defmodule Mobilizon.Events do [%Participant{}, ...] """ - def list_participants_for_event(uuid, page \\ nil, limit \\ nil) do - Repo.all( - from( - p in Participant, - join: e in Event, - on: p.event_id == e.id, - where: e.uuid == ^uuid and p.role != ^:not_approved, - preload: [:actor] - ) - |> paginate(page, limit) + def list_participants_for_event(uuid, page \\ nil, limit \\ nil, include_not_improved \\ false) + + def list_participants_for_event(uuid, page, limit, false) do + query = do_list_participants_for_event(uuid, page, limit) + query = from(p in query, where: p.role != ^:not_approved) + Repo.all(query) + end + + def list_participants_for_event(uuid, page, limit, true) do + query = do_list_participants_for_event(uuid, page, limit) + Repo.all(query) + end + + defp do_list_participants_for_event(uuid, page, limit) do + from( + p in Participant, + join: e in Event, + on: p.event_id == e.id, + where: e.uuid == ^uuid, + preload: [:actor] ) + |> paginate(page, limit) end @doc """ @@ -787,6 +819,15 @@ defmodule Mobilizon.Events do end end + def get_participant_by_url(url) do + Repo.one( + from(p in Participant, + where: p.url == ^url, + preload: [:actor, :event] + ) + ) + end + @doc """ Creates a participant. @@ -800,9 +841,10 @@ defmodule Mobilizon.Events do """ def create_participant(attrs \\ %{}) do - %Participant{} - |> Participant.changeset(attrs) - |> Repo.insert() + with {:ok, %Participant{} = participant} <- + %Participant{} |> Participant.changeset(attrs) |> Repo.insert() do + {:ok, Repo.preload(participant, [:event, :actor])} + end end @doc """ diff --git a/lib/mobilizon/events/participant.ex b/lib/mobilizon/events/participant.ex index 276891996..144dc7b38 100644 --- a/lib/mobilizon/events/participant.ex +++ b/lib/mobilizon/events/participant.ex @@ -17,9 +17,10 @@ defmodule Mobilizon.Events.Participant do alias Mobilizon.Events.{Participant, Event} alias Mobilizon.Actors.Actor - @primary_key false + @primary_key {:id, :binary_id, autogenerate: true} schema "participants" do field(:role, Mobilizon.Events.ParticipantRoleEnum, default: :participant) + field(:url, :string) belongs_to(:event, Event, primary_key: true) belongs_to(:actor, Actor, primary_key: true) @@ -29,7 +30,49 @@ defmodule Mobilizon.Events.Participant do @doc false def changeset(%Participant{} = participant, attrs) do participant - |> Ecto.Changeset.cast(attrs, [:role, :event_id, :actor_id]) - |> validate_required([:role, :event_id, :actor_id]) + |> Ecto.Changeset.cast(attrs, [:url, :role, :event_id, :actor_id]) + |> generate_url() + |> validate_required([:url, :role, :event_id, :actor_id]) + end + + # If there's a blank URL that's because we're doing the first insert + defp generate_url(%Ecto.Changeset{data: %Participant{url: nil}} = changeset) do + case fetch_change(changeset, :url) do + {:ok, _url} -> changeset + :error -> do_generate_url(changeset) + end + end + + # Most time just go with the given URL + defp generate_url(%Ecto.Changeset{} = changeset), do: changeset + + defp do_generate_url(%Ecto.Changeset{} = changeset) do + uuid = Ecto.UUID.generate() + + changeset + |> put_change( + :url, + "#{MobilizonWeb.Endpoint.url()}/join/event/#{uuid}" + ) + |> put_change( + :id, + uuid + ) + end + + @doc """ + We check that the actor asking to leave the event is not it's only organizer + We start by fetching the list of organizers and if there's only one of them + and that it's the actor requesting leaving the event we return true + """ + @spec check_that_participant_is_not_only_organizer(integer(), integer()) :: boolean() + def check_that_participant_is_not_only_organizer(event_id, actor_id) do + case Mobilizon.Events.list_organizers_participants_for_event(event_id) do + [%Participant{actor: %Actor{id: participant_actor_id}}] -> + participant_actor_id == actor_id + + _ -> + false + end end end diff --git a/lib/mobilizon_web/api/events.ex b/lib/mobilizon_web/api/events.ex index 463667395..b80d94aae 100644 --- a/lib/mobilizon_web/api/events.ex +++ b/lib/mobilizon_web/api/events.ex @@ -2,15 +2,12 @@ defmodule MobilizonWeb.API.Events do @moduledoc """ API for Events """ - alias Mobilizon.Addresses alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils alias MobilizonWeb.API.Utils - @visibility %{"PUBLIC" => :public, "PRIVATE" => :private} - @doc """ Create an event """ @@ -51,15 +48,4 @@ defmodule MobilizonWeb.API.Events do }) end end - - defp get_physical_address(address_id) when is_number(address_id), - do: Addresses.get_address!(address_id) - - defp get_physical_address(address_id) when is_binary(address_id) do - with {address_id, ""} <- Integer.parse(address_id) do - get_physical_address(address_id) - end - end - - defp get_physical_address(nil), do: nil end diff --git a/lib/mobilizon_web/api/participations.ex b/lib/mobilizon_web/api/participations.ex new file mode 100644 index 000000000..b105f1588 --- /dev/null +++ b/lib/mobilizon_web/api/participations.ex @@ -0,0 +1,24 @@ +defmodule MobilizonWeb.API.Participations do + @moduledoc """ + Common API to join events and groups + """ + + alias Mobilizon.Actors.Actor + alias Mobilizon.Events.{Event, Participant} + alias Mobilizon.Service.ActivityPub + require Logger + + @spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()} + def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do + with {:error, :participant_not_found} <- Mobilizon.Events.get_participant(event_id, actor_id), + {:ok, activity, participant} <- ActivityPub.join(event, actor, true) do + {:ok, activity, participant} + end + end + + def leave(%Event{} = event, %Actor{} = actor) do + with {:ok, activity, participant} <- ActivityPub.leave(event, actor, true) do + {:ok, activity, participant} + end + end +end diff --git a/lib/mobilizon_web/resolvers/event.ex b/lib/mobilizon_web/resolvers/event.ex index d3155bed5..255a6ddc5 100644 --- a/lib/mobilizon_web/resolvers/event.ex +++ b/lib/mobilizon_web/resolvers/event.ex @@ -8,7 +8,6 @@ defmodule MobilizonWeb.Resolvers.Event do alias Mobilizon.Events alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Media.Picture - alias Mobilizon.Actors.Actor alias Mobilizon.Users.User alias MobilizonWeb.Resolvers.Person @@ -108,20 +107,18 @@ defmodule MobilizonWeb.Resolvers.Event do } ) do with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), - {:ok, %Event{} = event} <- Mobilizon.Events.get_event(event_id), + {:has_event, {:ok, %Event{} = event}} <- + {:has_event, Mobilizon.Events.get_event_full(event_id)}, {:error, :participant_not_found} <- Mobilizon.Events.get_participant(event_id, actor_id), - role <- Mobilizon.Events.get_default_participant_role(event), - {:ok, participant} <- - Mobilizon.Events.create_participant(%{ - role: role, - event_id: event.id, - actor_id: actor.id - }), + {:ok, _activity, participant} <- MobilizonWeb.API.Participations.join(event, actor), participant <- Map.put(participant, :event, event) |> Map.put(:actor, Person.proxify_pictures(actor)) do {:ok, participant} else + {:has_event, _} -> + {:error, "Event with this ID #{inspect(event_id)} doesn't exist"} + {:is_owned, false} -> {:error, "Actor id is not owned by authenticated user"} @@ -149,15 +146,15 @@ defmodule MobilizonWeb.Resolvers.Event do } } ) do - with {:is_owned, true, _} <- User.owns_actor(user, actor_id), - {:ok, %Participant{} = participant} <- - Mobilizon.Events.get_participant(event_id, actor_id), - {:only_organizer, false} <- - {:only_organizer, check_that_participant_is_not_only_organizer(event_id, actor_id)}, - {:ok, _} <- - Mobilizon.Events.delete_participant(participant) do + with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), + {:has_event, {:ok, %Event{} = event}} <- + {:has_event, Mobilizon.Events.get_event_full(event_id)}, + {:ok, _activity, _participant} <- MobilizonWeb.API.Participations.leave(event, actor) do {:ok, %{event: %{id: event_id}, actor: %{id: actor_id}}} else + {:has_event, _} -> + {:error, "Event with this ID #{inspect(event_id)} doesn't exist"} + {:is_owned, false} -> {:error, "Actor id is not owned by authenticated user"} @@ -173,20 +170,6 @@ defmodule MobilizonWeb.Resolvers.Event do {:error, "You need to be logged-in to leave an event"} end - # We check that the actor asking to leave the event is not it's only organizer - # We start by fetching the list of organizers and if there's only one of them - # and that it's the actor requesting leaving the event we return true - @spec check_that_participant_is_not_only_organizer(integer(), integer()) :: boolean() - defp check_that_participant_is_not_only_organizer(event_id, actor_id) do - case Mobilizon.Events.list_organizers_participants_for_event(event_id) do - [%Participant{actor: %Actor{id: participant_actor_id}}] -> - participant_actor_id == actor_id - - _ -> - false - end - end - @doc """ Create an event """ diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index 48f69ca3d..3823d7c33 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -11,18 +11,19 @@ defmodule Mobilizon.Service.ActivityPub do """ alias Mobilizon.Events - alias Mobilizon.Events.{Event, Comment} + alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Service.ActivityPub.Transmogrifier alias Mobilizon.Service.WebFinger alias Mobilizon.Activity alias Mobilizon.Actors - alias Mobilizon.Actors.Actor - alias Mobilizon.Actors.Follower + alias Mobilizon.Actors.{Actor, Follower} alias Mobilizon.Service.Federator alias Mobilizon.Service.HTTPSignatures.Signature + alias Mobilizon.Service.ActivityPub.Convertible + require Logger import Mobilizon.Service.ActivityPub.Utils import Mobilizon.Service.ActivityPub.Visibility @@ -148,7 +149,7 @@ defmodule Mobilizon.Service.ActivityPub do end end - def accept(%{to: to, actor: actor, object: object} = params, activity_follow_id \\ nil) do + def accept(%{to: to, actor: actor, object: object} = params, activity_wrapper_id \\ nil) do # only accept false as false value local = !(params[:local] == false) @@ -157,7 +158,7 @@ defmodule Mobilizon.Service.ActivityPub do "type" => "Accept", "actor" => actor, "object" => object, - "id" => activity_follow_id || get_url(object) <> "/activity" + "id" => activity_wrapper_id || get_url(object) <> "/activity" }, {:ok, activity, object} <- insert(data, local), :ok <- maybe_federate(activity) do @@ -165,11 +166,17 @@ defmodule Mobilizon.Service.ActivityPub do end end - def reject(%{to: to, actor: actor, object: object} = params) do + def reject(%{to: to, actor: actor, object: object} = params, activity_wrapper_id \\ nil) do # only accept false as false value local = !(params[:local] == false) - with data <- %{"to" => to, "type" => "Reject", "actor" => actor.url, "object" => object}, + with data <- %{ + "to" => to, + "type" => "Reject", + "actor" => actor, + "object" => object, + "id" => activity_wrapper_id || get_url(object) <> "/activity" + }, {:ok, activity, object} <- insert(data, local), :ok <- maybe_federate(activity) do {:ok, activity, object} @@ -383,6 +390,65 @@ defmodule Mobilizon.Service.ActivityPub do end end + def join(object, actor, local \\ true) + + def join(%Event{} = event, %Actor{} = actor, local) do + with role <- Mobilizon.Events.get_default_participant_role(event), + {:ok, %Participant{} = participant} <- + Mobilizon.Events.create_participant(%{ + role: role, + event_id: event.id, + actor_id: actor.id + }), + join_data <- Convertible.model_to_as(participant), + join_data <- Map.put(join_data, "to", [event.organizer_actor.url]), + join_data <- Map.put(join_data, "cc", []), + {:ok, activity, _} <- insert(join_data, local), + :ok <- maybe_federate(activity) do + if role === :participant do + accept( + %{to: [actor.url], actor: event.organizer_actor.url, object: join_data["id"]}, + "#{MobilizonWeb.Endpoint.url()}/accept/join/#{participant.id}" + ) + end + + {:ok, activity, participant} + end + end + + # TODO: Implement me + def join(%Actor{type: :Group} = _group, %Actor{} = _actor, _local) do + :error + end + + def leave(object, actor, local \\ true) + + # TODO: If we want to use this for exclusion we need to have an extra field for the actor that excluded the participant + def leave( + %Event{id: event_id, url: event_url} = event, + %Actor{id: actor_id, url: actor_url} = _actor, + local + ) do + with {:only_organizer, false} <- + {:only_organizer, + Participant.check_that_participant_is_not_only_organizer(event_id, actor_id)}, + {:ok, %Participant{} = participant} <- + Mobilizon.Events.get_participant(event_id, actor_id), + {:ok, %Participant{} = participant} <- Mobilizon.Events.delete_participant(participant), + leave_data <- %{ + "type" => "Leave", + # If it's an exclusion it should be something else + "actor" => actor_url, + "object" => event_url, + "to" => [event.organizer_actor.url], + "cc" => [] + }, + {:ok, activity, _} <- insert(leave_data, local), + :ok <- maybe_federate(activity) do + {:ok, activity, participant} + end + end + @doc """ Create an actor locally by it's URL (AP ID) """ @@ -482,7 +548,7 @@ defmodule Mobilizon.Service.ActivityPub do """ def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do Logger.info("Federating #{id} to #{inbox}") - %URI{host: host, path: path} = URI.parse(inbox) + %URI{host: host, path: _path} = URI.parse(inbox) digest = Signature.build_digest(json) date = Signature.generate_date_header() diff --git a/lib/service/activity_pub/converters/comment.ex b/lib/service/activity_pub/converters/comment.ex index 1513dff3c..d412ad457 100644 --- a/lib/service/activity_pub/converters/comment.ex +++ b/lib/service/activity_pub/converters/comment.ex @@ -10,8 +10,6 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Comment do alias Mobilizon.Events.Event alias Mobilizon.Service.ActivityPub.Converter alias Mobilizon.Service.ActivityPub - alias MobilizonWeb.Router.Helpers, as: Routes - alias MobilizonWeb.Endpoint require Logger @behaviour Converter diff --git a/lib/service/activity_pub/converters/participant.ex b/lib/service/activity_pub/converters/participant.ex new file mode 100644 index 000000000..35de9d85f --- /dev/null +++ b/lib/service/activity_pub/converters/participant.ex @@ -0,0 +1,29 @@ +defmodule Mobilizon.Service.ActivityPub.Converters.Participant do + @moduledoc """ + Flag converter + + This module allows to convert reports from ActivityStream format to our own internal one, and back. + + Note: Reports are named Flag in AS. + """ + alias Mobilizon.Events.Participant, as: ParticipantModel + + @doc """ + Convert an event struct to an ActivityStream representation + """ + @spec model_to_as(ParticipantModel.t()) :: map() + def model_to_as(%ParticipantModel{} = participant) do + %{ + "type" => "Join", + "id" => participant.url, + "actor" => participant.actor.url, + "object" => participant.event.url + } + end + + defimpl Mobilizon.Service.ActivityPub.Convertible, for: Mobilizon.Events.Participant do + alias Mobilizon.Service.ActivityPub.Converters.Participant, as: ParticipantConverter + + defdelegate model_to_as(event), to: ParticipantConverter + end +end diff --git a/lib/service/activity_pub/transmogrifier.ex b/lib/service/activity_pub/transmogrifier.ex index 75c0bcc73..a29b61ed5 100644 --- a/lib/service/activity_pub/transmogrifier.ex +++ b/lib/service/activity_pub/transmogrifier.ex @@ -10,7 +10,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do alias Mobilizon.Actors alias Mobilizon.Actors.{Actor, Follower} alias Mobilizon.Events - alias Mobilizon.Events.{Event, Comment} + alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Visibility @@ -185,7 +185,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do end def handle_incoming( - %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data + %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data ) do with {:ok, %Actor{} = followed} <- Actors.get_or_fetch_by_url(followed, true), {:ok, %Actor{} = follower} <- Actors.get_or_fetch_by_url(follower), @@ -198,61 +198,65 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do end end - # TODO : Handle object being a Link def handle_incoming( %{ "type" => "Accept", - "object" => follow_object, + "object" => accepted_object, "actor" => _actor, - "id" => _id + "id" => id } = data ) do - with followed_actor_url <- get_actor(data), - {:ok, %Actor{} = followed} <- Actors.get_or_fetch_by_url(followed_actor_url), - {:ok, %Follower{approved: false, actor: follower, id: follow_id} = follow} <- - get_follow(follow_object), - {:ok, activity, _} <- - ActivityPub.accept( - %{ - to: [follower.url], - actor: followed.url, - object: follow_object, - local: false - }, - "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follow_id}" - ), - {:ok, %Follower{approved: true}} <- Actors.update_follower(follow, %{"approved" => true}) do - {:ok, activity, follow} + with actor_url <- get_actor(data), + {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor_url), + {:object_not_found, {:ok, activity, object}} <- + {:object_not_found, + do_handle_incoming_accept_following(accepted_object, actor) || + do_handle_incoming_accept_join(accepted_object, actor)} do + {:ok, activity, object} else - {:ok, %Follower{approved: true} = _follow} -> - {:error, "Follow already accepted"} + {:object_not_found, nil} -> + Logger.warn( + "Unable to process Accept activity #{inspect(id)}. Object #{inspect(accepted_object)} wasn't found." + ) + + :error e -> - Logger.warn("Unable to process Accept Follow activity #{inspect(e)}") + Logger.warn( + "Unable to process Accept activity #{inspect(id)} for object #{inspect(accepted_object)} only returned #{ + inspect(e) + }" + ) + :error end end def handle_incoming( - %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data + %{"type" => "Reject", "object" => rejected_object, "actor" => _actor, "id" => id} = data ) do - with followed_actor_url <- get_actor(data), - {:ok, %Actor{} = followed} <- Actors.get_or_fetch_by_url(followed_actor_url), - {:ok, %Follower{approved: false, actor: follower, id: follow_id} = follow} <- - get_follow(follow_object), - {:ok, activity, object} <- - ActivityPub.reject(%{ - to: [follower.url], - type: "Reject", - actor: followed, - object: follow_object, - local: false - }), - {:ok, _follower} <- Actor.unfollow(followed, follower) do + with actor_url <- get_actor(data), + {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor_url), + {:object_not_found, {:ok, activity, object}} <- + {:object_not_found, + do_handle_incoming_reject_following(rejected_object, actor) || + do_handle_incoming_reject_join(rejected_object, actor)} do {:ok, activity, object} else + {:object_not_found, nil} -> + Logger.warn( + "Unable to process Reject activity #{inspect(id)}. Object #{inspect(rejected_object)} wasn't found." + ) + + :error + e -> - Logger.debug(inspect(e)) + Logger.warn( + "Unable to process Reject activity #{inspect(id)} for object #{inspect(rejected_object)} only returned #{ + inspect(e) + }" + ) + :error end end @@ -272,7 +276,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do # end # # def handle_incoming( - %{"type" => "Announce", "object" => object_id, "actor" => actor, "id" => id} = data + %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data ) do with actor <- get_actor(data), {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor), @@ -320,7 +324,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do "object" => object_id, "id" => cancelled_activity_id }, - "actor" => actor, + "actor" => _actor, "id" => id } = data ) do @@ -378,6 +382,43 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do end end + def handle_incoming( + %{"type" => "Join", "object" => object, "actor" => _actor, "id" => _id} = data + ) do + with actor <- get_actor(data), + {:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor), + {:ok, object} <- fetch_obj_helper(object), + {:ok, activity, object} <- ActivityPub.join(object, actor, false) do + {:ok, activity, object} + else + e -> + Logger.debug(inspect(e)) + :error + end + end + + def handle_incoming( + %{"type" => "Leave", "object" => object, "actor" => actor, "id" => _id} = data + ) do + with actor <- get_actor(data), + {:ok, %Actor{} = actor} <- Actors.get_actor_by_url(actor), + {:ok, object} <- fetch_obj_helper(object), + {:ok, activity, object} <- ActivityPub.leave(object, actor, false) do + {:ok, activity, object} + else + {:only_organizer, true} -> + Logger.warn( + "Actor #{inspect(actor)} tried to leave event #{inspect(object)} but it was the only organizer so we didn't detach it" + ) + + :error + + e -> + Logger.error(inspect(e)) + :error + end + end + # # # TODO # # Accept @@ -406,13 +447,187 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do {:error, :not_supported} end + @doc """ + Handle incoming `Accept` activities wrapping a `Follow` activity + """ + def do_handle_incoming_accept_following(follow_object, %Actor{} = actor) do + with {:follow, + {:ok, + %Follower{approved: false, actor: follower, id: follow_id, target_actor: followed} = + follow}} <- + {:follow, get_follow(follow_object)}, + {:same_actor, true} <- {:same_actor, actor.id == followed.id}, + {:ok, activity, _} <- + ActivityPub.accept( + %{ + to: [follower.url], + actor: actor.url, + object: follow_object, + local: false + }, + "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follow_id}" + ), + {:ok, %Follower{approved: true}} <- Actors.update_follower(follow, %{"approved" => true}) do + {:ok, activity, follow} + else + {:follow, _} -> + Logger.debug( + "Tried to handle an Accept activity but it's not containing a Follow activity" + ) + + nil + + {:same_actor} -> + {:error, "Actor who accepted the follow wasn't the target. Quite odd."} + + {:ok, %Follower{approved: true} = _follow} -> + {:error, "Follow already accepted"} + end + end + + @doc """ + Handle incoming `Reject` activities wrapping a `Follow` activity + """ + def do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do + with {:follow, + {:ok, + %Follower{approved: false, actor: follower, id: follow_id, target_actor: followed} = + follow}} <- + {:follow, get_follow(follow_object)}, + {:same_actor, true} <- {:same_actor, actor.id == followed.id}, + {:ok, activity, _} <- + ActivityPub.reject( + %{ + to: [follower.url], + actor: actor.url, + object: follow_object, + local: false + }, + "#{MobilizonWeb.Endpoint.url()}/reject/follow/#{follow_id}" + ), + {:ok, %Follower{}} <- Actors.delete_follower(follow) do + {:ok, activity, follow} + else + {:follow, _} -> + Logger.debug( + "Tried to handle a Reject activity but it's not containing a Follow activity" + ) + + nil + + {:same_actor} -> + {:error, "Actor who rejected the follow wasn't the target. Quite odd."} + + {:ok, %Follower{approved: true} = _follow} -> + {:error, "Follow already accepted"} + end + end + + @doc """ + Handle incoming `Accept` activities wrapping a `Join` activity on an event + """ + def do_handle_incoming_accept_join(join_object, %Actor{} = actor_accepting) do + with {:join_event, + {:ok, + %Participant{role: :not_approved, actor: actor, id: join_id, event: event} = + participant}} <- + {:join_event, get_participant(join_object)}, + # TODO: The actor that accepts the Join activity may another one that the event organizer ? + # Or maybe for groups it's the group that sends the Accept activity + {:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id}, + {:ok, activity, _} <- + ActivityPub.accept( + %{ + to: [actor.url], + actor: actor_accepting.url, + object: join_object, + local: false + }, + "#{MobilizonWeb.Endpoint.url()}/accept/join/#{join_id}" + ), + {:ok, %Participant{role: :participant}} <- + Events.update_participant(participant, %{"role" => :participant}) do + {:ok, activity, participant} + else + {:join_event, {:ok, %Participant{role: :participant}}} -> + Logger.debug( + "Tried to handle an Accept activity on a Join activity with a event object but the participant is already validated" + ) + + nil + + {:join_event, _err} -> + Logger.debug( + "Tried to handle an Accept activity but it's not containing a Join activity on a event" + ) + + nil + + {:same_actor} -> + {:error, "Actor who accepted the join wasn't the event organizer. Quite odd."} + + {:ok, %Participant{role: :participant} = _follow} -> + {:error, "Participant"} + end + end + + @doc """ + Handle incoming `Reject` activities wrapping a `Join` activity on an event + """ + def do_handle_incoming_reject_join(join_object, %Actor{} = actor_accepting) do + with {:join_event, + {:ok, + %Participant{role: :not_approved, actor: actor, id: join_id, event: event} = + participant}} <- + {:join_event, get_participant(join_object)}, + # TODO: The actor that accepts the Join activity may another one that the event organizer ? + # Or maybe for groups it's the group that sends the Accept activity + {:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id}, + {:ok, activity, _} <- + ActivityPub.reject( + %{ + to: [actor.url], + actor: actor_accepting.url, + object: join_object, + local: false + }, + "#{MobilizonWeb.Endpoint.url()}/reject/join/#{join_id}" + ), + {:ok, %Participant{}} <- + Events.delete_participant(participant) do + {:ok, activity, participant} + else + {:join_event, {:ok, %Participant{role: :participant}}} -> + Logger.debug( + "Tried to handle an Reject activity on a Join activity with a event object but the participant is already validated" + ) + + nil + + {:join_event, _err} -> + Logger.debug( + "Tried to handle an Reject activity but it's not containing a Join activity on a event" + ) + + nil + + {:same_actor} -> + {:error, "Actor who rejected the join wasn't the event organizer. Quite odd."} + + {:ok, %Participant{role: :participant} = _follow} -> + {:error, "Participant"} + end + end + + # TODO: Add do_handle_incoming_accept_join/1 on Groups + defp get_follow(follow_object) do with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object), {:not_found, %Follower{} = follow} <- {:not_found, Actors.get_follow_by_url(follow_object_id)} do {:ok, follow} else - {:not_found, err} -> + {:not_found, _err} -> {:error, "Follow URL not found"} _ -> @@ -420,6 +635,20 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do end end + defp get_participant(join_object) do + with join_object_id when not is_nil(join_object_id) <- Utils.get_url(join_object), + {:not_found, %Participant{} = participant} <- + {:not_found, Events.get_participant_by_url(join_object_id)} do + {:ok, participant} + else + {:not_found, _err} -> + {:error, "Participant URL not found"} + + _ -> + {:error, "ActivityPub ID not found in Accept Join object"} + end + end + def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) do with false <- String.starts_with?(in_reply_to, "http"), {:ok, replied_to_object} <- fetch_obj_helper(in_reply_to) do diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index dd584992b..a47f38a97 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -512,7 +512,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do object_actor_url, object_url, activity_id, - public \\ true + public ) do {to, cc} = if public do @@ -611,6 +611,24 @@ defmodule Mobilizon.Service.ActivityPub.Utils do |> Map.merge(additional) end + def make_join_data(%Event{} = event, %Actor{} = actor) do + %{ + "type" => "Join", + "id" => "#{actor.url}/join/event/id", + "actor" => actor.url, + "object" => event.url + } + end + + def make_join_data(%Actor{type: :Group} = event, %Actor{} = actor) do + %{ + "type" => "Join", + "id" => "#{actor.url}/join/group/id", + "actor" => actor.url, + "object" => event.url + } + end + @doc """ Converts PEM encoded keys to a public key representation """ diff --git a/lib/service/activity_pub/visibility.ex b/lib/service/activity_pub/visibility.ex index aaf114dcb..93a5c5aba 100644 --- a/lib/service/activity_pub/visibility.ex +++ b/lib/service/activity_pub/visibility.ex @@ -8,7 +8,6 @@ defmodule Mobilizon.Service.ActivityPub.Visibility do Utility functions related to content visibility """ alias Mobilizon.Activity - alias Mobilizon.Events.Event @public "https://www.w3.org/ns/activitystreams#Public" diff --git a/priv/repo/migrations/20190813160207_add_url_and_uuid_to_participants.exs b/priv/repo/migrations/20190813160207_add_url_and_uuid_to_participants.exs new file mode 100644 index 000000000..43f331fa7 --- /dev/null +++ b/priv/repo/migrations/20190813160207_add_url_and_uuid_to_participants.exs @@ -0,0 +1,43 @@ +defmodule Mobilizon.Repo.Migrations.AddUrlAndUuidToParticipants do + use Ecto.Migration + + def up do + drop(index(:participants, :event_id)) + drop_if_exists(index(:participants, :account_id)) + drop_if_exists(index(:participants, :actor_id)) + drop(constraint(:participants, "participants_event_id_fkey")) + + # This is because even though we renamed the table accounts to actors indexes kept this name + drop_if_exists(constraint(:participants, "participants_account_id_fkey")) + drop_if_exists(constraint(:participants, "participants_actor_id_fkey")) + drop(constraint(:participants, "participants_pkey")) + + alter table(:participants, primary_key: false) do + modify(:event_id, references(:events, on_delete: :delete_all), primary_key: false) + modify(:actor_id, references(:actors, on_delete: :delete_all), primary_key: false) + add(:id, :uuid, primary_key: true) + add(:url, :string, null: false) + end + + create(index(:participants, :event_id)) + create(index(:participants, :actor_id)) + end + + def down do + drop(index(:participants, :event_id)) + drop(index(:participants, :actor_id)) + drop(constraint(:participants, "participants_event_id_fkey")) + drop(constraint(:participants, "participants_actor_id_fkey")) + drop(constraint(:participants, "participants_pkey")) + + alter table(:participants, primary_key: false) do + modify(:event_id, references(:events, on_delete: :nothing), primary_key: true) + modify(:actor_id, references(:actors, on_delete: :nothing), primary_key: true) + remove(:id) + remove(:url) + end + + create(index(:participants, :event_id)) + create(index(:participants, :actor_id)) + end +end diff --git a/test/fixtures/mobilizon-join-activity.json b/test/fixtures/mobilizon-join-activity.json new file mode 100644 index 000000000..f2669e3aa --- /dev/null +++ b/test/fixtures/mobilizon-join-activity.json @@ -0,0 +1,22 @@ +{ + "type": "Join", + "signature": { + "type": "RsaSignature2017", + "signatureValue": "Kn1/UkAQGJVaXBfWLAHcnwHg8YMAUqlEaBuYLazAG+pz5hqivsyrBmPV186Xzr+B4ZLExA9+SnOoNx/GOz4hBm0kAmukNSILAsUd84tcJ2yT9zc1RKtembK4WiwOw7li0+maeDN0HaB6t+6eTqsCWmtiZpprhXD8V1GGT8yG7X24fQ9oFGn+ng7lasbcCC0988Y1eGqNe7KryxcPuQz57YkDapvtONzk8gyLTkZMV4De93MyRHq6GVjQVIgtiYabQAxrX6Q8C+4P/jQoqdWJHEe+MY5JKyNaT/hMPt2Md1ok9fZQBGHlErk22/zy8bSN19GdG09HmIysBUHRYpBLig==", + "creator": "http://mobilizon.test/users/tcit#main-key", + "created": "2018-02-17T13:29:31Z" + }, + "object": "http://mobilizon.test/events/some-uuid", + "id": "http://mobilizon2.test/@admin/join/event/1", + "actor": "http://mobilizon2.test/@admin", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "sensitive": "as:sensitive", + "movedTo": "as:movedTo", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/mobilizon-leave-activity.json b/test/fixtures/mobilizon-leave-activity.json new file mode 100644 index 000000000..10d157987 --- /dev/null +++ b/test/fixtures/mobilizon-leave-activity.json @@ -0,0 +1,22 @@ +{ + "type": "Leave", + "signature": { + "type": "RsaSignature2017", + "signatureValue": "Kn1/UkAQGJVaXBfWLAHcnwHg8YMAUqlEaBuYLazAG+pz5hqivsyrBmPV186Xzr+B4ZLExA9+SnOoNx/GOz4hBm0kAmukNSILAsUd84tcJ2yT9zc1RKtembK4WiwOw7li0+maeDN0HaB6t+6eTqsCWmtiZpprhXD8V1GGT8yG7X24fQ9oFGn+ng7lasbcCC0988Y1eGqNe7KryxcPuQz57YkDapvtONzk8gyLTkZMV4De93MyRHq6GVjQVIgtiYabQAxrX6Q8C+4P/jQoqdWJHEe+MY5JKyNaT/hMPt2Md1ok9fZQBGHlErk22/zy8bSN19GdG09HmIysBUHRYpBLig==", + "creator": "http://mobilizon.test/users/tcit#main-key", + "created": "2018-02-17T13:29:31Z" + }, + "object": "http://mobilizon.test/events/some-uuid", + "id": "http://mobilizon2.test/@admin/join/event/1", + "actor": "http://mobilizon2.test/@admin", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "sensitive": "as:sensitive", + "movedTo": "as:movedTo", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag" + } + ] +} \ No newline at end of file diff --git a/test/mobilizon/service/activity_pub/transmogrifier_test.exs b/test/mobilizon/service/activity_pub/transmogrifier_test.exs index 4cbf4b1b5..dde9e57b5 100644 --- a/test/mobilizon/service/activity_pub/transmogrifier_test.exs +++ b/test/mobilizon/service/activity_pub/transmogrifier_test.exs @@ -12,7 +12,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events - alias Mobilizon.Events.{Comment, Event} + alias Mobilizon.Events.{Comment, Event, Participant} alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Transmogrifier @@ -695,6 +695,120 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do assert activity.data["actor"] == reporter_url assert activity.data["cc"] == [reported_url] end + + test "it accepts Join activities" do + %Actor{url: _organizer_url} = organizer = insert(:actor) + %Actor{url: participant_url} = _participant = insert(:actor) + + %Event{url: event_url} = _event = insert(:event, organizer_actor: organizer) + + join_data = + File.read!("test/fixtures/mobilizon-join-activity.json") + |> Jason.decode!() + |> Map.put("actor", participant_url) + |> Map.put("object", event_url) + + assert {:ok, activity, _} = Transmogrifier.handle_incoming(join_data) + + assert activity.data["object"] == event_url + assert activity.data["actor"] == participant_url + end + + test "it accepts Accept activities for Join activities" do + %Actor{url: organizer_url} = organizer = insert(:actor) + %Actor{} = participant_actor = insert(:actor) + + %Event{} = event = insert(:event, organizer_actor: organizer, join_options: :restricted) + + {:ok, join_activity, participation} = ActivityPub.join(event, participant_actor) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Jason.decode!() + |> Map.put("actor", organizer_url) + |> Map.put("object", participation.url) + + {:ok, accept_activity, _} = Transmogrifier.handle_incoming(accept_data) + assert accept_activity.data["object"] == join_activity.data["id"] + assert accept_activity.data["object"] =~ "/join/" + assert accept_activity.data["id"] =~ "/accept/join/" + + # We don't accept already accepted Accept activities + :error = Transmogrifier.handle_incoming(accept_data) + end + + test "it accepts Reject activities for Join activities" do + %Actor{url: organizer_url} = organizer = insert(:actor) + %Actor{} = participant_actor = insert(:actor) + + %Event{} = event = insert(:event, organizer_actor: organizer, join_options: :restricted) + + {:ok, join_activity, participation} = ActivityPub.join(event, participant_actor) + + reject_data = + File.read!("test/fixtures/mastodon-reject-activity.json") + |> Jason.decode!() + |> Map.put("actor", organizer_url) + |> Map.put("object", participation.url) + + {:ok, reject_activity, _} = Transmogrifier.handle_incoming(reject_data) + assert reject_activity.data["object"] == join_activity.data["id"] + assert reject_activity.data["object"] =~ "/join/" + assert reject_activity.data["id"] =~ "/reject/join/" + + # We don't accept already rejected Reject activities + assert :error == Transmogrifier.handle_incoming(reject_data) + + # Organiser is not present since we use factories directly + assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == + [] + end + + test "it accepts Leave activities" do + %Actor{url: _organizer_url} = organizer = insert(:actor) + %Actor{url: participant_url} = participant_actor = insert(:actor) + + %Event{url: event_url} = + event = insert(:event, organizer_actor: organizer, join_options: :restricted) + + organizer_participation = + %Participant{} = insert(:participant, event: event, actor: organizer, role: :creator) + + {:ok, _join_activity, _participation} = ActivityPub.join(event, participant_actor) + + join_data = + File.read!("test/fixtures/mobilizon-leave-activity.json") + |> Jason.decode!() + |> Map.put("actor", participant_url) + |> Map.put("object", event_url) + + assert {:ok, activity, _} = Transmogrifier.handle_incoming(join_data) + + assert activity.data["object"] == event_url + assert activity.data["actor"] == participant_url + + # The only participant left is the organizer + assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == [ + organizer_participation.id + ] + end + + test "it refuses Leave activities when actor is the only organizer" do + %Actor{url: organizer_url} = organizer = insert(:actor) + + %Event{url: event_url} = + event = insert(:event, organizer_actor: organizer, join_options: :restricted) + + %Participant{} = insert(:participant, event: event, actor: organizer, role: :creator) + + join_data = + File.read!("test/fixtures/mobilizon-leave-activity.json") + |> Jason.decode!() + |> Map.put("actor", organizer_url) + |> Map.put("object", event_url) + + assert :error = Transmogrifier.handle_incoming(join_data) + end end describe "prepare outgoing" do diff --git a/test/mobilizon_web/resolvers/participant_resolver_test.exs b/test/mobilizon_web/resolvers/participant_resolver_test.exs index bf526c8c2..80ad79cad 100644 --- a/test/mobilizon_web/resolvers/participant_resolver_test.exs +++ b/test/mobilizon_web/resolvers/participant_resolver_test.exs @@ -116,7 +116,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do |> auth_conn(user) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) - assert hd(json_response(res, 200)["errors"])["message"] =~ "Event id not found" + assert hd(json_response(res, 200)["errors"])["message"] == + "Event with this ID 1042 doesn't exist" end test "actor_leave_event/3 should delete a participant from an event", %{ @@ -290,13 +291,14 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do user: user, actor: actor } do + event = insert(:event) participant = insert(:participant, %{actor: actor}) mutation = """ mutation { leaveEvent( actor_id: #{participant.actor.id}, - event_id: 1042 + event_id: #{event.id} ) { actor { id @@ -355,13 +357,13 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do |> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) assert json_response(res, 200)["data"]["participants"] == [ - %{ - "actor" => %{"preferredUsername" => context.actor.preferred_username}, - "role" => "creator" - }, %{ "actor" => %{"preferredUsername" => participant2.actor.preferred_username}, "role" => "participant" + }, + %{ + "actor" => %{"preferredUsername" => context.actor.preferred_username}, + "role" => "creator" } ] end diff --git a/test/support/factory.ex b/test/support/factory.ex index 5d0d4bedf..eee37f91f 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -124,15 +124,20 @@ defmodule Mobilizon.Factory do tags: build_list(3, :tag), url: Routes.page_url(Endpoint, :event, uuid), picture: insert(:picture), - uuid: uuid + uuid: uuid, + join_options: :free } end def participant_factory do + uuid = Ecto.UUID.generate() + %Mobilizon.Events.Participant{ event: build(:event), actor: build(:actor), - role: :creator + role: :creator, + url: "#{Endpoint.url()}/join/event/#{uuid}", + id: uuid } end