diff --git a/js/src/graphql/followers.ts b/js/src/graphql/followers.ts new file mode 100644 index 000000000..660c2e7db --- /dev/null +++ b/js/src/graphql/followers.ts @@ -0,0 +1,49 @@ +import gql from "graphql-tag"; + +export const GROUP_FOLLOWERS = gql` + query( + $name: String! + $followersPage: Int + $followersLimit: Int + $approved: Boolean + ) { + group(preferredUsername: $name) { + id + preferredUsername + name + domain + followers( + page: $followersPage + limit: $followersLimit + approved: $approved + ) { + total + elements { + id + actor { + id + preferredUsername + name + domain + avatar { + id + url + } + } + approved + insertedAt + updatedAt + } + } + } + } +`; + +export const UPDATE_FOLLOWER = gql` + mutation UpdateFollower($id: ID!, $approved: Boolean) { + updateFollower(id: $id, approved: $approved) { + id + approved + } + } +`; diff --git a/js/src/graphql/group.ts b/js/src/graphql/group.ts index 89a14a716..84345a03c 100644 --- a/js/src/graphql/group.ts +++ b/js/src/graphql/group.ts @@ -64,6 +64,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql` suspended visibility openness + manuallyApprovesFollowers physicalAddress { description street @@ -265,6 +266,7 @@ export const UPDATE_GROUP = gql` $visibility: GroupVisibility $openness: Openness $physicalAddress: AddressInput + $manuallyApprovesFollowers: Boolean ) { updateGroup( id: $id @@ -275,6 +277,7 @@ export const UPDATE_GROUP = gql` visibility: $visibility openness: $openness physicalAddress: $physicalAddress + manuallyApprovesFollowers: $manuallyApprovesFollowers ) { id preferredUsername @@ -282,6 +285,7 @@ export const UPDATE_GROUP = gql` summary visibility openness + manuallyApprovesFollowers avatar { id url diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index d1df88117..fe0b9e625 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -829,5 +829,12 @@ "It is possible that the content is not accessible on this instance, because this instance has blocked the profiles or groups behind this content.": "It is possible that the content is not accessible on this instance, because this instance has blocked the profiles or groups behind this content.", "Atom feed for events and posts": "Atom feed for events and posts", "ICS feed for events": "ICS feed for events", - "ICS/WebCal Feed": "ICS/WebCal Feed" + "ICS/WebCal Feed": "ICS/WebCal Feed", + "Group Followers": "Group Followers", + "Follower": "Follower", + "Reject": "Reject", + "No follower matches the filters": "No follower matches the filters", + "@{username}'s follow request was rejected": "@{username}'s follow request was rejected", + "Followers will receive new public events and posts.": "Followers will receive new public events and posts.", + "Manually approve new followers": "Manually approve new followers" } diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index 31d1c70f2..4433842b5 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -274,7 +274,7 @@ "Find an address": "Trouver une adresse", "Find an instance": "Trouver une instance", "Find another instance": "Trouver une autre instance", - "Followers": "Abonnés", + "Followers": "Abonné⋅es", "Followings": "Abonnements", "For instance: London": "Par exemple : Lyon", "For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…", @@ -925,5 +925,11 @@ "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap", "Atom feed for events and posts": "Flux Atom pour les événements et les billets", "ICS feed for events": "Flux ICS pour les événements", - "ICS/WebCal Feed": "Flux ICS/WebCal" + "ICS/WebCal Feed": "Flux ICS/WebCal", + "Group Followers": "Abonné⋅es au groupe", + "Follower": "Abonné⋅es", + "No follower matches the filters": "Aucun⋅e abonné⋅e ne correspond aux filtres", + "@{username}'s follow request was rejected": "La demande de suivi de @{username} a été rejettée", + "Followers will receive new public events and posts.": "Les abonnée⋅s recevront les nouveaux événements et billets publics.", + "Manually approve new followers": "Approuver les nouvelles demandes de suivi manuellement" } diff --git a/js/src/router/groups.ts b/js/src/router/groups.ts index ae3a92055..75d16adae 100644 --- a/js/src/router/groups.ts +++ b/js/src/router/groups.ts @@ -8,6 +8,7 @@ export enum GroupsRouteName { GROUP_SETTINGS = "GROUP_SETTINGS", GROUP_PUBLIC_SETTINGS = "GROUP_PUBLIC_SETTINGS", GROUP_MEMBERS_SETTINGS = "GROUP_MEMBERS_SETTINGS", + GROUP_FOLLOWERS_SETTINGS = "GROUP_FOLLOWERS_SETTINGS", RESOURCES = "RESOURCES", RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT", RESOURCE_FOLDER = "RESOURCE_FOLDER", @@ -85,6 +86,13 @@ export const groupsRoutes: RouteConfig[] = [ import("../views/Group/GroupMembers.vue"), props: true, }, + { + path: "followers", + name: GroupsRouteName.GROUP_FOLLOWERS_SETTINGS, + component: (): Promise => + import("../views/Group/GroupFollowers.vue"), + props: true, + }, ], }, { diff --git a/js/src/types/actor/group.model.ts b/js/src/types/actor/group.model.ts index f3966c122..c178f404f 100644 --- a/js/src/types/actor/group.model.ts +++ b/js/src/types/actor/group.model.ts @@ -19,6 +19,7 @@ export interface IGroup extends IActor { organizedEvents: Paginate; physicalAddress: IAddress; openness: Openness; + manuallyApprovesFollowers: boolean; } export class Group extends Actor implements IGroup { @@ -45,6 +46,8 @@ export class Group extends Actor implements IGroup { physicalAddress: IAddress = new Address(); + manuallyApprovesFollowers = true; + patch(hash: IGroup | Record): void { Object.assign(this, hash); } diff --git a/js/src/views/Group/GroupFollowers.vue b/js/src/views/Group/GroupFollowers.vue new file mode 100644 index 000000000..712a34fec --- /dev/null +++ b/js/src/views/Group/GroupFollowers.vue @@ -0,0 +1,261 @@ + + + diff --git a/js/src/views/Group/GroupSettings.vue b/js/src/views/Group/GroupSettings.vue index 54bfbb9ed..f4401059b 100644 --- a/js/src/views/Group/GroupSettings.vue +++ b/js/src/views/Group/GroupSettings.vue @@ -134,6 +134,15 @@ + + + {{ $t("Manually approve new followers") }} + + + + diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex index b9208c700..3cf173441 100644 --- a/lib/federation/activity_pub/activity_pub.ex +++ b/lib/federation/activity_pub/activity_pub.ex @@ -50,7 +50,6 @@ defmodule Mobilizon.Federation.ActivityPub do alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Email.{Admin, Group, Mailer} - alias Mobilizon.Web.Email.Follow, as: FollowMailer require Logger @@ -320,13 +319,22 @@ defmodule Mobilizon.Federation.ActivityPub do @doc """ Make an actor follow another """ - def follow(%Actor{} = follower, %Actor{} = followed, activity_id \\ nil, local \\ true) do + def follow( + %Actor{} = follower, + %Actor{} = followed, + activity_id \\ nil, + local \\ true, + additional \\ %{} + ) do with {:different_actors, true} <- {:different_actors, followed.id != follower.id}, - {:ok, %Follower{} = follower} <- - Actors.follow(followed, follower, activity_id, false), - :ok <- FollowMailer.send_notification_to_admins(follower), - follower_as_data <- Convertible.model_to_as(follower), - {:ok, activity} <- create_activity(follower_as_data, local), + {:ok, activity_data, %Follower{} = follower} <- + Types.Actors.follow( + follower, + followed, + local, + Map.merge(additional, %{"activity_id" => activity_id}) + ), + {:ok, activity} <- create_activity(activity_data, local), :ok <- maybe_federate(activity) do {:ok, activity, follower} else diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex index ddeec0af1..620c2e747 100644 --- a/lib/federation/activity_pub/transmogrifier.ex +++ b/lib/federation/activity_pub/transmogrifier.ex @@ -302,6 +302,10 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do do_handle_incoming_accept_join(accepted_object, actor)} do {:ok, activity, object} else + {:object_not_found, {:error, "Follow already accepted"}} -> + Logger.info("Follow was already accepted. Ignoring.") + :error + {:object_not_found, nil} -> Logger.warn( "Unable to process Accept activity #{inspect(id)}. Object #{inspect(accepted_object)} wasn't found." @@ -761,6 +765,10 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do {:ok, activity, follow} else + {:follow, {:ok, %Follower{approved: true} = _follow}} -> + Logger.debug("Follow already accepted") + {:error, "Follow already accepted"} + {:follow, _} -> Logger.debug( "Tried to handle an Accept activity but it's not containing a Follow activity" @@ -770,9 +778,6 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do {: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 diff --git a/lib/federation/activity_pub/types/actors.ex b/lib/federation/activity_pub/types/actors.ex index 7491d1684..1e0cdc673 100644 --- a/lib/federation/activity_pub/types/actors.ex +++ b/lib/federation/activity_pub/types/actors.ex @@ -1,7 +1,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do @moduledoc false alias Mobilizon.Actors - alias Mobilizon.Actors.{Actor, Member} + alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub.Audience alias Mobilizon.Federation.ActivityPub.Types.Entity @@ -9,6 +9,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do alias Mobilizon.GraphQL.API.Utils, as: APIUtils alias Mobilizon.Service.Formatter.HTML alias Mobilizon.Service.Notifications.Scheduler + alias Mobilizon.Web.Email.Follow, as: FollowMailer alias Mobilizon.Web.Endpoint import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2] @@ -130,6 +131,15 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do end end + def follow(%Actor{} = follower_actor, %Actor{} = followed, _local, additional) do + with {:ok, %Follower{} = follower} <- + Mobilizon.Actors.follow(followed, follower_actor, additional["activity_id"], false), + :ok <- FollowMailer.send_notification_to_admins(follower), + follower_as_data <- Convertible.model_to_as(follower) do + approve_if_manually_approves_followers(follower, follower_as_data) + end + end + defp prepare_args_for_actor(args) do args |> maybe_sanitize_username() @@ -189,4 +199,21 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do {:ok, activity_data, member} end end + + defp approve_if_manually_approves_followers( + %Follower{} = follower, + follow_as_data + ) do + unless follower.target_actor.manually_approves_followers do + {:accept, + ActivityPub.accept( + :follow, + follower, + true, + %{"actor" => follower.actor.url} + )} + end + + {:ok, follow_as_data, follower} + end end diff --git a/lib/graphql/resolvers/followers.ex b/lib/graphql/resolvers/followers.ex new file mode 100644 index 000000000..950115a5a --- /dev/null +++ b/lib/graphql/resolvers/followers.ex @@ -0,0 +1,64 @@ +defmodule Mobilizon.GraphQL.Resolvers.Followers do + @moduledoc """ + Handles the followers-related GraphQL calls. + """ + + import Mobilizon.Users.Guards + alias Mobilizon.{Actors, Users} + alias Mobilizon.Actors.{Actor, Follower} + alias Mobilizon.Federation.ActivityPub + alias Mobilizon.Storage.Page + alias Mobilizon.Users.User + + @spec find_followers_for_group(Actor.t(), map(), map()) :: {:ok, Page.t()} + def find_followers_for_group( + %Actor{id: group_id} = group, + %{page: page, limit: limit} = args, + %{ + context: %{ + current_user: %User{role: user_role} = user + } + } + ) do + with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, + {:member, true} <- + {:member, Actors.is_moderator?(actor_id, group_id) or is_moderator(user_role)} do + {:ok, + Actors.list_paginated_followers_for_actor(group, Map.get(args, :approved), page, limit)} + else + _ -> {:error, :unauthorized} + end + end + + def find_followers_for_group(_, _, _), do: {:error, :unauthenticated} + + @spec update_follower(any(), map(), map()) :: {:ok, Follower.t()} | {:error, any()} + def update_follower(_, %{id: follower_id, approved: approved}, %{ + context: %{ + current_user: %User{} = user + } + }) do + with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, + %Follower{target_actor: %Actor{type: :Group, id: group_id}} = follower <- + Actors.get_follower(follower_id), + {:member, true} <- + {:member, Actors.is_moderator?(actor_id, group_id)}, + {:ok, _activity, %Follower{} = follower} <- + (if approved do + ActivityPub.accept(:follow, follower) + else + ActivityPub.reject(:follow, follower) + end) do + {:ok, follower} + else + {:member, _} -> + {:error, :unauthorized} + + _ -> + {:error, + if(approved, do: "Unable to approve follower", else: "Unable to reject follower")} + end + end + + def update_follower(_, _, _), do: {:error, :unauthenticated} +end diff --git a/lib/graphql/resolvers/group.ex b/lib/graphql/resolvers/group.ex index 67e236a74..6c5ffe5cb 100644 --- a/lib/graphql/resolvers/group.ex +++ b/lib/graphql/resolvers/group.ex @@ -26,8 +26,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do } } ) do - with {:ok, %Actor{id: group_id} = group} <- - ActivityPub.find_or_make_group_from_nickname(name), + with {:group, {:ok, %Actor{id: group_id} = group}} <- + {:group, ActivityPub.find_or_make_group_from_nickname(name)}, {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do {:ok, group} @@ -35,8 +35,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do {:member, false} -> find_group(parent, args, nil) - _ -> + {:group, _} -> {:error, :group_not_found} + + _ -> + {:error, :unknown} end end diff --git a/lib/graphql/schema.ex b/lib/graphql/schema.ex index f22c886d8..ceca7131b 100644 --- a/lib/graphql/schema.ex +++ b/lib/graphql/schema.ex @@ -177,6 +177,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:resource_mutations) import_fields(:post_mutations) import_fields(:actor_mutations) + import_fields(:follower_mutations) end @desc """ diff --git a/lib/graphql/schema/actor.ex b/lib/graphql/schema/actor.ex index 1acad9f0d..c2fd097bb 100644 --- a/lib/graphql/schema/actor.ex +++ b/lib/graphql/schema/actor.ex @@ -32,8 +32,6 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do field(:banner, :media, description: "The actor's banner media") # These one should have a privacy setting - field(:following, list_of(:follower), description: "List of followings") - field(:followers, list_of(:follower), description: "List of followers") field(:followersCount, :integer, description: "Number of followers for this actor") field(:followingCount, :integer, description: "Number of actors following this actor") diff --git a/lib/graphql/schema/actors/application.ex b/lib/graphql/schema/actors/application.ex index 9b89854b3..4a8d98bae 100644 --- a/lib/graphql/schema/actors/application.ex +++ b/lib/graphql/schema/actors/application.ex @@ -31,8 +31,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do field(:banner, :media, description: "The actor's banner media") # These one should have a privacy setting - field(:following, list_of(:follower), description: "List of followings") - field(:followers, list_of(:follower), description: "List of followers") field(:followersCount, :integer, description: "Number of followers for this actor") field(:followingCount, :integer, description: "Number of actors following this actor") diff --git a/lib/graphql/schema/actors/follower.ex b/lib/graphql/schema/actors/follower.ex index 99d708926..94da4335c 100644 --- a/lib/graphql/schema/actors/follower.ex +++ b/lib/graphql/schema/actors/follower.ex @@ -3,11 +3,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.FollowerType do Schema representation for Follower """ use Absinthe.Schema.Notation + alias Mobilizon.GraphQL.Resolvers.Followers @desc """ Represents an actor's follower """ object :follower do + field(:id, :id, description: "The follow ID") field(:target_actor, :actor, description: "What or who the profile follows") field(:actor, :actor, description: "Which profile follows") @@ -26,4 +28,17 @@ defmodule Mobilizon.GraphQL.Schema.Actors.FollowerType do field(:elements, list_of(:follower), description: "A list of followers") field(:total, :integer, description: "The total number of elements in the list") end + + object :follower_mutations do + @desc "Update follower" + field :update_follower, :follower do + arg(:id, non_null(:id), description: "The follower ID") + + arg(:approved, non_null(:boolean), + description: "Whether the follower has been approved by the target actor or not" + ) + + resolve(&Followers.update_follower/3) + end + end end diff --git a/lib/graphql/schema/actors/group.ex b/lib/graphql/schema/actors/group.ex index ad1fc9de9..90588e996 100644 --- a/lib/graphql/schema/actors/group.ex +++ b/lib/graphql/schema/actors/group.ex @@ -8,7 +8,18 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do import Absinthe.Resolution.Helpers, only: [dataloader: 1] alias Mobilizon.Addresses - alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Media, Member, Post, Resource, Todos} + + alias Mobilizon.GraphQL.Resolvers.{ + Discussion, + Followers, + Group, + Media, + Member, + Post, + Resource, + Todos + } + alias Mobilizon.GraphQL.Schema import_types(Schema.Actors.MemberType) @@ -47,8 +58,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do ) # These one should have a privacy setting - field(:following, list_of(:follower), description: "List of followings") - field(:followers, list_of(:follower), description: "List of followers") field(:followersCount, :integer, description: "Number of followers for this actor") field(:followingCount, :integer, description: "Number of actors following this actor") @@ -116,6 +125,23 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do resolve(&Todos.find_todo_lists_for_group/3) description("A paginated list of the todo lists this group has") end + + field :followers, :paginated_follower_list do + arg(:page, :integer, + default_value: 1, + description: "The page in the paginated followers list" + ) + + arg(:limit, :integer, default_value: 10, description: "The limit of followers per page") + + arg(:approved, :boolean, + default_value: nil, + description: "Used to filter the followers list by approved status" + ) + + resolve(&Followers.find_followers_for_group/3) + description("A paginated list of the followers this group has") + end end @desc """ @@ -232,6 +258,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do description: "Whether the group can be join freely, with approval or is invite-only." ) + arg(:manually_approves_followers, :boolean, + description: "Whether this group approves new followers manually" + ) + arg(:avatar, :media_input, description: "The avatar for the group, either as an object or directly the ID of an existing media" diff --git a/lib/graphql/schema/actors/person.ex b/lib/graphql/schema/actors/person.ex index bf6327a1a..1ed2ce262 100644 --- a/lib/graphql/schema/actors/person.ex +++ b/lib/graphql/schema/actors/person.ex @@ -44,8 +44,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do field(:banner, :media, description: "The actor's banner media") # These one should have a privacy setting - field(:following, list_of(:follower), description: "List of followings") - field(:followers, list_of(:follower), description: "List of followers") field(:followersCount, :integer, description: "Number of followers for this actor") field(:followingCount, :integer, description: "Number of actors following this actor") diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index d5e88d4df..763d2abb5 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -1022,6 +1022,16 @@ defmodule Mobilizon.Actors do @spec list_bots :: [Bot.t()] def list_bots, do: Repo.all(Bot) + @doc """ + Gets a single follower. + """ + @spec get_follower(integer | String.t()) :: Follower.t() | nil + def get_follower(id) do + Follower + |> Repo.get(id) + |> Repo.preload([:actor, :target_actor]) + end + @doc """ Gets a single follower. Raises `Ecto.NoResultsError` if the follower does not exist. @@ -1149,6 +1159,25 @@ defmodule Mobilizon.Actors do |> Repo.aggregate(:count) end + @doc """ + Returns a paginated list of followers for an actor. + """ + @spec list_paginated_followers_for_actor(Actor.t(), boolean | nil, integer | nil, integer | nil) :: + Page.t() + def list_paginated_followers_for_actor( + %Actor{id: actor_id}, + approved \\ nil, + page \\ nil, + limit \\ nil + ) do + actor_id + |> follower_for_actor_query() + |> filter_followed_by_approved_status(approved) + |> order_by(desc: :updated_at) + |> preload([:actor, :target_actor]) + |> Page.build_page(page, limit) + end + @doc """ Returns the list of followings for an actor. If actor A follows actor B and C, actor A's followings are B and C. @@ -1688,6 +1717,13 @@ defmodule Mobilizon.Actors do from(a in query, where: a.preferred_username == ^name and a.domain == ^domain) end + @spec filter_by_name(Ecto.Query.t(), boolean | nil) :: Ecto.Query.t() + defp filter_followed_by_approved_status(query, nil), do: query + + defp filter_followed_by_approved_status(query, approved) do + from(f in query, where: f.approved == ^approved) + end + @spec preload_followers(Actor.t(), boolean) :: Actor.t() defp preload_followers(actor, true), do: Repo.preload(actor, [:followers]) defp preload_followers(actor, false), do: actor diff --git a/test/federation/activity_pub/transmogrifier/follow_test.exs b/test/federation/activity_pub/transmogrifier/follow_test.exs index 5c15bd14c..db99d54ab 100644 --- a/test/federation/activity_pub/transmogrifier/follow_test.exs +++ b/test/federation/activity_pub/transmogrifier/follow_test.exs @@ -3,19 +3,73 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.FollowTest do import Mobilizon.Factory alias Mobilizon.Actors + alias Mobilizon.Actors.Follower alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} + describe "handle incoming follow requests" do + test "it works for incoming follow requests" do + actor = insert(:actor) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Jason.decode!() + |> Map.put("object", actor.url) + + {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "https://social.tcit.fr/users/tcit" + assert data["type"] == "Follow" + assert data["id"] == "https://social.tcit.fr/users/tcit#follows/2" + + actor = Actors.get_actor_with_preload(actor.id) + assert Actors.is_following(Actors.get_actor_by_url!(data["actor"], true), actor) + end + + test "it rejects activities without a valid ID" do + actor = insert(:actor) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Jason.decode!() + |> Map.put("object", actor.url) + |> Map.put("id", "") + + :error = Transmogrifier.handle_incoming(data) + end + + # test "it works for incoming follow requests from hubzilla" do + # user = insert(:user) + + # data = + # File.read!("test/fixtures/hubzilla-follow-activity.json") + # |> Jason.decode!() + # |> Map.put("object", user.ap_id) + # |> Utils.normalize_params() + + # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + # assert data["actor"] == "https://hubzilla.example.org/channel/kaniini" + # assert data["type"] == "Follow" + # assert data["id"] == "https://hubzilla.example.org/channel/kaniini#follows/2" + # assert User.is_following(User.get_by_ap_id(data["actor"]), user) + # end + end + describe "handle incoming follow accept activities" do - test "it works for incoming accepts which were pre-accepted" do + test "it works for incoming accepts" do follower = insert(:actor) - followed = insert(:actor) + followed = insert(:actor, manually_approves_followers: false) refute Actors.is_following(follower, followed) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) assert Actors.is_following(follower, followed) + follow_object_id = follow_activity.data["id"] + + assert %Follower{} = Actors.get_follower_by_url(follow_object_id) + accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") |> Jason.decode!() @@ -24,7 +78,39 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.FollowTest do object = accept_data["object"] |> Map.put("actor", follower.url) - |> Map.put("id", follow_activity.data["id"]) + |> Map.put("id", follow_object_id) + + accept_data = Map.put(accept_data, "object", object) + + :error = Transmogrifier.handle_incoming(accept_data) + + {:ok, follower} = Actors.get_actor_by_url(follower.url) + + assert Actors.is_following(follower, followed) + end + + test "it works for incoming accepts which were pre-accepted" do + follower = insert(:actor) + followed = insert(:actor, manually_approves_followers: true) + + refute Actors.is_following(follower, followed) + + {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) + assert Actors.is_following(follower, followed) + + follow_object_id = follow_activity.data["id"] + + assert %Follower{} = Actors.get_follower_by_url(follow_object_id) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Jason.decode!() + |> Map.put("actor", followed.url) + + object = + accept_data["object"] + |> Map.put("actor", follower.url) + |> Map.put("id", follow_object_id) accept_data = Map.put(accept_data, "object", object) @@ -40,7 +126,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.FollowTest do test "it works for incoming accepts which are referenced by IRI only" do follower = insert(:actor) - followed = insert(:actor) + followed = insert(:actor, manually_approves_followers: true) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs index 5b176249f..f04ad5a77 100644 --- a/test/federation/activity_pub/transmogrifier_test.exs +++ b/test/federation/activity_pub/transmogrifier_test.exs @@ -556,57 +556,6 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do end end - describe "handle incoming follow requests" do - test "it works for incoming follow requests" do - use_cassette "activity_pub/mastodon_follow_activity" do - actor = insert(:actor) - - data = - File.read!("test/fixtures/mastodon-follow-activity.json") - |> Jason.decode!() - |> Map.put("object", actor.url) - - {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "https://social.tcit.fr/users/tcit" - assert data["type"] == "Follow" - assert data["id"] == "https://social.tcit.fr/users/tcit#follows/2" - - actor = Actors.get_actor_with_preload(actor.id) - assert Actors.is_following(Actors.get_actor_by_url!(data["actor"], true), actor) - end - end - - test "it rejects activities without a valid ID" do - actor = insert(:actor) - - data = - File.read!("test/fixtures/mastodon-follow-activity.json") - |> Jason.decode!() - |> Map.put("object", actor.url) - |> Map.put("id", "") - - :error = Transmogrifier.handle_incoming(data) - end - - # test "it works for incoming follow requests from hubzilla" do - # user = insert(:user) - - # data = - # File.read!("test/fixtures/hubzilla-follow-activity.json") - # |> Jason.decode!() - # |> Map.put("object", user.ap_id) - # |> Utils.normalize_params() - - # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - # assert data["actor"] == "https://hubzilla.example.org/channel/kaniini" - # assert data["type"] == "Follow" - # assert data["id"] == "https://hubzilla.example.org/channel/kaniini#follows/2" - # assert User.is_following(User.get_by_ap_id(data["actor"]), user) - # end - end - # test "it works for incoming likes" do # %Comment{url: url} = insert(:comment) diff --git a/test/graphql/resolvers/follower_test.exs b/test/graphql/resolvers/follower_test.exs new file mode 100644 index 000000000..682ef9dd2 --- /dev/null +++ b/test/graphql/resolvers/follower_test.exs @@ -0,0 +1,275 @@ +defmodule Mobilizon.Web.Resolvers.FollowerTest do + use Mobilizon.Web.ConnCase + use Oban.Testing, repo: Mobilizon.Storage.Repo + + alias Mobilizon.Actors + alias Mobilizon.Actors.{Actor, Follower} + + import Mobilizon.Factory + + alias Mobilizon.GraphQL.AbsintheHelpers + + setup %{conn: conn} do + user = insert(:user) + actor = insert(:actor, user: user) + group = insert(:group) + insert(:member, parent: group, actor: actor, role: :moderator) + follower = insert(:follower, target_actor: group) + + {:ok, conn: conn, actor: actor, user: user, group: group, follower: follower} + end + + @group_followers_query """ + query( + $name: String! + $followersPage: Int + $followersLimit: Int + $approved: Boolean + ) { + group(preferredUsername: $name) { + id + preferredUsername + name + domain + followers( + page: $followersPage + limit: $followersLimit + approved: $approved + ) { + total + elements { + id + actor { + id + preferredUsername + name + domain + avatar { + id + url + } + } + approved + insertedAt + updatedAt + } + } + } + } + """ + + describe "list group followers find_followers_for_group/3" do + test "without being logged-in", %{ + conn: conn, + group: %Actor{preferred_username: preferred_username} + } do + res = + conn + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{name: preferred_username} + ) + + assert hd(res["errors"])["message"] == "unauthenticated" + end + + test "without being a member", %{ + conn: conn, + group: %Actor{preferred_username: preferred_username} + } do + user = insert(:user) + insert(:actor, user: user) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{name: preferred_username} + ) + + assert hd(res["errors"])["message"] == "unauthorized" + end + + test "without being a moderator", %{ + conn: conn, + group: %Actor{preferred_username: preferred_username} = group + } do + user = insert(:user) + actor = insert(:actor, user: user) + insert(:member, parent: group, actor: actor, role: :member) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{name: preferred_username} + ) + + assert hd(res["errors"])["message"] == "unauthorized" + end + + test "while being a moderator", %{ + conn: conn, + user: user, + group: %Actor{preferred_username: preferred_username, id: group_id} = group, + follower: %Follower{id: follower_id} + } do + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{name: preferred_username} + ) + + assert res["errors"] == nil + assert res["data"]["group"]["id"] == to_string(group_id) + assert res["data"]["group"]["followers"]["total"] == 1 + assert hd(res["data"]["group"]["followers"]["elements"])["id"] == to_string(follower_id) + + Process.sleep(1000) + insert(:follower, target_actor: group) + Process.sleep(1000) + follower3 = insert(:follower, target_actor: group) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{ + name: preferred_username, + followersLimit: 2, + followersPage: 1 + } + ) + + assert res["errors"] == nil + assert res["data"]["group"]["id"] == to_string(group_id) + assert res["data"]["group"]["followers"]["total"] == 3 + assert hd(res["data"]["group"]["followers"]["elements"])["id"] == to_string(follower3.id) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @group_followers_query, + variables: %{ + name: preferred_username, + followersLimit: 2, + followersPage: 2 + } + ) + + assert res["errors"] == nil + assert res["data"]["group"]["id"] == to_string(group_id) + assert res["data"]["group"]["followers"]["total"] == 3 + assert hd(res["data"]["group"]["followers"]["elements"])["id"] == to_string(follower_id) + end + end + + @update_follower_mutation """ + mutation UpdateFollower($id: ID!, $approved: Boolean) { + updateFollower(id: $id, approved: $approved) { + id + approved + } + } + """ + describe "update a follower update_follower/3" do + test "without being logged-in", %{ + conn: conn, + group: %Actor{} = group + } do + %Follower{id: follower_id} = insert(:follower, target_actor: group) + + res = + conn + |> AbsintheHelpers.graphql_query( + query: @update_follower_mutation, + variables: %{id: follower_id, approved: true} + ) + + assert hd(res["errors"])["message"] == "You need to be logged in" + end + + test "without being a member", %{ + conn: conn, + group: %Actor{} = group + } do + user = insert(:user) + insert(:actor, user: user) + %Follower{id: follower_id} = insert(:follower, target_actor: group) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @update_follower_mutation, + variables: %{id: follower_id, approved: true} + ) + + assert hd(res["errors"])["message"] == "You don't have permission to do this" + end + + test "without being a moderator", %{ + conn: conn, + group: %Actor{} = group + } do + user = insert(:user) + actor = insert(:actor, user: user) + insert(:member, parent: group, actor: actor, role: :member) + %Follower{id: follower_id} = insert(:follower, target_actor: group) + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @update_follower_mutation, + variables: %{id: follower_id, approved: true} + ) + + assert hd(res["errors"])["message"] == "You don't have permission to do this" + end + + test "while being a moderator", %{ + conn: conn, + user: user, + follower: %Follower{id: follower_id, approved: false} + } do + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @update_follower_mutation, + variables: %{id: follower_id, approved: true} + ) + + assert res["errors"] == nil + assert res["data"]["updateFollower"]["id"] == to_string(follower_id) + + assert %Follower{approved: true} = Actors.get_follower(follower_id) + end + + test "reject deletes the follower", %{ + conn: conn, + user: user, + follower: %Follower{id: follower_id, approved: false} + } do + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query( + query: @update_follower_mutation, + variables: %{id: follower_id, approved: false} + ) + + assert res["errors"] == nil + assert res["data"]["updateFollower"]["id"] == to_string(follower_id) + + assert is_nil(Actors.get_follower(follower_id)) + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 82b8205b9..09a2f6826 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -55,7 +55,8 @@ defmodule Mobilizon.Factory do shared_inbox_url: "#{Endpoint.url()}/inbox", last_refreshed_at: DateTime.utc_now(), user: build(:user), - visibility: :public + visibility: :public, + manually_approves_followers: false } end @@ -108,7 +109,8 @@ defmodule Mobilizon.Factory do target_actor: build(:actor), actor: build(:actor), id: uuid, - url: "#{Endpoint.url()}/follows/#{uuid}" + url: "#{Endpoint.url()}/follows/#{uuid}", + approved: false } end