Fix following groups + Add interface to manage followers

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-01-20 18:16:44 +01:00
parent 58fe7b74a5
commit 4fbdc94e7c
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
25 changed files with 929 additions and 83 deletions

View File

@ -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
}
}
`;

View File

@ -64,6 +64,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
suspended suspended
visibility visibility
openness openness
manuallyApprovesFollowers
physicalAddress { physicalAddress {
description description
street street
@ -265,6 +266,7 @@ export const UPDATE_GROUP = gql`
$visibility: GroupVisibility $visibility: GroupVisibility
$openness: Openness $openness: Openness
$physicalAddress: AddressInput $physicalAddress: AddressInput
$manuallyApprovesFollowers: Boolean
) { ) {
updateGroup( updateGroup(
id: $id id: $id
@ -275,6 +277,7 @@ export const UPDATE_GROUP = gql`
visibility: $visibility visibility: $visibility
openness: $openness openness: $openness
physicalAddress: $physicalAddress physicalAddress: $physicalAddress
manuallyApprovesFollowers: $manuallyApprovesFollowers
) { ) {
id id
preferredUsername preferredUsername
@ -282,6 +285,7 @@ export const UPDATE_GROUP = gql`
summary summary
visibility visibility
openness openness
manuallyApprovesFollowers
avatar { avatar {
id id
url url

View File

@ -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.", "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", "Atom feed for events and posts": "Atom feed for events and posts",
"ICS feed for events": "ICS feed for events", "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"
} }

View File

@ -274,7 +274,7 @@
"Find an address": "Trouver une adresse", "Find an address": "Trouver une adresse",
"Find an instance": "Trouver une instance", "Find an instance": "Trouver une instance",
"Find another instance": "Trouver une autre instance", "Find another instance": "Trouver une autre instance",
"Followers": "Abonnés", "Followers": "Abonné⋅es",
"Followings": "Abonnements", "Followings": "Abonnements",
"For instance: London": "Par exemple : Lyon", "For instance: London": "Par exemple : Lyon",
"For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…", "For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…",
@ -925,5 +925,11 @@
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap", "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Atom feed for events and posts": "Flux Atom pour les événements et les billets", "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 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"
} }

View File

@ -8,6 +8,7 @@ export enum GroupsRouteName {
GROUP_SETTINGS = "GROUP_SETTINGS", GROUP_SETTINGS = "GROUP_SETTINGS",
GROUP_PUBLIC_SETTINGS = "GROUP_PUBLIC_SETTINGS", GROUP_PUBLIC_SETTINGS = "GROUP_PUBLIC_SETTINGS",
GROUP_MEMBERS_SETTINGS = "GROUP_MEMBERS_SETTINGS", GROUP_MEMBERS_SETTINGS = "GROUP_MEMBERS_SETTINGS",
GROUP_FOLLOWERS_SETTINGS = "GROUP_FOLLOWERS_SETTINGS",
RESOURCES = "RESOURCES", RESOURCES = "RESOURCES",
RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT", RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT",
RESOURCE_FOLDER = "RESOURCE_FOLDER", RESOURCE_FOLDER = "RESOURCE_FOLDER",
@ -85,6 +86,13 @@ export const groupsRoutes: RouteConfig[] = [
import("../views/Group/GroupMembers.vue"), import("../views/Group/GroupMembers.vue"),
props: true, props: true,
}, },
{
path: "followers",
name: GroupsRouteName.GROUP_FOLLOWERS_SETTINGS,
component: (): Promise<EsModuleComponent> =>
import("../views/Group/GroupFollowers.vue"),
props: true,
},
], ],
}, },
{ {

View File

@ -19,6 +19,7 @@ export interface IGroup extends IActor {
organizedEvents: Paginate<IEvent>; organizedEvents: Paginate<IEvent>;
physicalAddress: IAddress; physicalAddress: IAddress;
openness: Openness; openness: Openness;
manuallyApprovesFollowers: boolean;
} }
export class Group extends Actor implements IGroup { export class Group extends Actor implements IGroup {
@ -45,6 +46,8 @@ export class Group extends Actor implements IGroup {
physicalAddress: IAddress = new Address(); physicalAddress: IAddress = new Address();
manuallyApprovesFollowers = true;
patch(hash: IGroup | Record<string, unknown>): void { patch(hash: IGroup | Record<string, unknown>): void {
Object.assign(this, hash); Object.assign(this, hash);
} }

View File

@ -0,0 +1,261 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul v-if="group">
<li>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Followers") }}</router-link
>
</li>
</ul>
</nav>
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin"
>
<h1>{{ $t("Group Followers") }} ({{ followers.total }})</h1>
<b-field :label="$t('Status')" horizontal>
<b-switch v-model="pending">{{ $t("Pending") }}</b-switch>
</b-field>
<b-table
v-if="followers"
:data="followers.elements"
ref="queueTable"
:loading="this.$apollo.loading"
paginated
backend-pagination
:current-page.sync="page"
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="followers.total"
:per-page="FOLLOWERS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="triggerLoadMoreFollowersPageChange"
@sort="(field, order) => $emit('sort', field, order)"
>
<b-table-column
field="actor.preferredUsername"
:label="$t('Follower')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<img
class="is-rounded"
:src="props.row.actor.avatar.url"
alt=""
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props">
<div class="buttons">
<b-button
v-if="!props.row.approved"
@click="updateFollower(props.row, true)"
icon-left="check"
type="is-success"
>{{ $t("Accept") }}</b-button
>
<b-button
@click="updateFollower(props.row, false)"
icon-left="close"
type="is-danger"
>{{ $t("Reject") }}</b-button
>
</div>
</b-table-column>
<template slot="empty">
<empty-content icon="account" inline>
{{ $t("No follower matches the filters") }}
</empty-content>
</template>
</b-table>
</section>
<b-message v-else-if="group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
import { GROUP_FOLLOWERS, UPDATE_FOLLOWER } from "@/graphql/followers";
import RouteName from "../../router/name";
import { usernameWithDomain } from "../../types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { IFollower } from "@/types/actor/follower.model";
import { Paginate } from "@/types/paginate";
@Component({
apollo: {
followers: {
query: GROUP_FOLLOWERS,
variables() {
return {
name: this.$route.params.preferredUsername,
followersPage: this.page,
followersLimit: this.FOLLOWERS_PER_PAGE,
approved: this.pending === null ? null : !this.pending,
};
},
update: (data) => data.group.followers,
},
},
components: {
EmptyContent,
},
})
export default class GroupFollowers extends mixins(GroupMixin) {
loading = true;
RouteName = RouteName;
page = parseInt((this.$route.query.page as string) || "1", 10);
pending: boolean | null =
(this.$route.query.pending as string) == "1" || null;
FOLLOWERS_PER_PAGE = 1;
usernameWithDomain = usernameWithDomain;
followers!: Paginate<IFollower>;
mounted(): void {
this.page = parseInt((this.$route.query.page as string) || "1", 10);
}
@Watch("page")
triggerLoadMoreFollowersPageChange(page: string): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
@Watch("pending")
triggerPendingStatusPageChange(pending: boolean): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, ...{ pending: pending ? "1" : "0" } },
});
}
async loadMoreFollowers(): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
await this.$apollo.queries.followers.fetchMore({
// New variables
variables() {
return {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
};
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const oldFollowers = previousResult.group.followers;
const newFollowers = fetchMoreResult.group.followers;
return {
elements: [...oldFollowers.elements, ...newFollowers.elements],
total: newFollowers.total,
__typename: oldFollowers.__typename,
};
},
});
}
async updateFollower(follower: IFollower, approved: boolean): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
try {
await this.$apollo.mutate<{ rejectFollower: IFollower }>({
mutation: UPDATE_FOLLOWER,
variables: {
id: follower.id,
approved,
},
refetchQueries: [
{
query: GROUP_FOLLOWERS,
variables: {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
},
},
],
});
this.$notifier.success(
this.$t("@{username}'s follow request was rejected", {
username: follower.actor.preferredUsername,
}) as string
);
} catch (error) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
}
</script>

View File

@ -134,6 +134,15 @@
</b-radio> </b-radio>
</div> </div>
<b-field
:label="$t('Followers')"
:message="$t('Followers will receive new public events and posts.')"
>
<b-checkbox v-model="group.manuallyApprovesFollowers">
{{ $t("Manually approve new followers") }}
</b-checkbox>
</b-field>
<full-address-auto-complete <full-address-auto-complete
:label="$t('Group address')" :label="$t('Group address')"
v-model="group.physicalAddress" v-model="group.physicalAddress"

View File

@ -16,6 +16,10 @@
:title="this.$t('Members')" :title="this.$t('Members')"
:to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }" :to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }"
/> />
<SettingMenuItem
:title="this.$t('Followers')"
:to="{ name: RouteName.GROUP_FOLLOWERS_SETTINGS }"
/>
</SettingMenuSection> </SettingMenuSection>
</ul> </ul>
</aside> </aside>

View File

@ -50,7 +50,6 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Group, Mailer} alias Mobilizon.Web.Email.{Admin, Group, Mailer}
alias Mobilizon.Web.Email.Follow, as: FollowMailer
require Logger require Logger
@ -320,13 +319,22 @@ defmodule Mobilizon.Federation.ActivityPub do
@doc """ @doc """
Make an actor follow another 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}, with {:different_actors, true} <- {:different_actors, followed.id != follower.id},
{:ok, %Follower{} = follower} <- {:ok, activity_data, %Follower{} = follower} <-
Actors.follow(followed, follower, activity_id, false), Types.Actors.follow(
:ok <- FollowMailer.send_notification_to_admins(follower), follower,
follower_as_data <- Convertible.model_to_as(follower), followed,
{:ok, activity} <- create_activity(follower_as_data, local), local,
Map.merge(additional, %{"activity_id" => activity_id})
),
{:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, follower} {:ok, activity, follower}
else else

View File

@ -302,6 +302,10 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
do_handle_incoming_accept_join(accepted_object, actor)} do do_handle_incoming_accept_join(accepted_object, actor)} do
{:ok, activity, object} {:ok, activity, object}
else else
{:object_not_found, {:error, "Follow already accepted"}} ->
Logger.info("Follow was already accepted. Ignoring.")
:error
{:object_not_found, nil} -> {:object_not_found, nil} ->
Logger.warn( Logger.warn(
"Unable to process Accept activity #{inspect(id)}. Object #{inspect(accepted_object)} wasn't found." "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} {:ok, activity, follow}
else else
{:follow, {:ok, %Follower{approved: true} = _follow}} ->
Logger.debug("Follow already accepted")
{:error, "Follow already accepted"}
{:follow, _} -> {:follow, _} ->
Logger.debug( Logger.debug(
"Tried to handle an Accept activity but it's not containing a Follow activity" "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} -> {:same_actor} ->
{:error, "Actor who accepted the follow wasn't the target. Quite odd."} {:error, "Actor who accepted the follow wasn't the target. Quite odd."}
{:ok, %Follower{approved: true} = _follow} ->
{:error, "Follow already accepted"}
end end
end end

View File

@ -1,7 +1,7 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Actors do defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
@moduledoc false @moduledoc false
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.{Actor, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Audience alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity 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.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Web.Email.Follow, as: FollowMailer
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2] 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
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 defp prepare_args_for_actor(args) do
args args
|> maybe_sanitize_username() |> maybe_sanitize_username()
@ -189,4 +199,21 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
{:ok, activity_data, member} {:ok, activity_data, member}
end end
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 end

View File

@ -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

View File

@ -26,8 +26,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
} }
} }
) do ) do
with {:ok, %Actor{id: group_id} = group} <- with {:group, {:ok, %Actor{id: group_id} = group}} <-
ActivityPub.find_or_make_group_from_nickname(name), {:group, ActivityPub.find_or_make_group_from_nickname(name)},
{:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, group} {:ok, group}
@ -35,8 +35,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:member, false} -> {:member, false} ->
find_group(parent, args, nil) find_group(parent, args, nil)
_ -> {:group, _} ->
{:error, :group_not_found} {:error, :group_not_found}
_ ->
{:error, :unknown}
end end
end end

View File

@ -177,6 +177,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:resource_mutations) import_fields(:resource_mutations)
import_fields(:post_mutations) import_fields(:post_mutations)
import_fields(:actor_mutations) import_fields(:actor_mutations)
import_fields(:follower_mutations)
end end
@desc """ @desc """

View File

@ -32,8 +32,6 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
field(:banner, :media, description: "The actor's banner media") field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting # 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")

View File

@ -31,8 +31,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:banner, :media, description: "The actor's banner media") field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting # 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")

View File

@ -3,11 +3,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.FollowerType do
Schema representation for Follower Schema representation for Follower
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Followers
@desc """ @desc """
Represents an actor's follower Represents an actor's follower
""" """
object :follower do object :follower do
field(:id, :id, description: "The follow ID")
field(:target_actor, :actor, description: "What or who the profile follows") field(:target_actor, :actor, description: "What or who the profile follows")
field(:actor, :actor, description: "Which 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(:elements, list_of(:follower), description: "A list of followers")
field(:total, :integer, description: "The total number of elements in the list") field(:total, :integer, description: "The total number of elements in the list")
end 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 end

View File

@ -8,7 +8,18 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Addresses 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 alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType) import_types(Schema.Actors.MemberType)
@ -47,8 +58,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
) )
# These one should have a privacy setting # 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following 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) resolve(&Todos.find_todo_lists_for_group/3)
description("A paginated list of the todo lists this group has") description("A paginated list of the todo lists this group has")
end 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 end
@desc """ @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." 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, arg(:avatar, :media_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing media" "The avatar for the group, either as an object or directly the ID of an existing media"

View File

@ -44,8 +44,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:banner, :media, description: "The actor's banner media") field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting # 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")

View File

@ -1022,6 +1022,16 @@ defmodule Mobilizon.Actors do
@spec list_bots :: [Bot.t()] @spec list_bots :: [Bot.t()]
def list_bots, do: Repo.all(Bot) 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 """ @doc """
Gets a single follower. Gets a single follower.
Raises `Ecto.NoResultsError` if the follower does not exist. Raises `Ecto.NoResultsError` if the follower does not exist.
@ -1149,6 +1159,25 @@ defmodule Mobilizon.Actors do
|> Repo.aggregate(:count) |> Repo.aggregate(:count)
end 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 """ @doc """
Returns the list of followings for an actor. Returns the list of followings for an actor.
If actor A follows actor B and C, actor A's followings are B and C. 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) from(a in query, where: a.preferred_username == ^name and a.domain == ^domain)
end 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() @spec preload_followers(Actor.t(), boolean) :: Actor.t()
defp preload_followers(actor, true), do: Repo.preload(actor, [:followers]) defp preload_followers(actor, true), do: Repo.preload(actor, [:followers])
defp preload_followers(actor, false), do: actor defp preload_followers(actor, false), do: actor

View File

@ -3,19 +3,73 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.FollowTest do
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Follower
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} 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 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) follower = insert(:actor)
followed = insert(:actor) followed = insert(:actor, manually_approves_followers: false)
refute Actors.is_following(follower, followed) refute Actors.is_following(follower, followed)
{:ok, follow_activity, _} = ActivityPub.follow(follower, followed) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed)
assert Actors.is_following(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 = accept_data =
File.read!("test/fixtures/mastodon-accept-activity.json") File.read!("test/fixtures/mastodon-accept-activity.json")
|> Jason.decode!() |> Jason.decode!()
@ -24,7 +78,39 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.FollowTest do
object = object =
accept_data["object"] accept_data["object"]
|> Map.put("actor", follower.url) |> 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) 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 test "it works for incoming accepts which are referenced by IRI only" do
follower = insert(:actor) follower = insert(:actor)
followed = insert(:actor) followed = insert(:actor, manually_approves_followers: true)
{:ok, follow_activity, _} = ActivityPub.follow(follower, followed) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed)

View File

@ -556,57 +556,6 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
end end
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 # test "it works for incoming likes" do
# %Comment{url: url} = insert(:comment) # %Comment{url: url} = insert(:comment)

View File

@ -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

View File

@ -55,7 +55,8 @@ defmodule Mobilizon.Factory do
shared_inbox_url: "#{Endpoint.url()}/inbox", shared_inbox_url: "#{Endpoint.url()}/inbox",
last_refreshed_at: DateTime.utc_now(), last_refreshed_at: DateTime.utc_now(),
user: build(:user), user: build(:user),
visibility: :public visibility: :public,
manually_approves_followers: false
} }
end end
@ -108,7 +109,8 @@ defmodule Mobilizon.Factory do
target_actor: build(:actor), target_actor: build(:actor),
actor: build(:actor), actor: build(:actor),
id: uuid, id: uuid,
url: "#{Endpoint.url()}/follows/#{uuid}" url: "#{Endpoint.url()}/follows/#{uuid}",
approved: false
} }
end end