Allow group admins to moderate new members

Closes #881

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2021-11-12 15:42:52 +01:00
parent ae24fa17d5
commit 6eba531c89
28 changed files with 795 additions and 212 deletions

View File

@@ -10,6 +10,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Web.Email.Member, as: EmailMember
alias Mobilizon.Web.Endpoint
require Logger
@@ -21,7 +22,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
maybe_relay_if_group_activity: 1
]
@type acceptable_types :: :join | :follow | :invite
@type acceptable_types :: :join | :follow | :invite | :member
@type acceptable_entities ::
accept_join_entities | accept_follow_entities | accept_invite_entities
@@ -35,6 +36,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
:join -> accept_join(entity, additional)
:follow -> accept_follow(entity, additional)
:invite -> accept_invite(entity, additional)
:member -> accept_member(entity, additional)
end
with {:ok, entity, update_data} <- accept_res do
@@ -158,12 +160,47 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
end
end
@spec maybe_refresh_group(Member.t()) :: :ok | nil
defp maybe_refresh_group(%Member{
parent: %Actor{domain: parent_domain, url: parent_url},
actor: %Actor{} = actor
}) do
unless is_nil(parent_domain),
do: Refresher.fetch_group(parent_url, actor)
@spec accept_member(Member.t(), map()) ::
{:ok, Member.t(), Activity.t()} | {:error, Ecto.Changeset.t()}
defp accept_member(
%Member{actor_id: actor_id, actor: actor, parent: %Actor{} = group} = member,
%{moderator: %Actor{url: actor_url} = moderator}
) do
with %Actor{} <- Actors.get_actor!(actor_id),
{:ok, %Member{id: member_id} = member} <-
Actors.update_member(member, %{role: :member}) do
Mobilizon.Service.Activity.Member.insert_activity(member,
subject: "member_approved",
moderator: moderator
)
Absinthe.Subscription.publish(Endpoint, actor,
group_membership_changed: [Actor.preferred_username_and_domain(group), actor_id]
)
EmailMember.send_notification_to_approved_member(member)
Cachex.del(:activity_pub, "member_#{member_id}")
maybe_refresh_group(member)
accept_data = %{
"type" => "Accept",
"attributedTo" => member.parent.url,
"to" => [member.parent.members_url],
"cc" => [member.parent.url],
"actor" => actor_url,
"object" => Convertible.model_to_as(member),
"id" => "#{Endpoint.url()}/accept/member/#{member_id}"
}
{:ok, member, accept_data}
end
end
@spec maybe_refresh_group(Member.t()) :: {:ok, Actor.t()} | {:error, atom()} | {:error}
defp maybe_refresh_group(%Member{
parent: %Actor{} = group
}),
do: Refresher.refresh_profile(group)
end

View File

@@ -69,14 +69,20 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Leave do
end
def leave(
%Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url},
%Actor{id: actor_id, url: actor_url},
%Actor{
type: :Group,
domain: group_domain,
id: group_id,
url: group_url,
members_url: group_members_url
},
%Actor{id: actor_id, url: actor_url, domain: actor_domain},
local,
additional
) do
case Actors.get_member(actor_id, group_id) do
{:ok, %Member{id: member_id} = member} ->
if Map.get(additional, :force_member_removal, false) ||
if Map.get(additional, :force_member_removal, false) || group_domain != actor_domain ||
!Actors.is_only_administrator?(member_id, group_id) do
with {:ok, %Member{} = member} <- Actors.delete_member(member) do
Mobilizon.Service.Activity.Member.insert_activity(member, subject: "member_quit")

View File

@@ -28,6 +28,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Reject do
:join -> reject_join(entity, additional)
:follow -> reject_follow(entity, additional)
:invite -> reject_invite(entity, additional)
:member -> reject_member(entity, additional)
end
{:ok, activity} = create_activity(update_data, local)
@@ -118,4 +119,28 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Reject do
{:ok, member, accept_data}
end
end
@spec reject_member(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any
defp reject_member(
%Member{actor_id: actor_id} = member,
%{moderator: %Actor{url: actor_url}}
) do
with %Actor{} <- Actors.get_actor(actor_id),
{:ok, %Member{url: member_url, id: member_id} = member} <-
Actors.delete_member(member),
Mobilizon.Service.Activity.Member.insert_activity(member,
subject: "member_rejected"
),
accept_data <- %{
"type" => "Reject",
"actor" => actor_url,
"attributedTo" => member.parent.url,
"to" => [member.parent.members_url],
"cc" => [member.parent.url],
"object" => member_url,
"id" => "#{Endpoint.url()}/reject/member/#{member_id}"
} do
{:ok, member, accept_data}
end
end
end

View File

@@ -21,23 +21,25 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Remove do
@spec remove(Member.t(), Actor.t(), Actor.t(), boolean, map) ::
{:ok, Activity.t(), Member.t()} | {:error, :member_not_found | Ecto.Changeset.t()}
def remove(
%Member{} = member,
%Member{id: member_id},
%Actor{type: :Group, url: group_url, members_url: group_members_url},
%Actor{url: moderator_url} = moderator,
local,
_additional \\ %{}
) do
with {:ok, %Member{id: member_id}} <- Actors.update_member(member, %{role: :rejected}),
%Member{} = member <- Actors.get_member(member_id) do
with %Member{actor: %Actor{url: actor_url}} = member <- Actors.get_member(member_id),
{:ok, %Member{}} <- Actors.delete_member(member) do
Mobilizon.Service.Activity.Member.insert_activity(member,
moderator: moderator,
subject: "member_removed"
)
Cachex.del(:activity_pub, "member_#{member_id}")
EmailMember.send_notification_to_removed_member(member)
remove_data = %{
"to" => [group_members_url],
"to" => [actor_url, group_members_url],
"type" => "Remove",
"actor" => moderator_url,
"object" => member.url,

View File

@@ -740,14 +740,13 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
) do
Logger.info("Handle incoming to remove a member from a group")
with {:ok, %Actor{id: moderator_id} = moderator} <-
with {:ok, %Actor{} = moderator} <-
data |> Utils.get_actor() |> ActivityPubActor.get_or_fetch_actor_by_url(),
{:ok, person_id} <- get_remove_object(object),
{:ok, %Actor{type: :Group, id: group_id} = group} <-
origin |> Utils.get_url() |> ActivityPubActor.get_or_fetch_actor_by_url(),
{:is_admin, {:ok, %Member{role: role}}}
when role in [:moderator, :administrator, :creator] <-
{:is_admin, Actors.get_member(moderator_id, group_id)},
{:is_admin, true} <-
{:is_admin, can_remove_actor_from_group?(moderator, group)},
{:is_member, {:ok, %Member{role: role} = member}} when role != :rejected <-
{:is_member, Actors.get_member(person_id, group_id)} do
Actions.Remove.remove(member, group, moderator, false)
@@ -866,6 +865,9 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:error, _err} ->
case get_member(join_object) do
{:ok, %Member{role: :not_approved} = member} ->
do_handle_incoming_accept_join_group(member, :member, %{moderator: actor_accepting})
{:ok, %Member{invited_by: nil} = member} ->
do_handle_incoming_accept_join_group(member, :join)
@@ -922,15 +924,17 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
defp do_handle_incoming_accept_join_group(
%Member{role: role, parent: _group} = member,
type
type,
additional \\ %{}
)
when role in [:not_approved, :rejected, :invited] and type in [:join, :invite] do
when role in [:not_approved, :rejected, :invited] and type in [:join, :invite, :member] do
# Or maybe for groups it's the group that sends the Accept activity
with {:ok, %Activity{} = activity, %Member{role: :member} = member} <-
Actions.Accept.accept(
type,
member,
false
false,
additional
) do
{:ok, activity, member}
end
@@ -1194,4 +1198,17 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Actions.Create.create(:discussion, object_data, false)
end
end
@spec can_remove_actor_from_group?(Actor.t(), Actor.t()) :: boolean()
defp can_remove_actor_from_group?(%Actor{} = moderator, %Actor{} = group) do
case Actors.get_member(moderator.id, group.id) do
{:ok, %Member{role: role}} when role in [:moderator, :administrator, :creator] ->
true
_ ->
# If member moderator not found, it's probably because no one on this instance is member of this group yet
# Therefore we can't access the list of admin/moderators and we just trust the origin domain
moderator.domain == group.domain
end
end
end

View File

@@ -151,7 +151,13 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}) do
{:ok, %Member{} = member} ->
Mobilizon.Service.Activity.Member.insert_activity(member, subject: "member_joined")
subject =
case Mobilizon.Actors.get_default_member_role(group) do
:not_approved -> "member_request"
:member -> "member_joined"
end
Mobilizon.Service.Activity.Member.insert_activity(member, subject: subject)
Absinthe.Subscription.publish(Endpoint, actor,
group_membership_changed: [Actor.preferred_username_and_domain(group), actor.id]

View File

@@ -141,6 +141,37 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
end
end
def approve_member(_parent, %{member_id: member_id}, %{
context: %{current_actor: %Actor{} = moderator}
}) do
case Actors.get_member(member_id) do
%Member{} = member ->
with {:ok, _activity, %Member{} = member} <-
Actions.Accept.accept(:member, member, true, %{moderator: moderator}) do
{:ok, member}
end
{:error, :member_not_found} ->
{:error, dgettext("errors", "You are not a moderator or admin for this group")}
end
end
# TODO : Maybe remove me ? Remove member with exclude parameter does the same
def reject_member(_parent, %{member_id: member_id}, %{
context: %{current_actor: %Actor{} = moderator}
}) do
case Actors.get_member(member_id) do
%Member{} = member ->
with {:ok, _activity, %Member{} = member} <-
Actions.Reject.reject(:member, member, true, %{moderator: moderator}) do
{:ok, member}
end
{:error, :member_not_found} ->
{:error, dgettext("errors", "You are not a moderator or admin for this group")}
end
end
@spec update_member(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Member.t()} | {:error, String.t()}
def update_member(_parent, %{member_id: member_id, role: role}, %{
@@ -168,18 +199,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
@spec remove_member(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Member.t()} | {:error, String.t()}
def remove_member(_parent, %{member_id: member_id, group_id: group_id}, %{
def remove_member(_parent, %{member_id: member_id, exclude: _exclude}, %{
context: %{current_actor: %Actor{id: moderator_id} = moderator}
}) do
with %Member{role: role} = member when role != :rejected <- Actors.get_member(member_id),
%Actor{type: :Group} = group <- Actors.get_actor(group_id),
{:has_rights_to_remove, {:ok, %Member{role: role}}}
when role in [:moderator, :administrator, :creator] <-
{:has_rights_to_remove, Actors.get_member(moderator_id, group_id)},
{:ok, _activity, %Member{}} <-
Actions.Remove.remove(member, group, moderator, true) do
{:ok, member}
else
case Actors.get_member(member_id) do
nil ->
{:error,
dgettext(
"errors",
"This member does not exist"
)}
%Member{role: :rejected} ->
{:error,
dgettext(
@@ -187,15 +217,41 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
"This member already has been rejected."
)}
{:has_rights_to_remove, _} ->
{:error,
dgettext(
"errors",
"You don't have the right to remove this member."
)}
%Member{parent_id: group_id} = member ->
case Actors.get_member(moderator_id, group_id) do
{:ok, %Member{role: role}} when role in [:moderator, :administrator, :creator] ->
%Actor{type: :Group} = group = Actors.get_actor(group_id)
with {:ok, _activity, %Member{}} <-
Actions.Remove.remove(member, group, moderator, true) do
{:ok, member}
end
{:ok, %Member{}} ->
{:error,
dgettext(
"errors",
"You don't have the role needed to remove this member."
)}
{:error, :member_not_found} ->
{:error,
dgettext(
"errors",
"You don't have the right to remove this member."
)}
end
end
end
def remove_member(_parent, _args, _resolution),
do:
{:error,
dgettext(
"errors",
"You must be logged-in to remove a member"
)}
# Rejected members can be invited again
@spec check_member_not_existant_or_rejected(String.t() | integer, String.t() | integer()) ::
boolean()

View File

@@ -81,6 +81,24 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
resolve(&Member.reject_invitation/3)
end
@desc """
Approve a membership request
"""
field :approve_member, :member do
arg(:member_id, non_null(:id), description: "The member ID")
resolve(&Member.approve_member/3)
end
@desc """
Reject a membership request
"""
field :reject_member, :member do
arg(:member_id, non_null(:id), description: "The member ID")
resolve(&Member.reject_member/3)
end
@desc """
Update a member's role
"""
@@ -93,9 +111,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
@desc "Remove a member from a group"
field :remove_member, :member do
arg(:group_id, non_null(:id), description: "The group ID")
arg(:member_id, non_null(:id), description: "The member ID")
arg(:exclude, :boolean,
default_value: false,
description: "Whether the member should be excluded from the group"
)
resolve(&Member.remove_member/3)
end
end

View File

@@ -50,6 +50,14 @@ defmodule Mobilizon.Service.Activity.Renderer.Member do
dgettext("activity", "%{profile} added the member %{member}.", args)
end
defp text(:member_approved, args) do
dgettext("activity", "%{profile} approved the membership request from %{member}.", args)
end
defp text(:member_rejected, args) do
dgettext("activity", "%{profile} rejected the membership request from %{member}.", args)
end
defp text(:member_updated, args) do
dgettext("activity", "%{profile} updated the member %{member}.", args)
end

View File

@@ -247,7 +247,7 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
:direct
:one_day ->
calculate_next_day_notification(Date.utc_today(), timezone)
calculate_next_day_notification(Date.utc_today(), timezone: timezone)
:one_hour ->
DateTime.utc_now()

View File

@@ -46,13 +46,65 @@ defmodule Mobilizon.Web.Email.Member do
end
end
# Only send notification to local members
def send_notification_to_approved_member(%Member{actor: %Actor{user_id: nil}}), do: :ok
def send_notification_to_approved_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
subject =
gettext(
"Your membership request for group %{group} has been approved",
group: Actor.display_name(group)
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_membership_approval)
|> Email.Mailer.send_email_later()
:ok
end
end
# Only send notification to local members
def send_notification_to_removed_member(%Member{actor: %Actor{user_id: nil}}), do: :ok
# Member rejection
def send_notification_to_removed_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group,
role: :rejected
role: :not_approved
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
subject =
gettext(
"Your membership request for group %{group} has been rejected",
group: Actor.display_name(group)
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_membership_rejection)
|> Email.Mailer.send_email_later()
:ok
end
end
def send_notification_to_removed_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
@@ -60,7 +112,7 @@ defmodule Mobilizon.Web.Email.Member do
subject =
gettext(
"You have been removed from group %{group}",
group: group.name
group: Actor.display_name(group)
)
Email.base_email(to: email, subject: subject)

View File

@@ -0,0 +1,69 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "You're in!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Your membership to group %{link_start}<b>%{group}</b>%{link_end} has been approved.", group: Mobilizon.Actors.Actor.display_name(@group), link_start: "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group))}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a href={"#{ Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) }"} target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;">
<%= gettext "View the group" %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,5 @@
<%= gettext "You're in!" %>
==
<%= gettext "Your membership to group %{group} has been approved.", group: Mobilizon.Actors.Actor.display_name(@group) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) %>

View File

@@ -0,0 +1,49 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Sorry, not this time!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Your membership request to join group %{link_start}<b>%{group}</b>%{link_end} has been rejected.", group: Mobilizon.Actors.Actor.display_name(@group), link_start: "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group))}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,4 @@
<%= gettext "Sorry, not this time!" %>
==
<%= gettext "Your membership request to join group %{group} has been rejected.", group: Mobilizon.Actors.Actor.display_name(@group) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) %>