Improve member adding and excluding flow

Allow to exclude a member

Send emails to the member when it's excluded

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-08-14 11:32:23 +02:00
parent ad13a57afc
commit 156eba0551
94 changed files with 2650 additions and 1862 deletions

View File

@@ -47,7 +47,7 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.Storage.Page
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Mailer}
alias Mobilizon.Web.Email.{Admin, Group, Mailer}
require Logger
@@ -225,7 +225,8 @@ defmodule Mobilizon.Federation.ActivityPub do
end
with {:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, entity}
else
err ->
@@ -240,10 +241,12 @@ defmodule Mobilizon.Federation.ActivityPub do
case type do
:join -> reject_join(entity, additional)
:follow -> reject_follow(entity, additional)
:invite -> reject_invite(entity, additional)
end
with {:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, entity}
else
err ->
@@ -376,8 +379,9 @@ defmodule Mobilizon.Federation.ActivityPub do
def leave(object, actor, local \\ true, additional \\ %{})
# TODO: If we want to use this for exclusion we need to have an extra field
# for the actor that excluded the participant
@doc """
Leave an event
"""
def leave(
%Event{id: event_id, url: event_url} = _event,
%Actor{id: actor_id, url: actor_url} = _actor,
@@ -409,10 +413,63 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@doc """
Leave a group
"""
def leave(
%Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url},
%Actor{id: actor_id, url: actor_url},
local,
_additional
) do
with {:member, {:ok, %Member{id: member_id} = member}} <-
{:member, Actors.get_member(actor_id, group_id)},
{:is_only_admin, false} <-
{:is_only_admin, Actors.is_only_administrator?(member_id, group_id)},
{:delete, {:ok, %Member{} = member}} <- {:delete, Actors.delete_member(member)},
leave_data <- %{
"to" => [group_members_url],
"cc" => [group_url],
"attributedTo" => group_url,
"type" => "Leave",
"actor" => actor_url,
"object" => group_url
},
{:ok, activity} <- create_activity(leave_data, local),
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, member}
end
end
def remove(
%Member{} = member,
%Actor{type: :Group, url: group_url, members_url: group_members_url},
%Actor{url: moderator_url},
local,
_additional \\ %{}
) do
with {:ok, %Member{id: member_id}} <- Actors.update_member(member, %{role: :rejected}),
%Member{} = member <- Actors.get_member(member_id),
:ok <- Group.send_notification_to_removed_member(member),
remove_data <- %{
"to" => [group_members_url],
"type" => "Remove",
"actor" => moderator_url,
"object" => member.actor.url,
"origin" => group_url
},
{:ok, activity} <- create_activity(remove_data, local),
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, member}
end
end
@spec invite(Actor.t(), Actor.t(), Actor.t(), boolean, map()) ::
{:ok, map(), Member.t()} | {:error, :member_not_found}
def invite(
%Actor{url: group_url, id: group_id} = group,
%Actor{url: group_url, id: group_id, members_url: members_url} = group,
%Actor{url: actor_url, id: actor_id} = actor,
%Actor{url: target_actor_url, id: target_actor_id} = _target_actor,
local \\ true,
@@ -431,6 +488,7 @@ defmodule Mobilizon.Federation.ActivityPub do
}),
invite_data <- %{
"type" => "Invite",
"attributedTo" => group_url,
"actor" => actor_url,
"object" => group_url,
"target" => target_actor_url,
@@ -439,11 +497,12 @@ defmodule Mobilizon.Federation.ActivityPub do
{:ok, activity} <-
create_activity(
invite_data
|> Map.merge(%{"to" => [target_actor_url], "cc" => [group_url]})
|> Map.merge(%{"to" => [target_actor_url, members_url], "cc" => [group_url]})
|> Map.merge(additional),
local
),
:ok <- maybe_federate(activity) do
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, member}
end
end
@@ -806,9 +865,10 @@ defmodule Mobilizon.Federation.ActivityPub do
Actors.update_member(member, %{role: :member}),
accept_data <- %{
"type" => "Accept",
"actor" => actor_url,
"to" => [inviter.url],
"attributedTo" => member.parent.url,
"to" => [inviter.url, member.parent.members_url],
"cc" => [member.parent.url],
"actor" => actor_url,
"object" => member_url,
"id" => "#{Endpoint.url()}/accept/invite/member/#{member_id}"
} do
@@ -873,4 +933,26 @@ defmodule Mobilizon.Federation.ActivityPub do
err
end
end
@spec reject_invite(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any
defp reject_invite(
%Member{invited_by_id: invited_by_id, actor_id: actor_id} = member,
_additional
) do
with %Actor{} = inviter <- Actors.get_actor(invited_by_id),
%Actor{url: actor_url} <- Actors.get_actor(actor_id),
{:ok, %Member{url: member_url, id: member_id} = member} <-
Actors.delete_member(member),
accept_data <- %{
"type" => "Reject",
"actor" => actor_url,
"attributedTo" => member.parent.url,
"to" => [inviter.url, member.parent.members_url],
"cc" => [member.parent.url],
"object" => member_url,
"id" => "#{Endpoint.url()}/reject/invite/member/#{member_id}"
} do
{:ok, member, accept_data}
end
end
end

View File

@@ -40,7 +40,8 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"actor" => data["actor"] || data["attributedTo"],
"attributedTo" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)
@@ -61,7 +62,8 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
"type" => "Update",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"actor" => data["actor"] || data["attributedTo"],
"attributedTo" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)

View File

@@ -17,7 +17,8 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do
def maybe_preload(%Comment{url: url}),
do: {:ok, Discussions.get_comment_from_url_with_preload!(url)}
def maybe_preload(%Discussion{} = discussion), do: {:ok, discussion}
def maybe_preload(%Discussion{id: discussion_id}),
do: {:ok, Discussions.get_discussion(discussion_id)}
def maybe_preload(%Resource{url: url}),
do: {:ok, Resources.get_resource_by_url_with_preloads(url)}

View File

@@ -21,6 +21,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Tombstone
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Group, Participation}
require Logger
@@ -313,7 +314,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:object_not_found, {:ok, activity, object}} <-
{:object_not_found,
do_handle_incoming_reject_following(rejected_object, actor) ||
do_handle_incoming_reject_join(rejected_object, actor)} do
do_handle_incoming_reject_join(rejected_object, actor) ||
do_handle_incoming_reject_invite(rejected_object, actor)} do
{:ok, activity, object}
else
{:object_not_found, nil} ->
@@ -341,8 +343,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:ok, %Actor{id: actor_id, suspended: false} = actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url),
:ok <- Logger.debug("Fetching contained object"),
{:ok, entity} <-
object |> Utils.get_url() |> fetch_object_optionnally_authenticated(actor),
{:ok, entity} <- process_announce_data(object, actor),
:ok <- eventually_create_share(object, entity, actor_id) do
{:ok, nil, entity}
else
@@ -396,6 +397,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
%{"type" => "Update", "object" => %{"type" => "Note"} = object, "actor" => _actor} =
update_data
) do
Logger.info("Handle incoming to update a note")
with actor <- Utils.get_actor(update_data),
{:ok, %Actor{url: actor_url, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(actor),
@@ -520,9 +523,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(
%{"type" => "Leave", "object" => object, "actor" => actor, "id" => _id} = data
) do
def handle_incoming(%{"type" => "Leave", "object" => object, "actor" => actor} = data) do
with actor <- Utils.get_actor(data),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor),
object <- Utils.get_url(object),
@@ -565,6 +566,39 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(
%{"type" => "Remove", "actor" => actor, "object" => object, "origin" => origin} = data
) do
Logger.info("Handle incoming to remove a member from a group")
with {:ok, %Actor{id: moderator_id} = moderator} <-
data |> Utils.get_actor() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, %Actor{id: person_id}} <-
object |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, %Actor{type: :Group, id: group_id} = group} <-
origin |> Utils.get_url() |> ActivityPub.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_member, {:ok, %Member{role: role} = member}} when role != :rejected <-
{:is_member, Actors.get_member(person_id, group_id)} do
ActivityPub.remove(member, group, moderator, false)
else
{:is_admin, {:ok, %Member{}}} ->
Logger.warn(
"Person #{inspect(actor)} is not an admin from #{inspect(origin)} and can't remove member #{
inspect(object)
}"
)
{:error, "Member already removed"}
{:is_member, {:ok, %Member{role: :rejected}}} ->
Logger.warn("Member #{inspect(object)} already removed from #{inspect(origin)}")
{:error, "Member already removed"}
end
end
#
# # TODO
# # Accept
@@ -761,6 +795,16 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
defp do_handle_incoming_reject_invite(invite_object, %Actor{} = actor_rejecting) do
with {:invite, {:ok, %Member{role: :invited, actor_id: actor_id} = member}} <-
{:invite, get_member(invite_object)},
{:same_actor, true} <- {:same_actor, actor_rejecting.id === actor_id},
{:ok, activity, member} <-
ActivityPub.reject(:invite, member, false) do
{:ok, activity, member}
end
end
# If the object has been announced by a group let's use one of our members to fetch it
@spec fetch_object_optionnally_authenticated(String.t(), Actor.t() | any()) ::
{:ok, struct()} | {:error, any()}
@@ -787,17 +831,24 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
:ok
end
# Comment initiates a whole discussion only if it has full title
@spec is_data_for_comment_or_discussion?(map()) :: boolean()
defp is_data_for_comment_or_discussion?(object_data) do
(not Map.has_key?(object_data, :title) or
is_nil(object_data.title) or object_data.title == "") and
is_data_a_discussion_initialization?(object_data) and
is_nil(object_data.discussion_id)
end
# Comment initiates a whole discussion only if it has full title
@spec is_data_for_comment_or_discussion?(map()) :: boolean()
defp is_data_a_discussion_initialization?(object_data) do
not Map.has_key?(object_data, :title) or
is_nil(object_data.title) or object_data.title == ""
end
# Comment and conversations have different attributes for actor and groups
defp transform_object_data_for_discussion(object_data) do
# Basic comment
if is_data_for_comment_or_discussion?(object_data) do
if is_data_a_discussion_initialization?(object_data) do
object_data
else
# Conversation
@@ -880,4 +931,16 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:ok, Convertible.model_to_as(object)}
end
end
# Otherwise we need to fetch what's at the URL (this is possible only for objects, not activities)
defp process_announce_data(%{"id" => url}, %Actor{} = actor),
do: process_announce_data(url, actor)
defp process_announce_data(url, %Actor{} = actor) do
if Utils.are_same_origin?(url, Endpoint.url()) do
ActivityPub.fetch_object_from_url(url, force: false)
else
fetch_object_optionnally_authenticated(url, actor)
end
end
end

View File

@@ -36,7 +36,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
@impl Entity
@spec update(Comment.t(), map(), map()) :: {:ok, Comment.t(), Activity.t()} | any()
def update(%Comment{} = old_comment, args, additional) do
with args <- prepare_args_for_comment(args),
with args <- prepare_args_for_comment_update(args),
{:ok, %Comment{} = new_comment} <- Discussions.update_comment(old_comment, args),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{new_comment.uuid}"),
comment_as_data <- Convertible.model_to_as(new_comment),
@@ -125,6 +125,20 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
end
end
defp prepare_args_for_comment_update(args) do
with {text, mentions, tags} <-
APIUtils.make_content_html(
args |> Map.get(:text, "") |> String.trim(),
# Can't put additional tags on a comment
[],
"text/html"
),
tags <- ConverterUtils.fetch_tags(tags),
mentions <- Map.get(args, :mentions, []) ++ ConverterUtils.fetch_mentions(mentions) do
Map.merge(args, %{text: text, mentions: mentions, tags: tags})
end
end
@spec handle_event_for_comment(String.t() | integer() | nil) :: Event.t() | nil
defp handle_event_for_comment(event_id) when not is_nil(event_id) do
case Events.get_event_with_preload(event_id) do

View File

@@ -145,7 +145,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def maybe_relay_if_group_activity(_, _), do: :ok
defp do_maybe_relay_if_group_activity(object, attributed_to) when not is_nil(attributed_to) do
defp do_maybe_relay_if_group_activity(object, attributed_to) when is_list(attributed_to),
do: do_maybe_relay_if_group_activity(object, hd(attributed_to))
defp do_maybe_relay_if_group_activity(object, attributed_to) when is_binary(attributed_to) do
id = "#{Endpoint.url()}/announces/#{Ecto.UUID.generate()}"
case Actors.get_local_group_by_url(attributed_to) do
@@ -358,15 +361,14 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def make_announce_data(
%Actor{} = actor,
%{"id" => url, "type" => type, "actor" => object_actor_url} = _object,
%{"actor" => object_actor_url} = object,
activity_id,
public
)
when type in ["Note", "Event", "ResourceCollection", "Document", "Todo"] do
) do
do_make_announce_data(
actor,
object_actor_url,
url,
object,
activity_id,
public
)
@@ -375,7 +377,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
defp do_make_announce_data(
%Actor{type: actor_type} = actor,
object_actor_url,
object_url,
object,
activity_id,
public
) do
@@ -394,7 +396,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
data = %{
"type" => "Announce",
"actor" => actor.url,
"object" => object_url,
"object" => object,
"to" => to,
"cc" => cc
}

View File

@@ -68,10 +68,18 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
tags: tags,
mentions: mentions,
local: is_nil(actor_domain),
visibility: if(Visibility.is_public?(object), do: :public, else: :private)
visibility: if(Visibility.is_public?(object), do: :public, else: :private),
published_at: object["published"]
}
maybe_fetch_parent_object(object, data)
Logger.debug("Converted object before fetching parents")
Logger.debug(inspect(data))
data = maybe_fetch_parent_object(object, data)
Logger.debug("Converted object after fetching parents")
Logger.debug(inspect(data))
data
else
{:ok, %Actor{suspended: true}} ->
:error
@@ -98,7 +106,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
comment.actor.url,
"uuid" => comment.uuid,
"id" => comment.url,
"tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags)
"tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags),
"published" => comment.published_at |> DateTime.to_iso8601()
}
object =

View File

@@ -35,7 +35,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Post do
"name" => post.title,
"content" => post.body,
"attributedTo" => creator_url,
"published" => post.publish_at || post.inserted_at
"published" => (post.publish_at || post.inserted_at) |> DateTime.to_iso8601()
}
end

View File

@@ -36,7 +36,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Resource do
"name" => resource.title,
"summary" => resource.summary,
"context" => get_context(resource),
"attributedTo" => actor_url
"attributedTo" => actor_url,
"published" => resource.published_at |> DateTime.to_iso8601()
}
case type do
@@ -65,7 +66,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Resource do
url: object["id"],
actor_id: actor_id,
creator_id: creator_id,
parent_id: parent_id
parent_id: parent_id,
published_at: object["published"]
}
case type do

View File

@@ -37,7 +37,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Todo do
"id" => todo.url,
"name" => todo.title,
"status" => todo.status,
"todoList" => todo_list_url
"todoList" => todo_list_url,
"published" => todo.published_at |> DateTime.to_iso8601()
}
end
@@ -58,7 +59,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Todo do
status: object["status"],
url: object["id"],
todo_list_id: todo_list_id,
creator_id: creator_id
creator_id: creator_id,
published_at: object["published"]
}
else
{:todo_list, nil} ->

View File

@@ -28,7 +28,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.TodoList do
"type" => "TodoList",
"actor" => group_url,
"id" => todo_list.url,
"name" => todo_list.title
"name" => todo_list.title,
"published" => todo_list.published_at |> DateTime.to_iso8601()
}
end
@@ -43,7 +44,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.TodoList do
%{
title: object["name"],
url: object["id"],
actor_id: group_id
actor_id: group_id,
published_at: object["published"]
}
_ ->

View File

@@ -51,7 +51,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
%CommentModel{actor_id: comment_actor_id} = comment <-
Mobilizon.Discussions.get_comment(comment_id),
Mobilizon.Discussions.get_comment_with_preload(comment_id),
true <- actor_id === comment_actor_id,
{:ok, _, %CommentModel{} = comment} <- Comments.update_comment(comment, %{text: text}) do
{:ok, comment}
@@ -64,15 +64,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
def delete_comment(
_parent,
%{actor_id: actor_id, comment_id: comment_id},
%{comment_id: comment_id},
%{
context: %{
current_user: %User{role: role} = user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
with {:actor, %Actor{id: actor_id} = actor} <- {:actor, Users.get_actor_for_user(user)},
%CommentModel{deleted_at: nil} = comment <-
Discussions.get_comment_with_preload(comment_id) do
cond do

View File

@@ -213,40 +213,25 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
"""
def leave_group(
_parent,
%{group_id: group_id, actor_id: actor_id},
%{group_id: group_id},
%{
context: %{
current_user: user
current_user: %User{} = user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:ok, %Member{} = member} <- Actors.get_member(actor.id, group_id),
{:only_administrator, false} <-
{:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)},
{:ok, _} <-
Mobilizon.Actors.delete_member(member) do
{
:ok,
%{
parent: %{
id: group_id
},
actor: %{
id: actor_id
}
}
}
with {:actor, %Actor{} = actor} <- {:actor, Users.get_actor_for_user(user)},
{:group, %Actor{type: :Group} = group} <- {:group, Actors.get_actor(group_id)},
{:ok, _activity, %Member{} = member} <- ActivityPub.leave(group, actor, true) do
{:ok, member}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:error, :member_not_found} ->
{:error, "Member not found"}
{:only_administrator, true} ->
{:group, nil} ->
{:error, "Group not found"}
{:is_only_admin, true} ->
{:error, "You can't leave this group because you are the only administrator"}
end
end
@@ -278,32 +263,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:ok, %Page{total: 0, elements: []}}
end
# We check that the actor asking to leave the group is not it's only administrator
# We start by fetching the list of administrator or creators and if there's only one of them
# and that it's the actor requesting leaving the group we return true
@spec check_that_member_is_not_last_administrator(integer, integer) :: boolean
defp check_that_member_is_not_last_administrator(group_id, actor_id) do
case Actors.list_administrator_members_for_group(group_id) do
%Page{total: total} when total > 1 ->
true
%Page{
total: 1,
elements: [
%Member{
actor: %Actor{
id: member_actor_id
}
}
]
} ->
actor_id == member_actor_id
_ ->
false
end
end
defp restrict_fields_for_non_member_request(%Actor{} = group) do
Map.merge(
group,

View File

@@ -6,6 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
alias Mobilizon.{Actors, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Refresher
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
@@ -65,7 +66,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
{:target_actor_username, {:ok, %Actor{id: target_actor_id} = target_actor}} <-
{:target_actor_username,
ActivityPub.find_or_make_actor_from_nickname(target_actor_username)},
{:error, :member_not_found} <- Actors.get_member(target_actor_id, group.id),
true <- check_member_not_existant_or_rejected(target_actor_id, group.id),
{:ok, _activity, %Member{} = member} <- ActivityPub.invite(group, actor, target_actor) do
{:ok, member}
else
@@ -91,7 +92,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
def accept_invitation(_parent, %{id: member_id}, %{context: %{current_user: %User{} = user}}) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
%Member{actor: %Actor{id: member_actor_id}} = member <- Actors.get_member(member_id),
%Member{actor: %Actor{id: member_actor_id} = actor} = member <-
Actors.get_member(member_id),
{:is_same_actor, true} <- {:is_same_actor, member_actor_id === actor_id},
{:ok, _activity, %Member{} = member} <-
ActivityPub.accept(
@@ -100,7 +102,51 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
true
) do
# Launch an async task to refresh the group profile, fetch resources, discussions, members
Refresher.fetch_group(member.parent.url, actor)
{:ok, member}
end
end
def reject_invitation(_parent, %{id: member_id}, %{context: %{current_user: %User{} = user}}) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
%Member{actor: %Actor{id: member_actor_id}} = member <- Actors.get_member(member_id),
{:is_same_actor, true} <- {:is_same_actor, member_actor_id === actor_id},
{:ok, _activity, %Member{} = member} <-
ActivityPub.reject(
:invite,
member,
true
) do
{:ok, member}
end
end
def remove_member(_parent, %{member_id: member_id, group_id: group_id}, %{
context: %{current_user: %User{} = user}
}) do
with %Actor{} = moderator <- Users.get_actor_for_user(user),
%Member{} = member <- Actors.get_member(member_id),
%Actor{type: :Group} = group <- Actors.get_actor(group_id),
{:ok, _activity, %Member{}} <- ActivityPub.remove(member, group, moderator, true) do
{:ok, member}
end
end
# Rejected members can be invited again
@spec check_member_not_existant_or_rejected(String.t() | integer, String.t() | integer()) ::
boolean()
defp check_member_not_existant_or_rejected(target_actor_id, group_id) do
case Actors.get_member(target_actor_id, group_id) do
{:ok, %Member{role: :rejected}} ->
true
{:error, :member_not_found} ->
true
err ->
require Logger
Logger.error(inspect(err))
false
end
end
end

View File

@@ -16,6 +16,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
field(:role, :member_role_enum, description: "The role of this membership")
field(:invited_by, :person, description: "Who invited this member")
field(:inserted_at, :naive_datetime, description: "When was this member created")
field(:updated_at, :naive_datetime, description: "When was this member updated")
end
enum :member_role_enum do
@@ -28,12 +29,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
value(:rejected)
end
@desc "Represents a deleted member"
object :deleted_member do
field(:parent, :deleted_object)
field(:actor, :deleted_object)
end
object :paginated_member_list do
field(:elements, list_of(:member), description: "A list of members")
field(:total, :integer, description: "The total number of elements in the list")
@@ -49,9 +44,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
end
@desc "Leave a group"
field :leave_group, :deleted_member do
field :leave_group, :deleted_object do
arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:id))
resolve(&Group.leave_group/3)
end
@@ -70,5 +64,20 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
resolve(&Member.accept_invitation/3)
end
@desc "Reject an invitation to a group"
field :reject_invitation, :member do
arg(:id, non_null(:id))
resolve(&Member.reject_invitation/3)
end
@desc "Remove a member from a group"
field :remove_member, :member do
arg(:group_id, non_null(:id))
arg(:member_id, non_null(:id))
resolve(&Member.remove_member/3)
end
end
end

View File

@@ -80,9 +80,9 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do
resolve(&Comment.update_comment/3)
end
@desc "Delete a single comment"
field :delete_comment, type: :comment do
arg(:comment_id, non_null(:id))
arg(:actor_id, non_null(:id))
resolve(&Comment.delete_comment/3)
end

View File

@@ -283,6 +283,7 @@ defmodule Mobilizon.Actors.Actor do
|> validate_required(@remote_actor_creation_required_attrs)
|> common_changeset(attrs)
|> unique_username_validator()
|> validate_required(:domain)
|> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100)

View File

@@ -558,7 +558,7 @@ defmodule Mobilizon.Actors do
@doc """
Gets a single member.
"""
@spec get_member(integer | String.t()) :: Member.t() | nil
@spec get_member(integer | String.t()) :: {:ok, Member.t()} | nil
def get_member(id) do
Member
|> Repo.get(id)
@@ -642,7 +642,14 @@ defmodule Mobilizon.Actors do
with {:ok, %Member{} = member} <-
%Member{}
|> Member.changeset(attrs)
|> Repo.insert() do
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :url, :actor_id, :parent_id]},
conflict_target: [:actor_id, :parent_id],
# See https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2-upserts,
# when doing an upsert with on_conflict, PG doesn't return whether it's an insert or upsert
# so we need to refresh the fields
returning: true
) do
{:ok, Repo.preload(member, [:actor, :parent, :invited_by])}
end
end
@@ -739,6 +746,20 @@ defmodule Mobilizon.Actors do
|> Repo.all()
end
@doc """
Returns whether the member is the last administrator for a group
"""
@spec is_only_administrator?(integer | String.t(), integer | String.t()) :: boolean()
def is_only_administrator?(member_id, group_id) do
Member
|> where(
[m],
m.parent_id == ^group_id and m.id != ^member_id and m.role in ^@administrator_roles
)
|> Repo.aggregate(:count)
|> (&(&1 == 0)).()
end
@doc """
Gets a single bot.
Raises `Ecto.NoResultsError` if the bot does not exist.
@@ -1240,7 +1261,7 @@ defmodule Mobilizon.Actors do
from(
m in Member,
where: m.actor_id == ^actor_id,
preload: [:parent]
preload: [:parent, :invited_by]
)
end

View File

@@ -6,6 +6,7 @@ defmodule Mobilizon.Discussions.Comment do
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [maybe_add_published_at: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
@@ -32,7 +33,7 @@ defmodule Mobilizon.Discussions.Comment do
# When deleting an event we only nihilify everything
@required_attrs [:url]
@creation_required_attrs @required_attrs ++ [:text, :actor_id]
@creation_required_attrs @required_attrs ++ [:text, :actor_id, :published_at]
@optional_attrs [
:text,
:actor_id,
@@ -54,6 +55,7 @@ defmodule Mobilizon.Discussions.Comment do
field(:uuid, Ecto.UUID)
field(:total_replies, :integer, virtual: true, default: 0)
field(:deleted_at, :utc_datetime)
field(:published_at, :utc_datetime)
belongs_to(:actor, Actor, foreign_key: :actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
@@ -98,7 +100,6 @@ defmodule Mobilizon.Discussions.Comment do
|> change()
|> put_change(:text, nil)
|> put_change(:actor_id, nil)
|> put_change(:discussion_id, nil)
|> put_change(:deleted_at, DateTime.utc_now() |> DateTime.truncate(:second))
end
@@ -116,6 +117,7 @@ defmodule Mobilizon.Discussions.Comment do
defp common_changeset(%__MODULE__{} = comment, attrs) do
comment
|> cast(attrs, @attrs)
|> maybe_add_published_at()
|> maybe_generate_uuid()
|> maybe_generate_url()
|> put_tags(attrs)

View File

@@ -294,6 +294,7 @@ defmodule Mobilizon.Discussions do
Discussion
|> where([c], c.actor_id == ^actor_id)
|> preload(^@discussion_preloads)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.Resources.Resource do
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import Mobilizon.Storage.Ecto, only: [ensure_url: 2, maybe_add_published_at: 1]
import EctoEnum
defenum(TypeEnum, folder: 0, link: 1, picture: 20, pad: 30, calc: 40, visio: 50)
@@ -22,7 +22,8 @@ defmodule Mobilizon.Resources.Resource do
parent: __MODULE__,
actor: Actor.t(),
creator: Actor.t(),
local: boolean
local: boolean,
published_at: DateTime.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
@@ -34,6 +35,7 @@ defmodule Mobilizon.Resources.Resource do
field(:type, TypeEnum)
field(:path, :string)
field(:local, :boolean, default: true)
field(:published_at, :utc_datetime)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:type, :string)
@@ -58,7 +60,7 @@ defmodule Mobilizon.Resources.Resource do
timestamps()
end
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path]
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path, :published_at]
@optional_attrs [:summary, :parent_id, :resource_url, :local]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs [
@@ -82,6 +84,7 @@ defmodule Mobilizon.Resources.Resource do
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url(:resource)
|> maybe_add_published_at()
|> validate_resource_or_folder()
|> validate_required(@required_attrs)
|> unique_constraint(:url, name: :resource_url_index)

View File

@@ -22,7 +22,7 @@ defmodule Mobilizon.Resources do
def get_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> order_by(desc: :published_at)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end

View File

@@ -4,14 +4,15 @@ defmodule Mobilizon.Storage.Ecto do
"""
import Ecto.Query, warn: false
import Ecto.Changeset, only: [fetch_change: 2, put_change: 3]
import Ecto.Changeset, only: [fetch_change: 2, put_change: 3, get_field: 2]
alias Ecto.{Changeset, Query}
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@doc """
Adds sort to the query.
"""
@spec sort(Ecto.Query.t(), atom, atom) :: Ecto.Query.t()
@spec sort(Query.t(), atom, atom) :: Query.t()
def sort(query, sort, direction) do
from(query, order_by: [{^direction, ^sort}])
end
@@ -22,8 +23,8 @@ defmodule Mobilizon.Storage.Ecto do
If there's a blank URL that's because we're doing the first insert.
Most of the time just go with the given URL.
"""
@spec ensure_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
def ensure_url(%Ecto.Changeset{data: %{url: nil}} = changeset, route) do
@spec ensure_url(Changeset.t(), atom()) :: Changeset.t()
def ensure_url(%Changeset{data: %{url: nil}} = changeset, route) do
case fetch_change(changeset, :url) do
{:ok, _url} ->
changeset
@@ -33,10 +34,10 @@ defmodule Mobilizon.Storage.Ecto do
end
end
def ensure_url(%Ecto.Changeset{} = changeset, _route), do: changeset
def ensure_url(%Changeset{} = changeset, _route), do: changeset
@spec generate_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
defp generate_url(%Ecto.Changeset{} = changeset, route) do
@spec generate_url(Changeset.t(), atom()) :: Changeset.t()
defp generate_url(%Changeset{} = changeset, route) do
uuid = Ecto.UUID.generate()
changeset
@@ -46,4 +47,13 @@ defmodule Mobilizon.Storage.Ecto do
apply(Routes, String.to_existing_atom("page_url"), [Endpoint, route, uuid])
)
end
@spec maybe_add_published_at(Changeset.t()) :: Changeset.t()
def maybe_add_published_at(%Changeset{} = changeset) do
if is_nil(get_field(changeset, :published_at)) do
put_change(changeset, :published_at, DateTime.utc_now() |> DateTime.truncate(:second))
else
changeset
end
end
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.Todos.Todo do
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import Mobilizon.Storage.Ecto, only: [ensure_url: 2, maybe_add_published_at: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.TodoList
@@ -16,7 +16,8 @@ defmodule Mobilizon.Todos.Todo do
todo_list: TodoList.t(),
creator: Actor.t(),
assigned_to: Actor.t(),
local: boolean
local: boolean,
published_at: DateTime.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
@@ -26,6 +27,7 @@ defmodule Mobilizon.Todos.Todo do
field(:url, :string)
field(:due_date, :utc_datetime)
field(:local, :boolean, default: true)
field(:published_at, :utc_datetime)
belongs_to(:todo_list, TodoList, type: :binary_id)
belongs_to(:creator, Actor)
belongs_to(:assigned_to, Actor)
@@ -33,7 +35,7 @@ defmodule Mobilizon.Todos.Todo do
timestamps()
end
@required_attrs [:title, :creator_id, :url, :todo_list_id]
@required_attrs [:title, :creator_id, :url, :todo_list_id, :published_at]
@optional_attrs [:status, :due_date, :assigned_to_id, :local]
@attrs @required_attrs ++ @optional_attrs
@@ -42,6 +44,7 @@ defmodule Mobilizon.Todos.Todo do
todo
|> cast(attrs, @attrs)
|> ensure_url(:todo)
|> maybe_add_published_at()
|> validate_required(@required_attrs)
end
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.Todos.TodoList do
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import Mobilizon.Storage.Ecto, only: [ensure_url: 2, maybe_add_published_at: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.Todo
@@ -13,7 +13,8 @@ defmodule Mobilizon.Todos.TodoList do
title: String.t(),
todos: [Todo.t()],
actor: Actor.t(),
local: boolean
local: boolean,
published_at: DateTime.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
@@ -21,14 +22,14 @@ defmodule Mobilizon.Todos.TodoList do
field(:title, :string)
field(:url, :string)
field(:local, :boolean, default: true)
field(:published_at, :utc_datetime)
belongs_to(:actor, Actor)
has_many(:todos, Todo)
timestamps()
end
@required_attrs [:title, :url, :actor_id]
@required_attrs [:title, :url, :actor_id, :published_at]
@optional_attrs [:local]
@attrs @required_attrs ++ @optional_attrs
@@ -37,6 +38,7 @@ defmodule Mobilizon.Todos.TodoList do
todo_list
|> cast(attrs, @attrs)
|> ensure_url(:todo_list)
|> maybe_add_published_at()
|> validate_required(@required_attrs)
end
end

View File

@@ -16,13 +16,30 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
@cache :activity_pub
@doc """
Gets a actor by username and eventually domain.
"""
@spec get_actor_by_name(String.t()) ::
{:commit, Actor.t()} | {:ignore, nil}
def get_actor_by_name(name) do
Cachex.fetch(@cache, "actor_" <> name, fn "actor_" <> name ->
case Actors.get_actor_by_name(name) do
%Actor{} = actor ->
{:commit, actor}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a local actor by username.
"""
@spec get_local_actor_by_name(String.t()) ::
{:commit, Actor.t()} | {:ignore, nil}
def get_local_actor_by_name(name) do
Cachex.fetch(@cache, "actor_" <> name, fn "actor_" <> name ->
Cachex.fetch(@cache, "local_actor_" <> name, fn "local_actor_" <> name ->
case Actors.get_local_actor_by_name(name) do
%Actor{} = actor ->
{:commit, actor}

View File

@@ -17,6 +17,7 @@ defmodule Mobilizon.Web.Cache do
Enum.each(@caches, &Cachex.del(&1, "actor_" <> preferred_username))
end
defdelegate get_actor_by_name(name), to: ActivityPub
defdelegate get_local_actor_by_name(name), to: ActivityPub
defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub

View File

@@ -27,7 +27,7 @@ defmodule Mobilizon.Web.PageController do
@spec actor(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def actor(conn, %{"name" => name}) do
{status, actor} = Cache.get_local_actor_by_name(name)
{status, actor} = Cache.get_actor_by_name(name)
render_or_error(conn, &ok_status?/3, status, :actor, actor)
end

View File

@@ -15,10 +15,14 @@ defmodule Mobilizon.Web.Email.Group do
@doc """
Send emails to local user
"""
@spec send_invite_to_user(Member.t(), String.t()) :: :ok
def send_invite_to_user(member, locale \\ "en")
def send_invite_to_user(%Member{actor: %Actor{user_id: nil}}, _locale), do: :ok
def send_invite_to_user(
%Member{actor: %Actor{user_id: user_id}, parent: %Actor{} = group, role: :invited} =
member,
locale \\ "en"
locale
) do
with %User{email: email} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
@@ -43,5 +47,33 @@ defmodule Mobilizon.Web.Email.Group do
end
end
# Only send notification to local members
def send_notification_to_removed_member(%Member{actor: %Actor{user_id: nil}}), do: :ok
def send_notification_to_removed_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group,
role: :rejected
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
subject =
gettext(
"You have been removed from group %{group}",
group: group.name
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_removal)
|> Email.Mailer.deliver_later()
:ok
end
end
# TODO : def send_confirmation_to_inviter()
end

View File

@@ -35,7 +35,7 @@
<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 "%{inviter} just invited you to join their group %{group}", group: @group.name, inviter: @inviter.name %>
<%= gettext("<b>%{inviter}</b> just invited you to join their group %{link_start}<b>%{group}</b>%{link_end}", group: @group.name, inviter: @inviter.name, link_start: "<a href=\"#{@group.url}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>

View File

@@ -1,5 +1,6 @@
<%= gettext "Come along!" %>
==
<%= gettext "%{inviter} just invited you to join their group %{group}", group: @group.name, inviter: @inviter.name %>
<%= @group.url %>
<%= gettext "To accept this invitation, head over to your groups." %>
<%= page_url(Mobilizon.Web.Endpoint, :my_groups) %>

View File

@@ -0,0 +1,56 @@
<!-- 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 "So long, and thanks for the fish!" %>
</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("You have been removed from group %{link_start}<b>%{group}</b>%{link_end}. You will not be able to access this group's private content anymore.", group: @group.name, link_start: "<a href=\"#{@group.url}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>
<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 "If you feel this is an error, you may contact the group's administrators so that they can add you back." %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,5 @@
<%= gettext "So long, and thanks for the fish!" %>
==
<%= gettext "You have been removed from group %{group}. You will not be able to access this group's private content anymore.", group: @group.name %>
<%= @group.url %>
<%= gettext "If you feel this is an error, you may contact the group's administrators so that they can add you back." %>

View File

@@ -14,7 +14,7 @@ defmodule Mobilizon.Web.ActivityPub.ObjectView do
end,
"actor" => activity.actor,
# Not sure if needed since this is used into outbox
"published" => Timex.now(),
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"to" => activity.recipients,
"object" =>
case data["type"] do