Introduce group posts

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-07-09 17:24:28 +02:00
parent bec1c69d4b
commit 9c9f1385fb
249 changed files with 11886 additions and 5023 deletions

View File

@@ -13,41 +13,38 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.{
Actors,
Config,
Conversations,
Discussions,
Events,
Reports,
Resources,
Share,
Todos,
Users
}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Reports.Report
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Tombstone
alias Mobilizon.Federation.ActivityPub.{
Activity,
Audience,
Federator,
Fetcher,
Preloader,
Relay,
Transmogrifier,
Types,
Visibility
}
alias Mobilizon.Federation.ActivityPub.Types.{Managable, Ownable}
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Federation.WebFinger
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Service.RichMedia.Parser
alias Mobilizon.Storage.Page
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Mailer}
@@ -74,75 +71,44 @@ defmodule Mobilizon.Federation.ActivityPub do
Fetch an object from an URL, from our local database of events and comments, then eventually remote
"""
# TODO: Make database calls parallel
@spec fetch_object_from_url(String.t()) :: {:ok, %Event{}} | {:ok, %Comment{}} | {:error, any()}
def fetch_object_from_url(url) do
@spec fetch_object_from_url(String.t(), Keyword.t()) ::
{:ok, struct()} | {:error, any()}
def fetch_object_from_url(url, options \\ []) do
Logger.info("Fetching object from url #{url}")
force_fetch = Keyword.get(options, :force, false)
with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)},
{:existing_resource, nil} <- {:existing_resource, Resources.get_resource_by_url(url)},
{:existing_actor, {:error, :actor_not_found}} <-
{:existing_actor, Actors.get_actor_by_url(url)},
date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch_relay(url, date),
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
headers,
follow_redirect: true,
timeout: 10_000,
recv_timeout: 20_000,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <- Jason.decode(body),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"],
"object" => data
},
{:ok, _activity, %{url: object_url} = _object} <- Transmogrifier.handle_incoming(params) do
case data["type"] do
"Event" ->
{:ok, Events.get_public_event_by_url_with_preload!(object_url)}
"Note" ->
{:ok, Conversations.get_comment_from_url_with_preload!(object_url)}
"Document" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"ResourceCollection" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"Actor" ->
{:ok, Actors.get_actor_by_url!(object_url, true)}
other ->
{:error, other}
end
{:existing, nil} <-
{:existing, Tombstone.find_tombstone(url)},
{:existing, nil} <- {:existing, Events.get_event_by_url(url)},
{:existing, nil} <-
{:existing, Discussions.get_discussion_by_url(url)},
{:existing, nil} <- {:existing, Discussions.get_comment_from_url(url)},
{:existing, nil} <- {:existing, Resources.get_resource_by_url(url)},
{:existing, nil} <-
{:existing, Actors.get_actor_by_url_2(url)},
:ok <- Logger.info("Data for URL not found anywhere, going to fetch it"),
{:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do
Logger.debug("Going to preload the new entity")
Preloader.maybe_preload(entity)
else
{:existing_event, %Event{url: event_url}} ->
{:ok, Events.get_public_event_by_url_with_preload!(event_url)}
{:existing, entity} ->
Logger.debug("Entity is already existing")
{:existing_comment, %Comment{url: comment_url}} ->
{:ok, Conversations.get_comment_from_url_with_preload!(comment_url)}
entity =
if force_fetch and not compare_origins?(url, Endpoint.url()) do
Logger.debug("Entity is external and we want a force fetch")
{:existing_resource, %Resource{url: resource_url}} ->
{:ok, Resources.get_resource_by_url_with_preloads(resource_url)}
with {:ok, _activity, entity} <- Fetcher.fetch_and_update(url, options) do
entity
end
else
entity
end
{:existing_actor, {:ok, %Actor{url: actor_url}}} ->
{:ok, Actors.get_actor_by_url!(actor_url, true)}
Logger.debug("Going to preload an existing entity")
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
Preloader.maybe_preload(entity)
e ->
Logger.warn("Something failed while fetching url #{inspect(e)}")
@@ -201,15 +167,18 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:tombstone, nil} <- {:tombstone, check_for_tombstones(args)},
{:ok, entity, create_data} <-
(case type do
:event -> create_event(args, additional)
:comment -> create_comment(args, additional)
:group -> create_group(args, additional)
:todo_list -> create_todo_list(args, additional)
:todo -> create_todo(args, additional)
:resource -> create_resource(args, additional)
:event -> Types.Events.create(args, additional)
:comment -> Types.Comments.create(args, additional)
:discussion -> Types.Discussions.create(args, additional)
:actor -> Types.Actors.create(args, additional)
:todo_list -> Types.TodoLists.create(args, additional)
:todo -> Types.Todos.create(args, additional)
:resource -> Types.Resources.create(args, additional)
:post -> Types.Posts.create(args, additional)
end),
{:ok, activity} <- create_activity(create_data, local),
:ok <- maybe_federate(activity) do
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, entity}
else
err ->
@@ -227,21 +196,15 @@ defmodule Mobilizon.Federation.ActivityPub do
* Federates (asynchronously) the activity
* Returns the activity
"""
@spec update(atom(), struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any()
def update(type, old_entity, args, local \\ false, additional \\ %{}) do
@spec update(struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any()
def update(old_entity, args, local \\ false, additional \\ %{}) do
Logger.debug("updating an activity")
Logger.debug(inspect(args))
with {:ok, entity, update_data} <-
(case type do
:event -> update_event(old_entity, args, additional)
:comment -> update_comment(old_entity, args, additional)
:actor -> update_actor(old_entity, args, additional)
:todo -> update_todo(old_entity, args, additional)
:resource -> update_resource(old_entity, args, additional)
end),
with {:ok, entity, update_data} <- Managable.update(old_entity, args, additional),
{: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 ->
@@ -366,182 +329,48 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def delete(object, local \\ true)
@spec delete(Event.t(), boolean) :: {:ok, Activity.t(), Event.t()}
def delete(%Event{url: url, organizer_actor: actor} = event, local) do
data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => url,
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"],
"id" => url <> "/delete"
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(event),
{:ok, %Event{} = event} <- Events.delete_event(event),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{event.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}),
Share.delete_all_by_uri(event.url),
def delete(object, actor, local \\ true) do
with {:ok, activity_data, actor, object} <-
Managable.delete(object, actor, local),
group <- Ownable.group_actor(object),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, event}
{:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity, group) do
{:ok, activity, object}
end
end
@spec delete(Comment.t(), boolean) :: {:ok, Activity.t(), Comment.t()}
def delete(%Comment{url: url, actor: actor} = comment, local) do
data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => url,
"id" => url <> "/delete",
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Conversations.delete_comment(comment),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
Share.delete_all_by_uri(comment.url),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
def join(%Event{} = event, %Actor{} = actor, local \\ true, additional \\ %{}) do
with {:ok, activity_data, participant} <- Types.Events.join(event, actor, local, additional),
{:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, comment}
end
end
def delete(%Actor{url: url} = actor, local) do
data = %{
"type" => "Delete",
"actor" => url,
"object" => url,
"id" => url <> "/delete",
"to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
# We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor, reserve_username: local),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, actor}
end
end
def delete(
%Resource{url: url, actor: %Actor{url: actor_url}} = resource,
local
) do
Logger.debug("Building Delete Resource activity")
data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => url,
"id" => url <> "/delete",
"to" => [actor_url]
}
Logger.debug(inspect(data))
with {:ok, _resource} <- Resources.delete_resource(resource),
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}"),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, resource}
end
end
def flag(args, local \\ false, _additional \\ %{}) do
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
{:create_report, {:ok, %Report{} = report}} <-
{:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report),
cc <- if(local, do: [report.reported.url], else: []),
report_as_data <- Map.merge(report_as_data, %{"to" => [], "cc" => cc}),
{:ok, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator ->
moderator
|> Admin.report(report)
|> Mailer.deliver_later()
end)
{:ok, activity, report}
{:ok, activity, participant}
else
err ->
Logger.error("Something went wrong while creating an activity")
Logger.debug(inspect(err))
err
{:maximum_attendee_capacity, err} ->
{:maximum_attendee_capacity, err}
{:accept, accept} ->
accept
end
end
def join(object, actor, local \\ true, additional \\ %{})
def join(%Event{} = event, %Actor{} = actor, local, additional) do
# TODO Refactor me for federation
with {:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity, check_attendee_capacity(event)},
role <-
additional
|> Map.get(:metadata, %{})
|> Map.get(:role, Mobilizon.Events.get_default_participant_role(event)),
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: role,
event_id: event.id,
actor_id: actor.id,
url: Map.get(additional, :url),
metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
def join_group(
%{parent_id: parent_id, actor_id: actor_id, role: role},
local \\ true,
additional \\ %{}
) do
with {:ok, %Member{} = member} <-
Mobilizon.Actors.create_member(%{
parent_id: parent_id,
actor_id: actor_id,
role: role
}),
join_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
activity_data when is_map(activity_data) <-
Convertible.model_to_as(member),
{:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),
:ok <- maybe_federate(activity) do
if event.local do
cond do
Mobilizon.Events.get_default_participant_role(event) === :participant &&
role == :participant ->
accept(
:join,
participant,
true,
%{"actor" => event.organizer_actor.url}
)
Mobilizon.Events.get_default_participant_role(event) === :not_approved &&
role == :not_approved ->
Scheduler.pending_participation_notification(event)
{:ok, activity, participant}
true ->
{:ok, activity, participant}
end
else
{:ok, activity, participant}
end
end
end
# TODO: Implement me
def join(%Actor{type: :Group} = _group, %Actor{} = _actor, _local, _additional) do
:error
end
defp check_attendee_capacity(%Event{options: options} = event) do
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0 do
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) < maximum_attendee_capacity
{:ok, activity, member}
end
end
@@ -640,7 +469,7 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:ok, entity, update_data} <-
(case type do
:resource -> move_resource(old_entity, args, additional)
:resource -> Types.Resources.move(old_entity, args, additional)
end),
{:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
@@ -653,6 +482,25 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def flag(args, local \\ false, additional \\ %{}) do
with {report, report_as_data} <- Types.Reports.flag(args, local, additional),
{:ok, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator ->
moderator
|> Admin.report(report)
|> Mailer.deliver_later()
end)
{:ok, activity, report}
else
err ->
Logger.error("Something went wrong while creating an activity")
Logger.debug(inspect(err))
err
end
end
@doc """
Create an actor locally by its URL (AP ID)
"""
@@ -711,9 +559,29 @@ defmodule Mobilizon.Federation.ActivityPub do
defp is_create_activity?(%Activity{data: %{"type" => "Create"}}), do: true
defp is_create_activity?(_), do: false
@spec is_announce_activity?(Activity.t()) :: boolean
defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
defp is_announce_activity?(_), do: false
@spec convert_members_in_recipients(list(String.t())) :: {list(String.t()), list(Actor.t())}
defp convert_members_in_recipients(recipients) do
Enum.reduce(recipients, {recipients, []}, fn recipient, {recipients, member_actors} = acc ->
case Actors.get_group_by_members_url(recipient) do
# If the group is local just add external members
%Actor{domain: domain} = group when is_nil(domain) ->
{Enum.filter(recipients, fn recipient -> recipient != group.members_url end),
member_actors ++ Actors.list_external_actors_members_for_group(group)}
# If it's remote add the remote group actor as well
%Actor{} = group ->
{Enum.filter(recipients, fn recipient -> recipient != group.members_url end),
member_actors ++ Actors.list_external_actors_members_for_group(group) ++ [group]}
_ ->
acc
end
end)
end
# @spec is_announce_activity?(Activity.t()) :: boolean
# defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
# defp is_announce_activity?(_), do: false
@doc """
Publish an activity to all appropriated audiences inboxes
@@ -741,19 +609,11 @@ defmodule Mobilizon.Federation.ActivityPub do
{recipients, []}
end
# If we want to send to all members of the group, because this server is the one the group is on
{recipients, members} =
if is_announce_activity?(activity) and actor.type == :Group and
actor.members_url in activity.recipients and is_nil(actor.domain) do
{Enum.filter(recipients, fn recipient -> recipient != actor.members_url end),
Actors.list_external_members_for_group(actor)}
else
{recipients, []}
end
{recipients, members} = convert_members_in_recipients(recipients)
remote_inboxes =
(remote_actors(recipients) ++ followers ++ members)
|> Enum.map(fn follower -> follower.shared_inbox_url || follower.inbox_url end)
|> Enum.map(fn actor -> actor.shared_inbox_url || actor.inbox_url end)
|> Enum.uniq()
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
@@ -791,16 +651,15 @@ defmodule Mobilizon.Federation.ActivityPub do
date: date
})
HTTPoison.post(
Tesla.post(
inbox,
json,
[
headers: [
{"Content-Type", "application/activity+json"},
{"signature", signature},
{"digest", digest},
{"date", date}
],
hackney: [pool: :default]
]
)
end
@@ -811,18 +670,15 @@ defmodule Mobilizon.Federation.ActivityPub do
Logger.debug(inspect(url))
res =
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, [Accept: "application/activity+json"],
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
with {:ok, %{status: 200, body: body}} <-
Tesla.get(url, headers: [{"Accept", "application/activity+json"}]),
:ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data")
{:ok, Converter.Actor.as_to_model_data(data)}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
{:ok, %{status: 410}} ->
Logger.info("Response HTTP 410")
{:error, :actor_deleted}
@@ -839,10 +695,11 @@ defmodule Mobilizon.Federation.ActivityPub do
"""
@spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map()
def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do
{:ok, events, total_events} = Events.list_public_events_for_actor(actor, page, limit)
%Page{total: total_events, elements: events} =
Events.list_public_events_for_actor(actor, page, limit)
{:ok, comments, total_comments} =
Conversations.list_public_comments_for_actor(actor, page, limit)
%Page{total: total_comments, elements: comments} =
Discussions.list_public_comments_for_actor(actor, page, limit)
event_activities = Enum.map(events, &event_to_activity/1)
comment_activities = Enum.map(comments, &comment_to_activity/1)
@@ -879,252 +736,10 @@ defmodule Mobilizon.Federation.ActivityPub do
Map.get(data, "to", []) ++ Map.get(data, "cc", [])
end
@spec create_event(map(), map()) :: {:ok, map()}
defp create_event(args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = event} <- Events.create_event(args),
event_as_data <- Convertible.model_to_as(event),
audience <-
Audience.calculate_to_and_cc_from_mentions(event),
create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data}
end
end
@spec create_comment(map(), map()) :: {:ok, map()}
defp create_comment(args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = comment} <- Conversations.create_comment(args),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data}
end
end
@spec create_group(map(), map()) :: {:ok, map()}
defp create_group(args, additional) do
with args <- prepare_args_for_group(args),
{:ok, %Actor{type: :Group} = group} <- Actors.create_group(args),
group_as_data <- Convertible.model_to_as(group),
audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
create_data <-
make_create_data(group_as_data, Map.merge(audience, additional)) do
{:ok, group, create_data}
end
end
@spec create_todo_list(map(), map()) :: {:ok, map()}
defp create_todo_list(args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <- Todos.create_todo_list(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <- Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, create_data}
end
end
@spec create_todo(map(), map()) :: {:ok, map()}
defp create_todo(args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id, creator_id: creator_id} = todo} <-
Todos.create_todo(args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
%Actor{} = creator <- Actors.get_actor(creator_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo <- %{todo | todo_list: %{todo_list | actor: group}, creator: creator},
todo_as_data <-
Convertible.model_to_as(todo),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
end
end
defp create_resource(%{type: type} = args, additional) do
args =
case type do
:folder ->
args
_ ->
case Parser.parse(Map.get(args, :resource_url)) do
{:ok, metadata} ->
Map.put(args, :metadata, metadata)
_ ->
args
end
end
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: parent_id} = resource} <-
Resources.create_resource(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group, creator: creator}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data =
case parent_id do
nil ->
make_create_data(resource_as_data, Map.merge(audience, additional))
parent_id ->
# In case the resource has a parent we don't `Create` the resource but `Add` it to an existing resource
parent = Resources.get_resource(parent_id)
make_add_data(resource_as_data, parent, Map.merge(audience, additional))
end
{:ok, resource, create_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec check_for_tombstones(map()) :: Tombstone.t() | nil
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
defp check_for_tombstones(_), do: nil
@spec update_event(Event.t(), map(), map()) :: {:ok, Event.t(), Activity.t()} | any()
defp update_event(%Event{} = old_event, args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = new_event} <- Events.update_event(old_event, args),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{new_event.uuid}"),
event_as_data <- Convertible.model_to_as(new_event),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_event),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec update_comment(Comment.t(), map(), map()) :: {:ok, Comment.t(), Activity.t()} | any()
defp update_comment(%Comment{} = old_comment, args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = new_comment} <- Conversations.update_comment(old_comment, args),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{new_comment.uuid}"),
comment_as_data <- Convertible.model_to_as(new_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_comment),
update_data <- make_update_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, new_comment, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec update_actor(Actor.t(), map, map) :: {:ok, Actor.t(), Activity.t()} | any
defp update_actor(%Actor{} = old_actor, args, additional) do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor),
{:ok, true} <- Cachex.del(:activity_pub, "actor_#{new_actor.preferred_username}"),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_actor),
additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data}
end
end
@spec update_todo(Todo.t(), map, map) :: {:ok, Todo.t(), Activity.t()} | any
defp update_todo(%Todo{} = old_todo, args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id} = todo} <- Todos.update_todo(old_todo, args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.url], "cc" => []},
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, update_data}
end
end
defp update_resource(%Resource{} = old_resource, %{parent_id: _parent_id} = args, additional) do
move_resource(old_resource, args, additional)
end
# Simple rename
defp update_resource(%Resource{} = old_resource, %{title: title} = _args, additional) do
with {:ok, %Resource{actor_id: group_id, creator_id: creator_id} = resource} <-
Resources.update_resource(old_resource, %{title: title}),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
update_data <-
make_update_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, update_data}
else
err ->
Logger.error(inspect(err))
err
end
end
defp move_resource(
%Resource{parent_id: old_parent_id} = old_resource,
%{parent_id: _new_parent_id} = args,
additional
) do
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: new_parent_id} =
resource} <-
Resources.update_resource(old_resource, args),
old_parent <- Resources.get_resource(old_parent_id),
new_parent <- Resources.get_resource(new_parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
move_data <-
make_move_data(
resource_as_data,
old_parent,
new_parent,
Map.merge(audience, additional)
) do
{:ok, resource, move_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec accept_follow(Follower.t(), map) :: {:ok, Follower.t(), Activity.t()} | any
defp accept_follow(%Follower{} = follower, additional) do
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}),
@@ -1254,138 +869,4 @@ defmodule Mobilizon.Federation.ActivityPub do
err
end
end
# Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do
# If title is not set: we are not updating it
args =
if Map.has_key?(args, :title) && !is_nil(args.title),
do: Map.update(args, :title, "", &String.trim/1),
else: args
# If we've been given a description (we might not get one if updating)
# sanitize it, HTML it, and extract tags & mentions from it
args =
if Map.has_key?(args, :description) && !is_nil(args.description) do
{description, mentions, tags} =
APIUtils.make_content_html(
String.trim(args.description),
Map.get(args, :tags, []),
"text/html"
)
mentions = ConverterUtils.fetch_mentions(Map.get(args, :mentions, []) ++ mentions)
Map.merge(args, %{
description: description,
mentions: mentions,
tags: tags
})
else
args
end
# Check that we can only allow anonymous participation if our instance allows it
{_, options} =
Map.get_and_update(
Map.get(args, :options, %{anonymous_participation: false}),
:anonymous_participation,
fn value ->
{value, value && Mobilizon.Config.anonymous_participation?()}
end
)
args = Map.put(args, :options, options)
Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1)
end
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Conversations.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{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),
args <-
Map.merge(args, %{
actor_id: Map.get(args, :actor_id),
text: text,
mentions: mentions,
tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
origin_comment_id:
if(is_nil(in_reply_to_comment),
do: nil,
else: Comment.get_thread_id(in_reply_to_comment)
)
}) do
args
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
{:ok, %Event{} = event} -> event
{:error, :event_not_found} -> nil
end
end
defp handle_event_for_comment(nil), do: nil
defp prepare_args_for_group(args) do
with preferred_username <-
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
summary <- args |> Map.get(:summary, "") |> String.trim(),
{summary, _mentions, _tags} <-
summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
%{args | preferred_username: preferred_username, summary: summary}
end
end
defp prepare_args_for_report(args) do
with {:reporter, %Actor{} = reporter_actor} <-
{:reporter, Actors.get_actor!(args.reporter_id)},
{:reported, %Actor{} = reported_actor} <-
{:reported, Actors.get_actor!(args.reported_id)},
content <- HTML.strip_tags(args.content),
event <- Conversations.get_comment(Map.get(args, :event_id)),
{:get_report_comments, comments} <-
{:get_report_comments,
Conversations.list_comments_by_actor_and_ids(
reported_actor.id,
Map.get(args, :comments_ids, [])
)} do
Map.merge(args, %{
reporter: reporter_actor,
reported: reported_actor,
content: content,
event: event,
comments: comments
})
end
end
defp check_for_actor_key_rotation(%Actor{} = actor) do
if Actors.should_rotate_actor_key(actor) do
Actors.schedule_key_rotation(
actor,
Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay]
)
end
:ok
end
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Share
alias Mobilizon.Storage.Repo
@@ -79,6 +79,14 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
def get_addressed_actors(mentioned_users, _), do: mentioned_users
def calculate_to_and_cc_from_mentions(
%Comment{discussion: %Discussion{actor_id: actor_id}} = _comment
) do
with %Actor{type: :Group, members_url: members_url} <- Actors.get_actor(actor_id) do
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Comment{} = comment) do
with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil),
@@ -96,6 +104,28 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
end
end
def calculate_to_and_cc_from_mentions(%Discussion{actor_id: actor_id}) do
with %Actor{type: :Group, members_url: members_url} <- Actors.get_actor(actor_id) do
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Event{
attributed_to: %Actor{members_url: members_url},
visibility: visibility
}) do
case visibility do
:public ->
%{"to" => [members_url, @ap_public], "cc" => []}
:unlisted ->
%{"to" => [members_url], "cc" => [@ap_public]}
:private ->
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Event{} = event) do
with mentioned_actors <- Enum.map(event.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil),

View File

@@ -0,0 +1,74 @@
defmodule Mobilizon.Federation.ActivityPub.Fetcher do
@moduledoc """
Module to handle direct URL ActivityPub fetches to remote content
If you need to first get cached data, see `Mobilizon.Federation.ActivityPub.fetch_object_from_url/2`
"""
require Logger
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Federation.ActivityPub.{Relay, Transmogrifier}
alias Mobilizon.Service.HTTP.ActivityPub, as: ActivityPubClient
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4, origin_check?: 2]
@spec fetch(String.t(), Keyword.t()) :: {:ok, map()}
def fetch(url, options \\ []) do
on_behalf_of = Keyword.get(options, :on_behalf_of, Relay.get_actor())
with date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date),
client <-
ActivityPubClient.client(headers: headers),
{:ok, %Tesla.Env{body: data, status: code}} when code in 200..299 <-
ActivityPubClient.get(client, url) do
{:ok, data}
end
end
@spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_create(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options),
:ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
:ok <- Logger.debug(inspect(data)),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)
else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
end
end
@spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_update(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options),
:ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
:ok <- Logger.debug(inspect(data)),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Update",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)
else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
end
end
end

View File

@@ -0,0 +1,30 @@
defmodule Mobilizon.Federation.ActivityPub.Preloader do
@moduledoc """
Module to ensure entities are correctly preloaded
"""
# TODO: Move me in a more appropriate place
alias Mobilizon.{Actors, Discussions, Events, Resources}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Resources.Resource
alias Mobilizon.Tombstone
def maybe_preload(%Event{url: url}),
do: {:ok, Events.get_public_event_by_url_with_preload!(url)}
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(%Resource{url: url}),
do: {:ok, Resources.get_resource_by_url_with_preloads(url)}
def maybe_preload(%Actor{url: url}), do: {:ok, Actors.get_actor_by_url!(url, true)}
def maybe_preload(%Tombstone{uri: _uri} = tombstone), do: {:ok, tombstone}
def maybe_preload(other), do: {:error, other}
end

View File

@@ -3,24 +3,31 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Module that provides functions to explore and fetch collections on a group
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.Converter.Member, as: MemberConverter
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Resources
alias Mobilizon.Federation.ActivityPub.{Fetcher, Transmogrifier}
require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4]
@spec fetch_group(String.t(), Actor.t()) :: :ok
def fetch_group(group_url, %Actor{} = on_behalf_of) do
with {:ok, %Actor{resources_url: resources_url, members_url: members_url}} <-
with {:ok,
%Actor{
outbox_url: outbox_url,
resources_url: resources_url,
members_url: members_url,
posts_url: posts_url,
todos_url: todos_url,
discussions_url: discussions_url,
events_url: events_url
}} <-
ActivityPub.get_or_fetch_actor_by_url(group_url) do
fetch_collection(outbox_url, on_behalf_of)
fetch_collection(members_url, on_behalf_of)
fetch_collection(resources_url, on_behalf_of)
fetch_collection(posts_url, on_behalf_of)
fetch_collection(todos_url, on_behalf_of)
fetch_collection(discussions_url, on_behalf_of)
fetch_collection(events_url, on_behalf_of)
end
end
@@ -30,12 +37,28 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug("Fetching and preparing collection from url")
Logger.debug(inspect(collection_url))
with {:ok, data} <- fetch(collection_url, on_behalf_of) do
with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of) do
Logger.debug("Fetch ok, passing to process_collection")
process_collection(data, on_behalf_of)
end
end
@spec fetch_element(String.t(), Actor.t()) :: any()
def fetch_element(url, %Actor{} = on_behalf_of) do
with {:ok, data} <- Fetcher.fetch(url, on_behalf_of: on_behalf_of) do
case handling_element(data) do
{:ok, _activity, entity} ->
{:ok, entity}
{:ok, entity} ->
{:ok, entity}
err ->
{:error, err}
end
end
end
defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of)
when type in ["OrderedCollection", "OrderedCollectionPage"] do
Logger.debug(
@@ -55,55 +78,26 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
when is_bitstring(first) do
Logger.debug("OrderedCollection has a first property pointing to an URI")
with {:ok, data} <- fetch(first, on_behalf_of) do
with {:ok, data} <- Fetcher.fetch(first, on_behalf_of: on_behalf_of) do
Logger.debug("Fetched the collection for first property")
process_collection(data, on_behalf_of)
end
end
defp handling_element(%{"type" => "Member"} = data) do
Logger.debug("Handling Member element")
defp handling_element(data) when is_map(data) do
activity = %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["actor"],
"attributedTo" => data["attributedTo"],
"object" => data
}
data
|> MemberConverter.as_to_model_data()
|> Actors.create_member()
Transmogrifier.handle_incoming(activity)
end
defp handling_element(%{"type" => type} = data)
when type in ["Document", "ResourceCollection"] do
Logger.debug("Handling Resource element")
data
|> ResourceConverter.as_to_model_data()
|> Resources.create_resource()
end
defp fetch(url, %Actor{} = on_behalf_of) do
with date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date),
%HTTPoison.Response{status_code: 200, body: body} <-
HTTPoison.get!(url, headers,
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <-
Jason.decode(body) do
{:ok, data}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
Logger.info("Response HTTP 410")
{:error, :actor_deleted}
{:origin_check, false} ->
{:error, "Origin check failed"}
e ->
Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}")
{:error, e}
end
defp handling_element(uri) when is_binary(uri) do
ActivityPub.fetch_object_from_url(uri)
end
end

View File

@@ -8,17 +8,19 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
A module to handle coding from internal to wire ActivityPub and back.
"""
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos}
alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.Federation.ActivityPub.{Activity, Relay, Utils}
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Tombstone
alias Mobilizon.Web.Email.{Group, Participation}
require Logger
@@ -62,10 +64,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
with object_data when is_map(object_data) <-
object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <-
ActivityPub.create(:comment, object_data, false) do
{:ok, activity, comment}
{:existing_comment, Discussions.get_comment_from_url_with_preload(object_data.url)},
object_data <- transform_object_data_for_discussion(object_data) do
# Check should be better
{:ok, %Activity{} = activity, entity} =
if is_data_for_comment_or_discussion?(object_data) do
Logger.debug("Chosing to create a regular comment")
ActivityPub.create(:comment, object_data, false)
else
Logger.debug("Chosing to initialize or add a comment to a conversation")
ActivityPub.create(:discussion, object_data, false)
end
{:ok, activity, entity}
else
{:existing_comment, {:ok, %Comment{} = comment}} ->
{:ok, nil, comment}
@@ -100,6 +112,77 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Group", "id" => group_url} = _object
}) do
Logger.info("Handle incoming to create a group")
with {:ok, %Actor{} = group} <- ActivityPub.get_or_fetch_actor_by_url(group_url) do
{:ok, nil, group}
end
end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Member"} = object
}) do
Logger.info("Handle incoming to create a member")
with object_data when is_map(object_data) <-
object |> Converter.Member.as_to_model_data(),
{:existing_member, nil} <-
{:existing_member, Actors.get_member_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Member{} = member} <-
ActivityPub.join_group(object_data, false) do
{:ok, activity, member}
else
{:existing_member, %Member{} = member} ->
{:ok, nil, member}
end
end
def handle_incoming(%{
"type" => "Create",
"object" =>
%{"type" => "Article", "actor" => _actor, "attributedTo" => _attributed_to} = object
}) do
Logger.info("Handle incoming to create articles")
with object_data when is_map(object_data) <-
object |> Converter.Post.as_to_model_data(),
{:existing_post, nil} <-
{:existing_post, Posts.get_post_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Post{} = post} <-
ActivityPub.create(:post, object_data, false) do
{:ok, activity, post}
else
{:existing_post, %Post{} = post} ->
{:ok, nil, post}
end
end
# This is a hack to handle Tombstones fetched by AP
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Tombstone", "id" => object_url} = _object
}) do
Logger.info("Handle incoming to create a tombstone")
case ActivityPub.fetch_object_from_url(object_url, force: true) do
# We already have the tombstone, object is probably already deleted
{:ok, %Tombstone{} = tombstone} ->
{:ok, nil, tombstone}
# Hack because deleted comments
{:ok, %Comment{deleted_at: deleted_at} = comment} when not is_nil(deleted_at) ->
{:ok, nil, comment}
{:ok, entity} ->
ActivityPub.delete(entity, Relay.get_actor(), false)
end
end
def handle_incoming(
%{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data
) do
@@ -165,7 +248,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create a resource")
Logger.debug(inspect(data))
group_url = hd(to)
group_url = if is_list(to) and not is_nil(to), do: hd(to), else: to
with {:existing_resource, nil} <-
{:existing_resource, Resources.get_resource_by_url(object_url)},
@@ -175,8 +258,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:member, Actors.is_member?(object_data.creator_id, object_data.actor_id)},
{:ok, %Activity{} = activity, %Resource{} = resource} <-
ActivityPub.create(:resource, object_data, false),
{:ok, %Actor{type: :Group, id: group_id} = group} <-
ActivityPub.get_or_fetch_actor_by_url(group_url),
%Actor{type: :Group, id: group_id} = group <-
Actors.get_group_by_members_url(group_url),
announce_id <- "#{object_url}/announces/#{group_id}",
{:ok, _activity, _resource} <- ActivityPub.announce(group, object, announce_id) do
{:ok, activity, resource}
@@ -190,7 +273,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
:error
{:error, e} ->
Logger.error(inspect(e))
Logger.debug(inspect(e))
:error
end
end
@@ -261,23 +344,14 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(
%{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data
) do
with actor <- Utils.get_actor(data),
# TODO: Is the following line useful?
{:ok, %Actor{id: actor_id, suspended: false} = _actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor),
with actor_url <- Utils.get_actor(data),
{:ok, %Actor{id: actor_id, suspended: false} = actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url),
:ok <- Logger.debug("Fetching contained object"),
{:ok, object} <- fetch_obj_helper_as_activity_streams(object),
:ok <- Logger.debug("Handling contained object"),
create_data <- Utils.make_create_data(object),
:ok <- Logger.debug(inspect(object)),
{:ok, _activity, entity} <- handle_incoming(create_data),
:ok <- Logger.debug("Finished processing contained object"),
{:ok, activity} <- ActivityPub.create_activity(data, false),
{:ok, %Actor{id: object_owner_actor_id}} <-
ActivityPub.get_or_fetch_actor_by_url(object["actor"]),
{:ok, %Mobilizon.Share{} = _share} <-
Mobilizon.Share.create(object["id"], actor_id, object_owner_actor_id) do
{:ok, activity, entity}
{:ok, entity} <-
object |> Utils.get_url() |> fetch_object_optionnally_authenticated(actor),
:ok <- eventually_create_share(object, entity, actor_id) do
{:ok, nil, entity}
else
e ->
Logger.debug(inspect(e))
@@ -296,7 +370,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <-
object |> Converter.Actor.as_to_model_data(),
{:ok, %Activity{} = activity, %Actor{} = new_actor} <-
ActivityPub.update(:actor, old_actor, object_data, false) do
ActivityPub.update(old_actor, object_data, false) do
{:ok, activity, new_actor}
else
e ->
@@ -317,7 +391,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <- Converter.Event.as_to_model_data(object),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
{:ok, %Activity{} = activity, %Event{} = new_event} <-
ActivityPub.update(:event, old_event, object_data, false) do
ActivityPub.update(old_event, object_data, false) do
{:ok, activity, new_event}
else
_e ->
@@ -325,6 +399,42 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(
%{"type" => "Update", "object" => %{"type" => "Note"} = object, "actor" => _actor} =
update_data
) do
with actor <- Utils.get_actor(update_data),
{:ok, %Actor{url: actor_url, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(actor),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
object_data <- Converter.Comment.as_to_model_data(object),
{:ok, old_entity} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
object_data <- transform_object_data_for_discussion(object_data),
{:ok, %Activity{} = activity, new_entity} <-
ActivityPub.update(old_entity, object_data, false) do
{:ok, activity, new_entity}
else
_e ->
:error
end
end
def handle_incoming(%{
"type" => "Update",
"object" => %{"type" => "Tombstone"} = object,
"actor" => _actor
}) do
Logger.info("Handle incoming to update a tombstone")
with object_url <- Utils.get_url(object),
{:ok, entity} <- ActivityPub.fetch_object_from_url(object_url) do
ActivityPub.delete(entity, Relay.get_actor(), false)
else
{:ok, %Tombstone{} = tombstone} ->
{:ok, nil, tombstone}
end
end
def handle_incoming(
%{
"type" => "Undo",
@@ -367,21 +477,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
# TODO: We presently assume that any actor on the same origin domain as the object being
# deleted has the rights to delete that object. A better way to validate whether or not
# the object should be deleted is to refetch the object URI, which should return either
# an error or a tombstone. This would allow us to verify that a deletion actually took
# place.
# We assume everyone on the same instance as the object
# or who is member of a group has the right to delete the object
def handle_incoming(
%{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data
) do
with actor <- Utils.get_actor(data),
{:ok, %Actor{url: actor_url}} <- ActivityPub.get_or_fetch_actor_by_url(actor),
with actor_url <- Utils.get_actor(data),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
object_id <- Utils.get_url(object),
{:origin_check, true} <-
{:origin_check, Utils.origin_check_from_id?(actor_url, object_id)},
{:ok, object} <- ActivityPub.fetch_object_from_url(object_id),
{:ok, activity, object} <- ActivityPub.delete(object, false) do
{:origin_check, true} <-
{:origin_check,
Utils.origin_check_from_id?(actor_url, object_id) ||
Utils.activity_actor_is_group_member?(actor, object)},
{:ok, activity, object} <- ActivityPub.delete(object, actor, false) do
{:ok, activity, object}
else
{:origin_check, false} ->
@@ -449,6 +558,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
"target" => target
} = data
) do
Logger.info("Handle incoming to invite someone")
with {:ok, %Actor{} = actor} <-
data |> Utils.get_actor() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, object} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
@@ -485,7 +596,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# end
def handle_incoming(object) do
Logger.info("Handing something not supported")
Logger.info("Handing something with type #{object["type"]} not supported")
Logger.debug(inspect(object))
{:error, :not_supported}
end
@@ -657,6 +768,52 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
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()}
defp fetch_object_optionnally_authenticated(url, %Actor{type: :Group, id: group_id}) do
case Actors.get_single_group_member_actor(group_id) do
%Actor{} = actor ->
ActivityPub.fetch_object_from_url(url, on_behalf_of: actor, force: true)
_err ->
fetch_object_optionnally_authenticated(url, nil)
end
end
defp fetch_object_optionnally_authenticated(url, _),
do: ActivityPub.fetch_object_from_url(url, force: true)
defp eventually_create_share(object, entity, actor_id) do
with object_id <- object |> Utils.get_url(),
%Actor{id: object_owner_actor_id} <- Ownable.actor(entity) do
{:ok, %Mobilizon.Share{} = _share} =
Mobilizon.Share.create(object_id, actor_id, object_owner_actor_id)
end
:ok
end
@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_nil(object_data.discussion_id)
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
object_data
else
# Conversation
object_data
|> Map.put(:creator_id, object_data.actor_id)
|> Map.put(:actor_id, object_data.attributed_to_id)
end
end
defp get_follow(follow_object) do
with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object),
{:not_found, %Follower{} = follow} <-

View File

@@ -0,0 +1,74 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
@moduledoc false
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_actor(args),
{:ok, %Actor{} = actor} <- Actors.create_actor(args),
actor_as_data <- Convertible.model_to_as(actor),
audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
create_data <-
make_create_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, actor, create_data}
end
end
@impl Entity
@spec update(Actor.t(), map, map) :: {:ok, Actor.t(), Activity.t()} | any
def update(%Actor{} = old_actor, args, additional) do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor),
{:ok, true} <- Cachex.del(:activity_pub, "actor_#{new_actor.preferred_username}"),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_actor),
additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data}
end
end
@impl Entity
def delete(
%Actor{followers_url: followers_url, url: target_actor_url} = target_actor,
%Actor{url: actor_url} = actor,
local
) do
activity_data = %{
"type" => "Delete",
"actor" => actor_url,
"object" => Convertible.model_to_as(target_actor),
"id" => target_actor_url <> "/delete",
"to" => [followers_url, "https://www.w3.org/ns/activitystreams#Public"]
}
# We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(target_actor, reserve_username: local) do
{:ok, activity_data, actor, target_actor}
end
end
def actor(%Actor{} = actor), do: actor
def group_actor(%Actor{} = _actor), do: nil
defp prepare_args_for_actor(args) do
with preferred_username <-
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
summary <- args |> Map.get(:summary, "") |> String.trim(),
{summary, _mentions, _tags} <-
summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
%{args | preferred_username: preferred_username, summary: summary}
end
end
end

View File

@@ -0,0 +1,149 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
@moduledoc false
alias Mobilizon.{Actors, Discussions, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Share
alias Mobilizon.Tombstone
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{discussion_id: discussion_id} = comment} <-
Discussions.create_comment(args),
:ok <- maybe_publish_graphql_subscription(discussion_id),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data}
end
end
@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),
{: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),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_comment),
update_data <- make_update_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, new_comment, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Comment.t(), Actor.t(), boolean) :: {:ok, Comment.t()}
def delete(%Comment{url: url} = comment, %Actor{} = actor, _local) do
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(comment),
"id" => url <> "/delete",
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Discussions.delete_comment(comment),
# Preload to be sure
%Comment{} = comment <- Discussions.get_comment_with_preload(comment.id),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}) do
Share.delete_all_by_uri(comment.url)
{:ok, Map.merge(activity_data, audience), actor, comment}
end
end
def actor(%Comment{actor: %Actor{} = actor}), do: actor
def actor(%Comment{actor_id: actor_id}) when not is_nil(actor_id),
do: Actors.get_actor(actor_id)
def actor(_), do: nil
def group_actor(%Comment{attributed_to: %Actor{} = group}), do: group
def group_actor(%Comment{attributed_to_id: attributed_to_id}) when not is_nil(attributed_to_id),
do: Actors.get_actor(attributed_to_id)
def group_actor(_), do: nil
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Discussions.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{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),
args <-
Map.merge(args, %{
actor_id: Map.get(args, :actor_id),
text: text,
mentions: mentions,
tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
origin_comment_id:
if(is_nil(in_reply_to_comment),
do: nil,
else: Comment.get_thread_id(in_reply_to_comment)
)
}) do
args
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
{:ok, %Event{} = event} -> event
{:error, :event_not_found} -> nil
end
end
defp handle_event_for_comment(nil), do: nil
defp maybe_publish_graphql_subscription(nil), do: :ok
defp maybe_publish_graphql_subscription(discussion_id) do
with %Discussion{} = discussion <- Discussions.get_discussion(discussion_id) do
Absinthe.Subscription.publish(Endpoint, discussion,
discussion_comment_changed: discussion.slug
)
:ok
end
end
end

View File

@@ -0,0 +1,115 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Discussions do
@moduledoc false
alias Mobilizon.{Actors, Discussions}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.{Comments, Entity}
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Storage.Repo
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(%{discussion_id: discussion_id} = args, additional) when not is_nil(discussion_id) do
with %Discussion{} = discussion <- Discussions.get_discussion(discussion_id),
{:ok, %Discussion{last_comment_id: last_comment_id} = discussion} <-
Discussions.reply_to_discussion(discussion, args),
%Comment{} = last_comment <- Discussions.get_comment_with_preload(last_comment_id),
:ok <- maybe_publish_graphql_subscription(discussion),
comment_as_data <- Convertible.model_to_as(last_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(discussion),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, discussion, create_data}
end
end
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %Discussion{} = discussion} <-
Discussions.create_discussion(args),
discussion_as_data <- Convertible.model_to_as(discussion),
audience <-
Audience.calculate_to_and_cc_from_mentions(discussion),
create_data <-
make_create_data(discussion_as_data, Map.merge(audience, additional)) do
{:ok, discussion, create_data}
end
end
@impl Entity
@spec update(Discussion.t(), map(), map()) :: {:ok, Discussion.t(), Activity.t()} | any()
def update(%Discussion{} = old_discussion, args, additional) do
with {:ok, %Discussion{} = new_discussion} <-
Discussions.update_discussion(old_discussion, args),
{:ok, true} <- Cachex.del(:activity_pub, "discussion_#{new_discussion.slug}"),
discussion_as_data <- Convertible.model_to_as(new_discussion),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_discussion),
update_data <- make_update_data(discussion_as_data, Map.merge(audience, additional)) do
{:ok, new_discussion, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Discussion.t(), Actor.t(), boolean) :: {:ok, Discussion.t()}
def delete(%Discussion{actor: group, url: url} = discussion, %Actor{} = actor, _local) do
stream =
discussion.comments
|> Enum.map(
&Repo.preload(&1, [
:actor,
:attributed_to,
:in_reply_to_comment,
:mentions,
:origin_comment,
:discussion,
:tags,
:replies
])
)
|> Enum.map(&Map.put(&1, :event, nil))
|> Task.async_stream(fn comment -> Comments.delete(comment, actor, nil) end)
Stream.run(stream)
with {:ok, %Discussion{}} <- Discussions.delete_discussion(discussion) do
# This is just fake
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(discussion),
"id" => url <> "/delete",
"to" => [group.members_url]
}
{:ok, activity_data, actor, discussion}
end
end
def actor(%Discussion{creator_id: creator_id}), do: Actors.get_actor(creator_id)
def group_actor(%Discussion{actor_id: actor_id}), do: Actors.get_actor(actor_id)
@spec maybe_publish_graphql_subscription(Discussion.t()) :: :ok
defp maybe_publish_graphql_subscription(%Discussion{} = discussion) do
Absinthe.Subscription.publish(Endpoint, discussion,
discussion_comment_changed: discussion.slug
)
:ok
end
end

View File

@@ -0,0 +1,151 @@
alias Mobilizon.Federation.ActivityPub.Types.{
Actors,
Comments,
Discussions,
Entity,
Events,
Managable,
Ownable,
Posts,
Resources,
Todos,
TodoLists,
Tombstones
}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Tombstone
defmodule Mobilizon.Federation.ActivityPub.Types.Entity do
@moduledoc """
ActivityPub entity behaviour
"""
@type t :: %{id: String.t()}
@callback create(data :: any(), additionnal :: map()) ::
{:ok, t(), ActivityStream.t()}
@callback update(struct :: t(), attrs :: map(), additionnal :: map()) ::
{:ok, t(), ActivityStream.t()}
@callback delete(struct :: t(), Actor.t(), local :: boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), t()}
end
defprotocol Mobilizon.Federation.ActivityPub.Types.Managable do
@moduledoc """
ActivityPub entity Managable protocol.
"""
@spec update(Entity.t(), map(), map()) :: {:ok, Entity.t(), ActivityStream.t()}
@doc """
Updates a `Managable` entity with the appropriate attributes and returns the updated entity and an activitystream representation for it
"""
def update(entity, attrs, additionnal)
@spec delete(Entity.t(), Actor.t(), boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), Entity.t()}
@doc "Deletes an entity and returns the activitystream representation for it"
def delete(entity, actor, local)
end
defprotocol Mobilizon.Federation.ActivityPub.Types.Ownable do
@spec group_actor(Entity.t()) :: Actor.t() | nil
@doc "Returns an eventual group for the entity"
def group_actor(entity)
@spec actor(Entity.t()) :: Actor.t() | nil
@doc "Returns the actor for the entity"
def actor(entity)
end
defimpl Managable, for: Event do
defdelegate update(entity, attrs, additionnal), to: Events
defdelegate delete(entity, actor, local), to: Events
end
defimpl Ownable, for: Event do
defdelegate group_actor(entity), to: Events
defdelegate actor(entity), to: Events
end
defimpl Managable, for: Comment do
defdelegate update(entity, attrs, additionnal), to: Comments
defdelegate delete(entity, actor, local), to: Comments
end
defimpl Ownable, for: Comment do
defdelegate group_actor(entity), to: Comments
defdelegate actor(entity), to: Comments
end
defimpl Managable, for: Post do
defdelegate update(entity, attrs, additionnal), to: Posts
defdelegate delete(entity, actor, local), to: Posts
end
defimpl Ownable, for: Post do
defdelegate group_actor(entity), to: Posts
defdelegate actor(entity), to: Posts
end
defimpl Managable, for: Actor do
defdelegate update(entity, attrs, additionnal), to: Actors
defdelegate delete(entity, actor, local), to: Actors
end
defimpl Ownable, for: Actor do
defdelegate group_actor(entity), to: Actors
defdelegate actor(entity), to: Actors
end
defimpl Managable, for: TodoList do
defdelegate update(entity, attrs, additionnal), to: TodoLists
defdelegate delete(entity, actor, local), to: TodoLists
end
defimpl Ownable, for: TodoList do
defdelegate group_actor(entity), to: TodoLists
defdelegate actor(entity), to: TodoLists
end
defimpl Managable, for: Todo do
defdelegate update(entity, attrs, additionnal), to: Todos
defdelegate delete(entity, actor, local), to: Todos
end
defimpl Ownable, for: Todo do
defdelegate group_actor(entity), to: Todos
defdelegate actor(entity), to: Todos
end
defimpl Managable, for: Resource do
defdelegate update(entity, attrs, additionnal), to: Resources
defdelegate delete(entity, actor, local), to: Resources
end
defimpl Ownable, for: Resource do
defdelegate group_actor(entity), to: Resources
defdelegate actor(entity), to: Resources
end
defimpl Managable, for: Discussion do
defdelegate update(entity, attrs, additionnal), to: Discussions
defdelegate delete(entity, actor, local), to: Discussions
end
defimpl Ownable, for: Discussion do
defdelegate group_actor(entity), to: Discussions
defdelegate actor(entity), to: Discussions
end
defimpl Ownable, for: Tombstone do
defdelegate group_actor(entity), to: Tombstones
defdelegate actor(entity), to: Tombstones
end

View File

@@ -0,0 +1,203 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Events do
@moduledoc false
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events, as: EventsManager
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Share
alias Mobilizon.Tombstone
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = event} <- EventsManager.create_event(args),
event_as_data <- Convertible.model_to_as(event),
audience <-
Audience.calculate_to_and_cc_from_mentions(event),
create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data}
end
end
@impl Entity
@spec update(Event.t(), map(), map()) :: {:ok, Event.t(), Activity.t()} | any()
def update(%Event{} = old_event, args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = new_event} <- EventsManager.update_event(old_event, args),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{new_event.uuid}"),
event_as_data <- Convertible.model_to_as(new_event),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_event),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Event.t(), Actor.t(), boolean) :: {:ok, Event.t()}
def delete(%Event{url: url} = event, %Actor{} = actor, _local) do
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(event),
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"],
"id" => url <> "/delete"
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(event),
{:ok, %Event{} = event} <- EventsManager.delete_event(event),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{event.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}) do
Share.delete_all_by_uri(event.url)
{:ok, Map.merge(activity_data, audience), actor, event}
end
end
def actor(%Event{organizer_actor: %Actor{} = actor}), do: actor
def actor(%Event{organizer_actor_id: organizer_actor_id}),
do: Actors.get_actor(organizer_actor_id)
def actor(_), do: nil
def group_actor(%Event{attributed_to: %Actor{} = group}), do: group
def group_actor(%Event{attributed_to_id: attributed_to_id}) when not is_nil(attributed_to_id),
do: Actors.get_actor(attributed_to_id)
def group_actor(_), do: nil
def join(%Event{} = event, %Actor{} = actor, _local, additional) do
with {:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity, check_attendee_capacity(event)},
role <-
additional
|> Map.get(:metadata, %{})
|> Map.get(:role, Mobilizon.Events.get_default_participant_role(event)),
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: role,
event_id: event.id,
actor_id: actor.id,
url: Map.get(additional, :url),
metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}),
join_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant) do
approve_if_default_role_is_participant(
event,
Map.merge(join_data, audience),
participant,
role
)
else
{:maximum_attendee_capacity, err} ->
{:maximum_attendee_capacity, err}
end
end
defp check_attendee_capacity(%Event{options: options} = event) do
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0 do
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) < maximum_attendee_capacity
end
end
# Set the participant to approved if the default role for new participants is :participant
defp approve_if_default_role_is_participant(event, activity_data, participant, role) do
if event.local do
cond do
Mobilizon.Events.get_default_participant_role(event) === :participant &&
role == :participant ->
{:accept,
ActivityPub.accept(
:join,
participant,
true,
%{"actor" => event.organizer_actor.url}
)}
Mobilizon.Events.get_default_participant_role(event) === :not_approved &&
role == :not_approved ->
Scheduler.pending_participation_notification(event)
{:ok, activity_data, participant}
true ->
{:ok, activity_data, participant}
end
else
{:ok, activity_data, participant}
end
end
# Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do
# If title is not set: we are not updating it
args =
if Map.has_key?(args, :title) && !is_nil(args.title),
do: Map.update(args, :title, "", &String.trim/1),
else: args
# If we've been given a description (we might not get one if updating)
# sanitize it, HTML it, and extract tags & mentions from it
args =
if Map.has_key?(args, :description) && !is_nil(args.description) do
{description, mentions, tags} =
APIUtils.make_content_html(
String.trim(args.description),
Map.get(args, :tags, []),
"text/html"
)
mentions = ConverterUtils.fetch_mentions(Map.get(args, :mentions, []) ++ mentions)
Map.merge(args, %{
description: description,
mentions: mentions,
tags: tags
})
else
args
end
# Check that we can only allow anonymous participation if our instance allows it
{_, options} =
Map.get_and_update(
Map.get(args, :options, %{anonymous_participation: false}),
:anonymous_participation,
fn value ->
{value, value && Mobilizon.Config.anonymous_participation?()}
end
)
args = Map.put(args, :options, options)
Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1)
end
end

View File

@@ -0,0 +1,93 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Posts do
@moduledoc false
alias Mobilizon.{Actors, Posts}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Posts.Post
require Logger
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
@behaviour Entity
@impl Entity
def create(args, additional) do
with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1),
{:ok, %Post{attributed_to_id: group_id, author_id: creator_id} = post} <-
Posts.create_post(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
post_as_data <-
Convertible.model_to_as(%{post | attributed_to: group, author: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data = make_create_data(post_as_data, Map.merge(audience, additional))
{:ok, post, create_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def update(%Post{} = post, args, additional) do
with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1),
{:ok, %Post{attributed_to_id: group_id, author_id: creator_id} = post} <-
Posts.update_post(post, args),
{:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}"),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
post_as_data <-
Convertible.model_to_as(%{post | attributed_to: group, author: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
update_data = make_update_data(post_as_data, Map.merge(audience, additional))
{:ok, post, update_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def delete(
%Post{
url: url,
attributed_to: %Actor{url: group_url}
} = post,
%Actor{url: actor_url} = actor,
_local
) do
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(post),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _post} <- Posts.delete_post(post),
{:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}") do
{:ok, activity_data, actor, post}
end
end
def actor(%Post{author_id: author_id}),
do: Actors.get_actor(author_id)
def group_actor(%Post{attributed_to_id: attributed_to_id}),
do: Actors.get_actor(attributed_to_id)
end

View File

@@ -0,0 +1,43 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Reports do
@moduledoc false
alias Mobilizon.{Actors, Discussions, Reports}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Reports.Report
alias Mobilizon.Service.Formatter.HTML
require Logger
def flag(args, local \\ false, _additional \\ %{}) do
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
{:create_report, {:ok, %Report{} = report}} <-
{:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report),
cc <- if(local, do: [report.reported.url], else: []),
report_as_data <- Map.merge(report_as_data, %{"to" => [], "cc" => cc}) do
{report, report_as_data}
end
end
defp prepare_args_for_report(args) do
with {:reporter, %Actor{} = reporter_actor} <-
{:reporter, Actors.get_actor!(args.reporter_id)},
{:reported, %Actor{} = reported_actor} <-
{:reported, Actors.get_actor!(args.reported_id)},
content <- HTML.strip_tags(args.content),
event <- Discussions.get_comment(Map.get(args, :event_id)),
{:get_report_comments, comments} <-
{:get_report_comments,
Discussions.list_comments_by_actor_and_ids(
reported_actor.id,
Map.get(args, :comments_ids, [])
)} do
Map.merge(args, %{
reporter: reporter_actor,
reported: reported_actor,
content: content,
event: event,
comments: comments
})
end
end
end

View File

@@ -0,0 +1,157 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Resources do
@moduledoc false
alias Mobilizon.{Actors, Resources}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Resources.Resource
alias Mobilizon.Service.RichMedia.Parser
require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [make_create_data: 2, make_update_data: 2, make_add_data: 3, make_move_data: 4]
@behaviour Entity
@impl Entity
def create(%{type: type} = args, additional) do
args =
case type do
:folder ->
args
_ ->
case Parser.parse(Map.get(args, :resource_url)) do
{:ok, metadata} ->
Map.put(args, :metadata, metadata)
_ ->
args
end
end
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: parent_id} = resource} <-
Resources.create_resource(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group, creator: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data =
case parent_id do
nil ->
make_create_data(resource_as_data, Map.merge(audience, additional))
parent_id ->
# In case the resource has a parent we don't `Create` the resource but `Add` it to an existing resource
parent = Resources.get_resource(parent_id)
make_add_data(resource_as_data, parent, Map.merge(audience, additional))
end
{:ok, resource, create_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def update(%Resource{} = old_resource, %{parent_id: _parent_id} = args, additional) do
move(old_resource, args, additional)
end
# Simple rename
def update(%Resource{} = old_resource, %{title: title} = _args, additional) do
with {:ok, %Resource{actor_id: group_id, creator_id: creator_id} = resource} <-
Resources.update_resource(old_resource, %{title: title}),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
update_data <-
make_update_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, update_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
def move(
%Resource{parent_id: old_parent_id} = old_resource,
%{parent_id: _new_parent_id} = args,
additional
) do
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: new_parent_id} =
resource} <-
Resources.update_resource(old_resource, args),
old_parent <- Resources.get_resource(old_parent_id),
new_parent <- Resources.get_resource(new_parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
move_data <-
make_move_data(
resource_as_data,
old_parent,
new_parent,
Map.merge(audience, additional)
) do
{:ok, resource, move_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def delete(
%Resource{url: url, actor: %Actor{url: group_url, members_url: members_url}} = resource,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete Resource activity")
activity_data = %{
"actor" => actor_url,
"attributedTo" => [group_url],
"type" => "Delete",
"object" => Convertible.model_to_as(resource),
"id" => url <> "/delete",
"to" => [members_url]
}
with {:ok, _resource} <- Resources.delete_resource(resource),
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}") do
{:ok, activity_data, actor, resource}
end
end
def actor(%Resource{creator_id: creator_id}),
do: Actors.get_actor(creator_id)
def group_actor(%Resource{actor_id: actor_id}), do: Actors.get_actor(actor_id)
end

View File

@@ -0,0 +1,69 @@
defmodule Mobilizon.Federation.ActivityPub.Types.TodoLists do
@moduledoc false
alias Mobilizon.{Actors, Todos}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Todos.TodoList
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <- Todos.create_todo_list(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <- Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.members_url], "cc" => []},
create_data <-
make_create_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, create_data}
end
end
@impl Entity
@spec update(TodoList.t(), map, map) :: {:ok, TodoList.t(), Activity.t()} | any
def update(%TodoList{} = old_todo_list, args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <-
Todos.update_todo_list(old_todo_list, args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <-
Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.members_url], "cc" => []},
update_data <-
make_update_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, update_data}
end
end
@impl Entity
@spec delete(TodoList.t(), Actor.t(), boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), TodoList.t()}
def delete(
%TodoList{url: url, actor: %Actor{url: group_url}} = todo_list,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete TodoList activity")
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(url),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _todo_list} <- Todos.delete_todo_list(todo_list),
{:ok, true} <- Cachex.del(:activity_pub, "todo_list_#{todo_list.id}") do
{:ok, activity_data, actor, todo_list}
end
end
def actor(%TodoList{}), do: nil
def group_actor(%TodoList{actor_id: actor_id}), do: Actors.get_actor(actor_id)
end

View File

@@ -0,0 +1,80 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Todos do
@moduledoc false
alias Mobilizon.{Actors, Todos}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Todos.{Todo, TodoList}
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id, creator_id: creator_id} = todo} <-
Todos.create_todo(args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
%Actor{} = creator <- Actors.get_actor(creator_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo <- %{todo | todo_list: %{todo_list | actor: group}, creator: creator},
todo_as_data <-
Convertible.model_to_as(todo),
audience <- %{"to" => [group.members_url], "cc" => []},
create_data <-
make_create_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
end
end
@impl Entity
@spec update(Todo.t(), map, map) :: {:ok, Todo.t(), Activity.t()} | any
def update(%Todo{} = old_todo, args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id} = todo} <- Todos.update_todo(old_todo, args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.members_url], "cc" => []},
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, update_data}
end
end
@impl Entity
@spec delete(Todo.t(), Actor.t(), boolean()) :: {:ok, ActivityStream.t(), Actor.t(), Todo.t()}
def delete(
%Todo{url: url, creator: %Actor{url: group_url}} = todo,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete Todo activity")
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(url),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _todo} <- Todos.delete_todo(todo),
{:ok, true} <- Cachex.del(:activity_pub, "todo_#{todo.id}") do
{:ok, activity_data, actor, todo}
end
end
def actor(%Todo{creator_id: creator_id}), do: Actors.get_actor(creator_id)
def group_actor(%Todo{todo_list_id: todo_list_id}) do
case Todos.get_todo_list(todo_list_id) do
%TodoList{actor_id: group_id} ->
Actors.get_actor(group_id)
_ ->
nil
end
end
end

View File

@@ -0,0 +1,14 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Tombstones do
@moduledoc false
alias Mobilizon.{Actors, Tombstone}
alias Mobilizon.Actors.Actor
def actor(%Tombstone{actor: %Actor{id: actor_id}}), do: Actors.get_actor(actor_id)
def actor(%Tombstone{actor_id: actor_id}) when not is_nil(actor_id),
do: Actors.get_actor(actor_id)
def actor(_), do: nil
def group_actor(_), do: nil
end

View File

@@ -8,13 +8,16 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Various ActivityPub related utils.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.Converter
alias Mobilizon.Federation.HTTPSignatures
alias Mobilizon.Web.Endpoint
require Logger
@@ -114,6 +117,53 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def maybe_federate(_), do: :ok
@doc """
Applies to activities sent by group members from outside this instance to a group of this instance,
we then need to relay (`Announce`) the object to other members on other instances.
"""
def maybe_relay_if_group_activity(activity, attributed_to \\ nil)
def maybe_relay_if_group_activity(
%Activity{local: false, data: %{"object" => object}},
_attributed_to
)
when is_map(object) do
do_maybe_relay_if_group_activity(object, object["attributedTo"])
end
# When doing a delete the object is just an AP ID string, so we pass the attributed_to actor as well
def maybe_relay_if_group_activity(
%Activity{local: false, data: %{"object" => object}},
%Actor{url: attributed_to_url}
)
when is_binary(object) do
do_maybe_relay_if_group_activity(object, attributed_to_url)
end
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
id = "#{Endpoint.url()}/announces/#{Ecto.UUID.generate()}"
case Actors.get_local_group_by_url(attributed_to) do
%Actor{} = group ->
case ActivityPub.announce(group, object, id, true, false) do
{:ok, _activity, _object} ->
Logger.info("Forwarded activity to external members of the group")
:ok
_ ->
Logger.info("Failed to forward activity to external members of the group")
:error
end
_ ->
:ok
end
end
defp do_maybe_relay_if_group_activity(_, _), do: :ok
@spec remote_actors(list(String.t())) :: list(Actor.t())
def remote_actors(recipients) do
recipients
@@ -135,7 +185,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Adds an id and a published data if they aren't there,
also adds it to an included object
"""
def lazy_put_activity_defaults(map) do
def lazy_put_activity_defaults(%{"object" => _object} = map) do
if is_map(map["object"]) do
object = lazy_put_object_defaults(map["object"])
%{map | "object" => object}
@@ -147,7 +197,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Adds an id and published date if they aren't there.
"""
def lazy_put_object_defaults(map) do
def lazy_put_object_defaults(map) when is_map(map) do
Map.put_new_lazy(map, "published", &make_date/0)
end
@@ -175,25 +225,49 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Checks that an incoming AP object's actor matches the domain it came from.
Takes the actor or attributedTo attributes (considers only the first elem if they're an array)
"""
def origin_check?(id, %{"actor" => actor, "attributedTo" => _attributed_to} = params)
when not is_nil(actor) and actor != "" do
params = Map.delete(params, "attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"attributedTo" => actor} = params) do
params = params |> Map.put("actor", actor) |> Map.delete("attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
compare_uris?(actor_uri, id_uri)
def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) and is_list(actor) and length(actor) > 0 do
origin_check?(id, Map.put(params, "actor", hd(actor)))
end
def origin_check?(_id, %{"actor" => nil}), do: false
def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) do
actor = get_actor(params)
Logger.debug("Performing origin check on #{id} and #{actor} URIs")
compare_origins?(id, actor)
end
def origin_check?(_id, _data), do: false
def origin_check?(_id, %{"type" => type} = _params) when type in ["Actor", "Group"], do: true
def origin_check?(_id, %{"actor" => nil} = _args), do: false
def origin_check?(_id, _args), do: false
@spec compare_origins?(String.t(), String.t()) :: boolean()
def compare_origins?(url_1, url_2) when is_binary(url_1) and is_binary(url_2) do
uri_1 = URI.parse(url_1)
uri_2 = URI.parse(url_2)
compare_uris?(uri_1, uri_2)
end
defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host
@spec origin_check_from_id?(String.t(), String.t()) :: boolean()
def origin_check_from_id?(id, other_id) when is_binary(other_id) do
id_uri = URI.parse(id)
other_uri = URI.parse(other_id)
@@ -201,9 +275,20 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
compare_uris?(id_uri, other_uri)
end
@spec origin_check_from_id?(String.t(), map()) :: boolean()
def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id),
do: origin_check_from_id?(id, other_id)
def activity_actor_is_group_member?(%Actor{id: actor_id}, object) do
case Ownable.group_actor(object) do
%Actor{type: :Group, id: group_id} ->
Actors.is_member?(actor_id, group_id)
_ ->
false
end
end
@doc """
Save picture data from %Plug.Upload{} and return AS Link data.
"""
@@ -274,7 +359,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
activity_id,
public
)
when type in ["Note", "Event", "ResourceCollection", "Document"] do
when type in ["Note", "Event", "ResourceCollection", "Document", "Todo"] do
do_make_announce_data(
actor,
object_actor_url,
@@ -367,6 +452,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"type" => "Create",
"to" => object["to"],
"cc" => object["cc"],
"attributedTo" => object["attributedTo"] || object["actor"],
"actor" => object["actor"],
"object" => object,
"published" => make_date(),
@@ -494,7 +580,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Sign a request with the instance Relay actor.
"""
@spec sign_fetch_relay(List.t(), String.t(), String.t()) :: List.t()
@spec sign_fetch_relay(Enum.t(), String.t(), String.t()) :: Enum.t()
def sign_fetch_relay(headers, id, date) do
with %Actor{} = actor <- Relay.get_actor() do
sign_fetch(headers, actor, id, date)
@@ -504,7 +590,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Sign a request with an actor.
"""
@spec sign_fetch(List.t(), Actor.t(), String.t(), String.t()) :: List.t()
@spec sign_fetch(Enum.t(), Actor.t(), String.t(), String.t()) :: Enum.t()
def sign_fetch(headers, actor, id, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ make_signature(actor, id, date)
@@ -516,7 +602,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Add the Date header to the request if we sign object fetches
"""
@spec maybe_date_fetch(List.t(), String.t()) :: List.t()
@spec maybe_date_fetch(Enum.t(), String.t()) :: Enum.t()
def maybe_date_fetch(headers, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]
@@ -524,4 +610,15 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
headers
end
end
def check_for_actor_key_rotation(%Actor{} = actor) do
if Actors.should_rotate_actor_key(actor) do
Actors.schedule_key_rotation(
actor,
Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay]
)
end
:ok
end
end

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.Federation.ActivityPub.Visibility do
Utility functions related to content visibility
"""
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub.Activity

View File

@@ -0,0 +1,7 @@
defmodule Mobilizon.Federation.ActivityStream do
@moduledoc """
The ActivityStream Type
"""
@type t :: map()
end

View File

@@ -49,7 +49,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
banner: banner,
name: data["name"],
preferred_username: data["preferredUsername"],
summary: data["summary"],
summary: data["summary"] || "",
keys: data["publicKey"]["publicKeyPem"],
inbox_url: data["inbox"],
outbox_url: data["outbox"],
@@ -57,6 +57,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
followers_url: data["followers"],
members_url: data["members"],
resources_url: data["resources"],
todos_url: data["todos"],
events_url: data["events"],
posts_url: data["posts"],
discussions_url: data["discussions"],
shared_inbox_url: data["endpoints"]["sharedInbox"],
domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"],
@@ -77,12 +81,15 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"type" => actor.type,
"preferredUsername" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"summary" => actor.summary || "",
"following" => actor.following_url,
"followers" => actor.followers_url,
"members" => actor.members_url,
"resources" => actor.resources_url,
"todos" => actor.todos_url,
"posts" => actor.posts_url,
"events" => actor.events_url,
"discussions" => actor.discussions_url,
"inbox" => actor.inbox_url,
"outbox" => actor.outbox_url,
"url" => actor.url,

View File

@@ -7,22 +7,30 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment, as: CommentModel
alias Mobilizon.Discussions
alias Mobilizon.Discussions.Comment, as: CommentModel
alias Mobilizon.Discussions.Discussion
alias Mobilizon.Events.Event
alias Mobilizon.Tombstone, as: TombstoneModel
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Visibility
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Converter.Comment, as: CommentConverter
alias Mobilizon.Tombstone, as: TombstoneModel
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
fetch_tags: 1,
fetch_mentions: 1,
build_tags: 1,
build_mentions: 1,
maybe_fetch_actor_and_attributed_to_id: 1
]
require Logger
@behaviour Converter
defimpl Convertible, for: CommentModel do
alias Mobilizon.Federation.ActivityStream.Converter.Comment, as: CommentConverter
defdelegate model_to_as(comment), to: CommentConverter
end
@@ -35,61 +43,35 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
Logger.debug("We're converting raw ActivityStream data to a comment entity")
Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id, domain: domain, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(author_url),
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))},
with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
maybe_fetch_actor_and_attributed_to_id(object),
{:tags, tags} <- {:tags, fetch_tags(Map.get(object, "tag", []))},
{:mentions, mentions} <-
{:mentions, ConverterUtils.fetch_mentions(Map.get(object, "tag", []))} do
{:mentions, fetch_mentions(Map.get(object, "tag", []))},
discussion <-
Discussions.get_discussion_by_url(Map.get(object, "context")) do
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
data = %{
text: object["content"],
url: object["id"],
# Will be used in conversations, ignored in basic comments
title: object["name"],
context: object["context"],
actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
in_reply_to_comment_id: nil,
event_id: nil,
uuid: object["uuid"],
discussion_id: if(is_nil(discussion), do: nil, else: discussion.id),
tags: tags,
mentions: mentions,
local: is_nil(domain),
local: is_nil(actor_domain),
visibility: if(Visibility.is_public?(object), do: :public, else: :private)
}
# We fetch the parent object
Logger.debug("We're fetching the parent object")
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
end
maybe_fetch_parent_object(object, data)
else
{:ok, %Actor{suspended: true}} ->
:error
@@ -102,10 +84,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{deleted_at: nil} = comment) do
to =
if comment.visibility == :public,
do: ["https://www.w3.org/ns/activitystreams#Public"],
else: [comment.actor.followers_url]
to = determine_to(comment)
object = %{
"type" => "Note",
@@ -114,13 +93,19 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
"content" => comment.text,
"mediaType" => "text/html",
"actor" => comment.actor.url,
"attributedTo" => comment.actor.url,
"attributedTo" =>
if(is_nil(comment.attributed_to), do: nil, else: comment.attributed_to.url) ||
comment.actor.url,
"uuid" => comment.uuid,
"id" => comment.url,
"tag" =>
ConverterUtils.build_mentions(comment.mentions) ++ ConverterUtils.build_tags(comment.tags)
"tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags)
}
object =
if comment.discussion_id,
do: Map.put(object, "context", comment.discussion.url),
else: object
cond do
comment.in_reply_to_comment ->
Map.put(object, "inReplyTo", comment.in_reply_to_comment.url)
@@ -133,15 +118,78 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
end
end
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
@doc """
A "soft-deleted" comment is a tombstone
"""
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{} = comment) do
Convertible.model_to_as(%TombstoneModel{
uri: comment.url,
inserted_at: comment.deleted_at
})
end
@spec determine_to(CommentModel.t()) :: [String.t()]
defp determine_to(%CommentModel{} = comment) do
cond do
not is_nil(comment.attributed_to) ->
[comment.attributed_to.url]
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
end
end
defp maybe_fetch_parent_object(object, data) do
# We fetch the parent object
Logger.debug("We're fetching the parent object")
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Reply to a discucssion (Discussion)
{:ok,
%Discussion{
id: discussion_id,
last_comment: %CommentModel{id: last_comment_id, origin_comment_id: origin_comment_id}
} = _discussion} ->
Logger.debug("Parent object is a discussion")
data
|> Map.put(:in_reply_to_comment_id, last_comment_id)
|> Map.put(:origin_comment_id, origin_comment_id)
|> Map.put(:discussion_id, discussion_id)
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
end
end
end

View File

@@ -6,6 +6,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter do
one, and back.
"""
@callback as_to_model_data(map) :: map
@callback model_to_as(struct) :: map
@type model_data :: map()
@callback as_to_model_data(as_data :: ActivityStream.t()) :: model_data()
@callback model_to_as(model :: struct()) :: ActivityStream.t()
end

View File

@@ -0,0 +1,63 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Discussion do
@moduledoc """
Comment converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Discussion
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Discussion, as: DiscussionConverter
alias Mobilizon.Storage.Repo
require Logger
@behaviour Converter
defimpl Convertible, for: Discussion do
defdelegate model_to_as(comment), to: DiscussionConverter
end
@doc """
Make an AS comment object from an existing `discussion` structure.
"""
@impl Converter
@spec model_to_as(Discussion.t()) :: map
def model_to_as(%Discussion{} = discussion) do
discussion = Repo.preload(discussion, [:last_comment, :actor, :creator])
%{
"type" => "Note",
"to" => [discussion.actor.followers_url],
"cc" => [],
"name" => discussion.title,
"content" => discussion.last_comment.text,
"mediaType" => "text/html",
"actor" => discussion.creator.url,
"attributedTo" => discussion.actor.url,
"id" => discussion.url,
"context" => discussion.url
}
end
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(%{"type" => "Note", "name" => name} = object) when not is_nil(name) do
with creator_url <- Map.get(object, "actor"),
{:ok, %Actor{id: creator_id, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(creator_url),
actor_url <- Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url) do
%{
title: name,
actor_id: actor_id,
creator_id: creator_id,
url: object["id"]
}
end
end
end

View File

@@ -12,11 +12,17 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Media.Picture
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
alias Mobilizon.Federation.ActivityStream.Converter.Picture, as: PictureConverter
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
fetch_tags: 1,
fetch_mentions: 1,
build_tags: 1,
maybe_fetch_actor_and_attributed_to_id: 1
]
require Logger
@@ -34,16 +40,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
@impl Converter
@spec as_to_model_data(map) :: {:ok, map()} | {:error, any()}
def as_to_model_data(object) do
Logger.debug("event as_to_model_data")
Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:actor, {:ok, %Actor{id: actor_id, domain: actor_domain, suspended: false}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)},
with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
maybe_fetch_actor_and_attributed_to_id(object),
{:address, address_id} <-
{:address, get_address(object["location"])},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])},
{:tags, tags} <- {:tags, fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, fetch_mentions(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do
attachments =
@@ -67,6 +69,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
title: object["name"],
description: object["content"],
organizer_actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
picture_id: picture_id,
begins_on: object["startTime"],
ends_on: object["endTime"],
@@ -108,7 +111,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"type" => "Event",
"to" => to,
"cc" => [],
"attributedTo" => event.organizer_actor.url,
"attributedTo" =>
if(is_nil(event.attributed_to), do: nil, else: event.attributed_to.url) ||
event.organizer_actor.url,
"name" => event.title,
"actor" => event.organizer_actor.url,
"uuid" => event.uuid,
@@ -120,7 +125,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"startTime" => event.begins_on |> date_to_string(),
"joinMode" => to_string(event.join_options),
"endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> ConverterUtils.build_tags(),
"tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"repliesModerationOption" => event.options.comment_moderation,
"commentsEnabled" => event.options.comment_moderation == :allow_all,

View File

@@ -9,7 +9,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations
alias Mobilizon.Discussions
alias Mobilizon.Events
alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report
@@ -92,7 +92,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == url))
end),
comments <- Enum.map(comments, &Conversations.get_comment_from_url/1) do
comments <- Enum.map(comments, &Discussions.get_comment_from_url/1) do
%{
"reporter" => reporter,
"uri" => object["id"],

View File

@@ -39,7 +39,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
actor_id
)
when is_bitstring(picture_url) do
with {:ok, %HTTPoison.Response{body: body}} <- HTTPoison.get(picture_url, [], @http_options),
with {:ok, %{body: body}} <- Tesla.get(picture_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do

View File

@@ -0,0 +1,70 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Post do
@moduledoc """
Post converter.
This module allows to convert posts from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Posts.Post
require Logger
@behaviour Converter
defimpl Convertible, for: Post do
alias Mobilizon.Federation.ActivityStream.Converter.Post, as: PostConverter
defdelegate model_to_as(post), to: PostConverter
end
@doc """
Convert an post struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(Post.t()) :: map
def model_to_as(
%Post{author: %Actor{url: actor_url}, attributed_to: %Actor{url: creator_url}} = post
) do
%{
"type" => "Article",
"actor" => actor_url,
"id" => post.url,
"name" => post.title,
"content" => post.body,
"attributedTo" => creator_url,
"published" => post.publish_at || post.inserted_at
}
end
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(
%{"type" => "Article", "actor" => creator, "attributedTo" => group} = object
) do
with {:ok, %Actor{id: attributed_to_id}} <- get_actor(group),
{:ok, %Actor{id: author_id}} <- get_actor(creator) do
%{
title: object["name"],
body: object["content"],
url: object["id"],
attributed_to_id: attributed_to_id,
author_id: author_id,
local: false,
publish_at: object["published"]
}
else
{:error, err} -> {:error, err}
err -> {:error, err}
end
end
@spec get_actor(String.t() | map() | nil) :: {:ok, Actor.t()} | {:error, String.t()}
defp get_actor(nil), do: {:error, "nil property found for actor data"}
defp get_actor(actor), do: actor |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url()
end

View File

@@ -28,7 +28,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.TodoList do
"type" => "TodoList",
"actor" => group_url,
"id" => todo_list.url,
"title" => todo_list.title
"name" => todo_list.title
}
end

View File

@@ -28,6 +28,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Tombstone do
%{
"type" => "Tombstone",
"id" => tombstone.uri,
"actor" => tombstone.actor.url,
"deleted" => tombstone.inserted_at
}
end

View File

@@ -23,6 +23,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Utils do
tags |> Enum.flat_map(&fetch_tag/1) |> Enum.uniq() |> Enum.map(&existing_tag_or_data/1)
end
def fetch_tags(_), do: []
@spec fetch_mentions([map()]) :: [map()]
def fetch_mentions(mentions) when is_list(mentions) do
Logger.debug("fetching mentions")
@@ -30,6 +32,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Utils do
Enum.reduce(mentions, [], fn mention, acc -> create_mention(mention, acc) end)
end
def fetch_mentions(_), do: []
def fetch_address(%{id: id}) do
with {id, ""} <- Integer.parse(id), do: %{id: id}
end
@@ -38,7 +42,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Utils do
address
end
@spec build_tags([Tag.t()]) :: [Map.t()]
@spec build_tags([Tag.t()]) :: [map()]
def build_tags(tags) do
Enum.map(tags, fn %Tag{} = tag ->
%{
@@ -111,4 +115,51 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Utils do
defp create_mention({_, mention}, acc) when is_map(mention) do
create_mention(mention, acc)
end
@spec maybe_fetch_actor_and_attributed_to_id(map()) :: {Actor.t() | nil, Actor.t() | nil}
def maybe_fetch_actor_and_attributed_to_id(%{
"actor" => actor_url,
"attributedTo" => attributed_to_url
})
when is_nil(attributed_to_url) do
{fetch_actor(actor_url), nil}
end
@spec maybe_fetch_actor_and_attributed_to_id(map()) :: {Actor.t() | nil, Actor.t() | nil}
def maybe_fetch_actor_and_attributed_to_id(%{
"actor" => actor_url,
"attributedTo" => attributed_to_url
})
when is_nil(actor_url) do
{fetch_actor(attributed_to_url), nil}
end
# Only when both actor and attributedTo fields are both filled is when we can return both
def maybe_fetch_actor_and_attributed_to_id(%{
"actor" => actor_url,
"attributedTo" => attributed_to_url
})
when actor_url != attributed_to_url do
with actor <- fetch_actor(actor_url),
attributed_to <- fetch_actor(attributed_to_url) do
{actor, attributed_to}
end
end
# If we only have attributedTo and no actor, take attributedTo as the actor
def maybe_fetch_actor_and_attributed_to_id(%{
"attributedTo" => attributed_to_url
}) do
{fetch_actor(attributed_to_url), nil}
end
def maybe_fetch_actor_and_attributed_to_id(_), do: {nil, nil}
@spec fetch_actor(String.t()) :: Actor.t()
defp fetch_actor(actor_url) do
with {:ok, %Actor{suspended: false} = actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url) do
actor
end
end
end

View File

@@ -3,8 +3,9 @@ defprotocol Mobilizon.Federation.ActivityStream.Convertible do
Convertible protocol.
"""
@type activity_streams :: map
@type t :: struct()
@type activity_streams :: map()
@spec model_to_as(t) :: activity_streams
@spec model_to_as(t()) :: activity_streams()
def model_to_as(convertible)
end

View File

@@ -118,13 +118,15 @@ defmodule Mobilizon.Federation.WebFinger do
Logger.debug(inspect(address))
with false <- is_nil(domain),
{:ok, %HTTPoison.Response{} = response} <-
HTTPoison.get(
{:ok, %{} = response} <-
Tesla.get(
address,
[Accept: "application/json, application/activity+json, application/jrd+json"],
@http_options
headers: [
{"accept", "application/json, application/activity+json, application/jrd+json"}
],
opts: @http_options
),
%{status_code: status_code, body: body} when status_code in 200..299 <- response,
%{status: status, body: body} when status in 200..299 <- response,
{:ok, doc} <- Jason.decode(body) do
webfinger_from_json(doc)
else

View File

@@ -3,8 +3,8 @@ defmodule Mobilizon.GraphQL.API.Comments do
API for Comments.
"""
alias Mobilizon.Conversations.Comment
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity
@@ -19,7 +19,7 @@ defmodule Mobilizon.GraphQL.API.Comments do
end
def update_comment(%Comment{} = comment, args) do
ActivityPub.update(:comment, comment, args, true)
ActivityPub.update(comment, args, true)
end
@doc """
@@ -27,8 +27,8 @@ defmodule Mobilizon.GraphQL.API.Comments do
Deletes a comment from an actor
"""
@spec delete_comment(Comment.t()) :: {:ok, Activity.t(), Comment.t()} | any
def delete_comment(%Comment{} = comment) do
ActivityPub.delete(comment, true)
@spec delete_comment(Comment.t(), Actor.t()) :: {:ok, Activity.t(), Comment.t()} | any
def delete_comment(%Comment{} = comment, %Actor{} = actor) do
ActivityPub.delete(comment, actor, true)
end
end

View File

@@ -34,7 +34,7 @@ defmodule Mobilizon.GraphQL.API.Events do
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor)
end) do
ActivityPub.update(:event, event, args, Map.get(args, :draft, false) == false)
ActivityPub.update(event, args, Map.get(args, :draft, false) == false)
end
end
@@ -43,8 +43,8 @@ defmodule Mobilizon.GraphQL.API.Events do
If the event is deleted by
"""
def delete_event(%Event{} = event, federate \\ true) do
ActivityPub.delete(event, federate)
def delete_event(%Event{} = event, %Actor{} = actor, federate \\ true) do
ActivityPub.delete(event, actor, federate)
end
defp process_picture(nil, _), do: nil

View File

@@ -19,8 +19,25 @@ defmodule Mobilizon.GraphQL.API.Groups do
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
{:existing_group, nil} <-
{:existing_group, Actors.get_local_group_by_title(preferred_username)},
args <- args |> Map.put(:type, :Group),
{:ok, %Activity{} = activity, %Actor{} = group} <-
ActivityPub.create(:group, args, true, %{"actor" => args.creator_actor.url}) do
ActivityPub.create(:actor, args, true, %{"actor" => args.creator_actor.url}) do
{:ok, activity, group}
else
{:existing_group, _} ->
{:error, "A group with this name already exists"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
end
end
@spec create_group(map) :: {:ok, Activity.t(), Actor.t()} | any
def update_group(%{id: id} = args) do
with {:existing_group, {:ok, %Actor{type: :Group} = group}} <-
{:existing_group, Actors.get_group_by_actor_id(id)},
{:ok, %Activity{} = activity, %Actor{} = group} <-
ActivityPub.update(group, args, true, %{"actor" => args.updater_actor.url}) do
{:ok, activity, group}
else
{:existing_group, _} ->

View File

@@ -9,7 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Actors.Actor
alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Config
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Relay
@@ -297,7 +297,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
with {:changes, true} <- {:changes, args != %{}},
%Actor{} = instance_actor <- Relay.get_actor(),
{:ok, _activity, _actor} <- ActivityPub.update(:actor, instance_actor, args, true) do
{:ok, _activity, _actor} <- ActivityPub.update(instance_actor, args, true) do
:ok
else
{:changes, false} ->

View File

@@ -3,9 +3,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
Handles the comment-related GraphQL calls.
"""
alias Mobilizon.{Actors, Admin, Conversations}
alias Mobilizon.{Actors, Admin, Discussions}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment, as: CommentModel
alias Mobilizon.Discussions.Comment, as: CommentModel
alias Mobilizon.Users
alias Mobilizon.Users.User
@@ -14,7 +14,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
require Logger
def get_thread(_parent, %{id: thread_id}, _context) do
{:ok, Conversations.get_thread_replies(thread_id)}
{:ok, Discussions.get_thread_replies(thread_id)}
end
def create_comment(
@@ -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.Conversations.get_comment(comment_id),
Mobilizon.Discussions.get_comment(comment_id),
true <- actor_id === comment_actor_id,
{:ok, _, %CommentModel{} = comment} <- Comments.update_comment(comment, %{text: text}) do
{:ok, comment}
@@ -72,15 +72,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
%CommentModel{deleted_at: nil} = comment <-
Conversations.get_comment_with_preload(comment_id) do
Discussions.get_comment_with_preload(comment_id) do
cond do
{:comment_can_be_managed, true} == CommentModel.can_be_managed_by(comment, actor_id) ->
do_delete_comment(comment)
do_delete_comment(comment, actor)
role in [:moderator, :administrator] ->
with {:ok, res} <- do_delete_comment(comment),
with {:ok, res} <- do_delete_comment(comment, actor),
%Actor{} = actor <- Actors.get_actor(actor_id) do
Admin.log_action(actor, "delete", comment)
@@ -103,9 +103,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
{:error, "You are not allowed to delete a comment if not connected"}
end
defp do_delete_comment(%CommentModel{} = comment) do
defp do_delete_comment(%CommentModel{} = comment, %Actor{} = actor) do
with {:ok, _, %CommentModel{} = comment} <-
Comments.delete_comment(comment) do
Comments.delete_comment(comment, actor) do
{:ok, comment}
end
end

View File

@@ -1,110 +0,0 @@
defmodule Mobilizon.GraphQL.Resolvers.Conversation do
@moduledoc """
Handles the group-related GraphQL calls.
"""
alias Mobilizon.{Actors, Conversations, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation, as: ConversationModel
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
def find_conversations_for_actor(
%Actor{id: group_id},
_args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, Conversations.find_conversations_for_actor(group_id)}
else
{:member, false} ->
{:ok, %Page{total: 0, elements: []}}
end
end
def find_conversations_for_actor(%Actor{}, _args, _resolution) do
{:ok, %Page{total: 0, elements: []}}
end
def get_conversation(_parent, %{id: id}, _resolution) do
{:ok, Conversations.get_conversation(id)}
end
def get_comments_for_conversation(
%ConversationModel{id: conversation_id},
%{page: page, limit: limit},
_resolution
) do
{:ok, Conversations.get_comments_for_conversation(conversation_id, page, limit)}
end
def create_conversation(
_parent,
%{title: title, text: text, actor_id: actor_id, creator_id: creator_id},
_resolution
) do
with {:ok, %ConversationModel{} = conversation} <-
Conversations.create_conversation(%{
title: title,
text: text,
actor_id: actor_id,
creator_id: creator_id
}) do
{:ok, conversation}
end
end
def reply_to_conversation(
_parent,
%{text: text, conversation_id: conversation_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_conversation, %ConversationModel{} = conversation} <-
{:no_conversation, Conversations.get_conversation(conversation_id)},
{:ok, %ConversationModel{} = conversation} <-
Conversations.reply_to_conversation(
conversation,
%{
text: text,
actor_id: actor_id
}
) do
{:ok, conversation}
end
end
@spec update_conversation(map(), map(), map()) :: {:ok, ConversationModel.t()}
def update_conversation(
_parent,
%{title: title, conversation_id: conversation_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_conversation, %ConversationModel{creator_id: creator_id} = conversation} <-
{:no_conversation, Conversations.get_conversation(conversation_id)},
{:check_access, true} <- {:check_access, actor_id == creator_id},
{:ok, %ConversationModel{} = conversation} <-
Conversations.update_conversation(
conversation,
%{
title: title
}
) do
{:ok, conversation}
end
end
end

View File

@@ -0,0 +1,179 @@
defmodule Mobilizon.GraphQL.Resolvers.Discussion do
@moduledoc """
Handles the group-related GraphQL calls.
"""
alias Mobilizon.{Actors, Discussions, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
def find_discussions_for_actor(
%Actor{id: group_id},
_args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, Discussions.find_discussions_for_actor(group_id)}
else
{:member, false} ->
{:ok, %Page{total: 0, elements: []}}
end
end
def find_discussions_for_actor(%Actor{}, _args, _resolution) do
{:ok, %Page{total: 0, elements: []}}
end
def get_discussion(_parent, %{id: id}, %{
context: %{
current_user: %User{} = user
}
}) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
%Discussion{actor_id: actor_id} = discussion <-
Discussions.get_discussion(id),
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)} do
{:ok, discussion}
end
end
def get_discussion(_parent, %{slug: slug}, %{
context: %{
current_user: %User{} = user
}
}) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
%Discussion{actor_id: actor_id} = discussion <-
Discussions.get_discussion_by_slug(slug),
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)} do
{:ok, discussion}
else
nil -> {:error, "No such discussion"}
end
end
def get_discussion(_parent, _args, _resolution),
do: {:error, "You need to be logged-in to access discussions"}
def get_comments_for_discussion(
%Discussion{id: discussion_id},
%{page: page, limit: limit},
_resolution
) do
{:ok, Discussions.get_comments_for_discussion(discussion_id, page, limit)}
end
def create_discussion(
_parent,
%{title: title, text: text, actor_id: actor_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create(
:discussion,
%{
title: title,
text: text,
actor_id: actor_id,
creator_id: creator_id,
attributed_to_id: actor_id
},
true
) do
{:ok, discussion}
end
end
def reply_to_discussion(
_parent,
%{text: text, discussion_id: discussion_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_discussion,
%Discussion{
actor_id: actor_id,
last_comment: %Comment{
id: last_comment_id,
origin_comment_id: origin_comment_id,
in_reply_to_comment_id: previous_in_reply_to_comment_id
}
} = _discussion} <-
{:no_discussion, Discussions.get_discussion(discussion_id)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create(
:discussion,
%{
text: text,
discussion_id: discussion_id,
actor_id: creator_id,
attributed_to_id: actor_id,
in_reply_to_comment_id: last_comment_id,
origin_comment_id:
origin_comment_id || previous_in_reply_to_comment_id || last_comment_id
},
true
) do
{:ok, discussion}
end
end
@spec update_discussion(map(), map(), map()) :: {:ok, Discussion.t()}
def update_discussion(
_parent,
%{title: title, discussion_id: discussion_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_discussion, %Discussion{actor_id: actor_id} = discussion} <-
{:no_discussion, Discussions.get_discussion(discussion_id)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.update(
discussion,
%{
title: title
}
) do
{:ok, discussion}
end
end
def delete_discussion(_parent, %{discussion_id: discussion_id}, %{
context: %{
current_user: %User{} = user
}
}) do
with {:actor, %Actor{id: creator_id} = actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_discussion, %Discussion{actor_id: actor_id} = discussion} <-
{:no_discussion, Discussions.get_discussion(discussion_id)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.delete(discussion, actor) do
{:ok, discussion}
end
end
end

View File

@@ -255,13 +255,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
) do
with {:ok, %Event{local: is_local} = event} <- Events.get_event_with_preload(event_id),
{actor_id, ""} <- Integer.parse(actor_id),
{:is_owned, %Actor{}} <- User.owns_actor(user, actor_id) do
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id) do
cond do
{:event_can_be_managed, true} == Event.can_be_managed_by(event, actor_id) ->
do_delete_event(event)
do_delete_event(event, actor)
role in [:moderator, :administrator] ->
with {:ok, res} <- do_delete_event(event, !is_local),
with {:ok, res} <- do_delete_event(event, actor, !is_local),
%Actor{} = actor <- Actors.get_actor(actor_id) do
Admin.log_action(actor, "delete", event)
@@ -284,8 +284,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
{:error, "You need to be logged-in to delete an event"}
end
defp do_delete_event(event, federate \\ true) when is_boolean(federate) do
with {:ok, _activity, event} <- API.Events.delete_event(event) do
defp do_delete_event(%Event{} = event, %Actor{} = actor, federate \\ true)
when is_boolean(federate) do
with {:ok, _activity, event} <- API.Events.delete_event(event, actor) do
{:ok, %{id: event.id}}
end
end

View File

@@ -80,7 +80,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
API.Groups.create_group(args) do
{:ok, group}
else
{:error, err} when is_bitstring(err) ->
{:error, err} when is_binary(err) ->
{:error, err}
{:is_owned, nil} ->
@@ -92,6 +92,36 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:error, "You need to be logged-in to create a group"}
end
@doc """
Create a new group. The creator is automatically added as admin
"""
def update_group(
_parent,
args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with %Actor{} = updater_actor <- Users.get_actor_for_user(user),
args <- Map.put(args, :updater_actor, updater_actor),
{:ok, _activity, %Actor{type: :Group} = group} <-
API.Groups.update_group(args) do
{:ok, group}
else
{:error, err} when is_binary(err) ->
{:error, err}
{:is_owned, nil} ->
{:error, "Creator actor id is not owned by the current user"}
end
end
def update_group(_parent, _args, _resolution) do
{:error, "You need to be logged-in to update a group"}
end
@doc """
Delete an existing group
"""

View File

@@ -16,14 +16,26 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
"""
def find_members_for_group(
%Actor{id: group_id} = group,
_args,
%{page: page, limit: limit, roles: roles},
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Actors.list_members_for_group(group) do
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
roles =
case roles do
"" ->
[]
roles ->
roles
|> String.split(",")
|> Enum.map(&String.downcase/1)
|> Enum.map(&String.to_existing_atom/1)
end
%Page{} = page = Actors.list_members_for_group(group, roles, page, limit)
{:ok, page}
else
{:member, false} ->

View File

@@ -129,7 +129,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
{:find_actor, Actors.get_actor(id)},
{:is_owned, %Actor{}} <- User.owns_actor(user, actor.id),
args <- save_attached_pictures(args),
{:ok, _activity, %Actor{} = actor} <- ActivityPub.update(:actor, actor, args, true) do
{:ok, _activity, %Actor{} = actor} <- ActivityPub.update(actor, args, true) do
{:ok, actor}
else
{:find_actor, nil} ->

View File

@@ -0,0 +1,198 @@
defmodule Mobilizon.GraphQL.Resolvers.Post do
@moduledoc """
Handles the posts-related GraphQL calls
"""
alias Mobilizon.{Actors, Posts, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Posts.Post
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
require Logger
@public_accessible_visibilities [:public, :unlisted]
@doc """
Find posts for group.
Returns only if actor requesting is a member of the group
"""
def find_posts_for_group(
%Actor{id: group_id} = group,
%{page: page, limit: limit} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Posts.get_posts_for_group(group, page, limit) do
{:ok, page}
else
{:member, _} ->
find_posts_for_group(group, args, nil)
end
end
def find_posts_for_group(
%Actor{} = group,
%{page: page, limit: limit},
_resolution
) do
with %Page{} = page <- Posts.get_public_posts_for_group(group, page, limit) do
{:ok, page}
end
end
def find_posts_for_group(
_group,
_args,
_resolution
) do
{:ok, %Page{total: 0, elements: []}}
end
def get_post(
parent,
%{slug: slug},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:current_actor, %Actor{id: actor_id}} <-
{:current_actor, Users.get_actor_for_user(user)},
{:post, %Post{attributed_to: %Actor{id: group_id}} = post} <-
{:post, Posts.get_post_by_slug_with_preloads(slug)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, post}
else
{:member, false} -> get_post(parent, %{slug: slug}, nil)
{:post, _} -> {:error, "No such post"}
end
end
def get_post(
_parent,
%{slug: slug},
_resolution
) do
case {:post, Posts.get_post_by_slug_with_preloads(slug)} do
{:post, %Post{visibility: visibility, draft: false} = post}
when visibility in @public_accessible_visibilities ->
{:ok, post}
{:post, _} ->
{:error, "No such post"}
end
end
def get_post(_parent, _args, _resolution) do
{:error, "No such post"}
end
def create_post(
_parent,
%{attributed_to_id: group_id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <-
ActivityPub.create(
:post,
args
|> Map.put(:author_id, actor_id)
|> Map.put(:attributed_to_id, group_id),
true,
%{}
) do
{:ok, post}
else
{:own_check, _} ->
{:error, "Parent post doesn't match this group"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_post(_parent, _args, _resolution) do
{:error, "You need to be logged-in to create posts"}
end
def update_post(
_parent,
%{id: id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:uuid, {:ok, _uuid}} <- {:uuid, Ecto.UUID.cast(id)},
%Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:post, %Post{attributed_to: %Actor{id: group_id}} = post} <-
{:post, Posts.get_post_with_preloads(id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <-
ActivityPub.update(post, args, true, %{}) do
{:ok, post}
else
{:uuid, :error} ->
{:error, "Post ID is not a valid ID"}
{:post, _} ->
{:error, "Post doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_post(_parent, _args, _resolution) do
{:error, "You need to be logged-in to update posts"}
end
def delete_post(
_parent,
%{id: post_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:uuid, {:ok, _uuid}} <- {:uuid, Ecto.UUID.cast(post_id)},
%Actor{id: actor_id} = actor <- Users.get_actor_for_user(user),
{:post, %Post{attributed_to: %Actor{id: group_id}} = post} <-
{:post, Posts.get_post_with_preloads(post_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <-
ActivityPub.delete(post, actor) do
{:ok, post}
else
{:uuid, :error} ->
{:error, "Post ID is not a valid ID"}
{:post, _} ->
{:error, "Post doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def delete_post(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete posts"}
end
end

View File

@@ -141,7 +141,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.update(:resource, resource, args, true, %{}) do
ActivityPub.update(resource, args, true, %{}) do
{:ok, resource}
else
{:resource, _} ->
@@ -165,12 +165,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
with %Actor{id: actor_id} = actor <- Users.get_actor_for_user(user),
{:resource, %Resource{parent_id: _parent_id, actor_id: group_id} = resource} <-
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.delete(resource) do
ActivityPub.delete(resource, actor) do
{:ok, resource}
else
{:resource, _} ->

View File

@@ -3,8 +3,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Tag do
Handles the tag-related GraphQL calls
"""
alias Mobilizon.Events
alias Mobilizon.{Events, Posts}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Posts.Post
def list_tags(_parent, %{page: page, limit: limit}, _resolution) do
tags = Mobilizon.Events.list_tags(page, limit)
@@ -16,7 +17,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Tag do
Retrieve the list of tags for an event
"""
def list_tags_for_event(%Event{id: id}, _args, _resolution) do
{:ok, Mobilizon.Events.list_tags_for_event(id)}
{:ok, Events.list_tags_for_event(id)}
end
@doc """
@@ -24,10 +25,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Tag do
"""
def list_tags_for_event(%{url: url}, _args, _resolution) do
with %Event{id: event_id} <- Events.get_event_by_url(url) do
{:ok, Mobilizon.Events.list_tags_for_event(event_id)}
{:ok, Events.list_tags_for_event(event_id)}
end
end
@doc """
Retrieve the list of tags for a post
"""
def list_tags_for_post(%Post{id: id}, _args, _resolution) do
{:ok, Posts.list_tags_for_post(id)}
end
# @doc """
# Retrieve the list of related tags for a given tag ID
# """
@@ -42,7 +50,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Tag do
Retrieve the list of related tags for a parent tag
"""
def get_related_tags(%Tag{} = tag, _args, _resolution) do
with tags <- Mobilizon.Events.list_tag_neighbors(tag) do
with tags <- Events.list_tag_neighbors(tag) do
{:ok, tags}
end
end

View File

@@ -211,7 +211,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Todos do
{:todo_list, Todos.get_todo_list(todo_list_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Todo{} = todo} <-
ActivityPub.update(:todo, todo, args, true, %{}) do
ActivityPub.update(todo, args, true, %{}) do
{:ok, todo}
else
{:todo_list, _} ->

View File

@@ -9,6 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User}
@@ -417,7 +418,8 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(moderator_user)},
%User{disabled: false} = user <- Users.get_user(user_id),
{:ok, %User{}} <- do_delete_account(%User{} = user) do
{:ok, %User{}} <-
do_delete_account(%User{} = user, Relay.get_actor()) do
Admin.log_action(moderator_actor, "delete", user)
else
{:moderator_actor, nil} ->
@@ -432,7 +434,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
{:error, "You need to be logged-in to delete your account"}
end
defp do_delete_account(%User{} = user) do
defp do_delete_account(%User{} = user, actor_performing \\ nil) do
with actors <- Users.get_actors_for_user(user),
activated <- not is_nil(user.confirmed_at),
# Detach actors from user
@@ -444,7 +446,8 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
# Launch a background job to delete actors
:ok <-
Enum.each(actors, fn actor ->
ActivityPub.delete(actor, true)
actor_performing = actor_performing || actor
ActivityPub.delete(actor, actor_performing, true)
end),
# Delete user
{:ok, user} <- Users.delete_user(user, reserve_email: activated) do

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema do
alias Mobilizon.{
Actors,
Addresses,
Conversations,
Discussions,
Events,
Media,
Reports,
@@ -18,7 +18,7 @@ defmodule Mobilizon.GraphQL.Schema do
}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.GraphQL.Schema
alias Mobilizon.Storage.Repo
@@ -34,10 +34,11 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Actors.PersonType)
import_types(Schema.Actors.GroupType)
import_types(Schema.Actors.ApplicationType)
import_types(Schema.Conversations.CommentType)
import_types(Schema.Conversations.ConversationType)
import_types(Schema.Discussions.CommentType)
import_types(Schema.Discussions.DiscussionType)
import_types(Schema.SearchType)
import_types(Schema.ResourceType)
import_types(Schema.PostType)
import_types(Schema.Todos.TodoListType)
import_types(Schema.Todos.TodoType)
import_types(Schema.ConfigType)
@@ -116,7 +117,7 @@ defmodule Mobilizon.GraphQL.Schema do
|> Dataloader.add_source(Actors, default_source)
|> Dataloader.add_source(Users, default_source)
|> Dataloader.add_source(Events, default_source)
|> Dataloader.add_source(Conversations, Conversations.data())
|> Dataloader.add_source(Discussions, Discussions.data())
|> Dataloader.add_source(Addresses, default_source)
|> Dataloader.add_source(Media, default_source)
|> Dataloader.add_source(Reports, default_source)
@@ -148,8 +149,9 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:admin_queries)
import_fields(:todo_list_queries)
import_fields(:todo_queries)
import_fields(:conversation_queries)
import_fields(:discussion_queries)
import_fields(:resource_queries)
import_fields(:post_queries)
import_fields(:statistics_queries)
end
@@ -170,8 +172,9 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:admin_mutations)
import_fields(:todo_list_mutations)
import_fields(:todo_mutations)
import_fields(:conversation_mutations)
import_fields(:discussion_mutations)
import_fields(:resource_mutations)
import_fields(:post_mutations)
end
@desc """
@@ -179,5 +182,6 @@ defmodule Mobilizon.GraphQL.Schema do
"""
subscription do
import_fields(:person_subscriptions)
import_fields(:discussion_subscriptions)
end
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Conversation, Group, Member, Resource, Todos}
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType)
@@ -46,9 +46,9 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description("A list of the events this actor has organized")
end
field :conversations, :paginated_conversation_list do
resolve(&Conversation.find_conversations_for_actor/3)
description("A list of the conversations for this group")
field :discussions, :paginated_discussion_list do
resolve(&Discussion.find_discussions_for_actor/3)
description("A list of the discussions for this group")
end
field(:types, :group_type, description: "The type of group : Group, Community,…")
@@ -58,8 +58,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
)
field :members, :paginated_member_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
arg(:roles, :string, default_value: "")
resolve(&Member.find_members_for_group/3)
description("List of group members")
description("A paginated list of group members")
end
field :resources, :paginated_resource_list do
@@ -69,6 +72,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description("A paginated list of the resources this group has")
end
field :posts, :paginated_post_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Post.find_posts_for_group/3)
description("A paginated list of the posts this group has")
end
field :todo_lists, :paginated_todo_list_list do
resolve(&Todos.find_todo_lists_for_group/3)
description("A paginated list of the todo lists this group has")
@@ -99,6 +109,12 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:total, :integer, description: "The total number of elements in the list")
end
@desc "The list of visibility options for a group"
enum :group_visibility do
value(:public, description: "Publicly listed and federated")
value(:unlisted, description: "Visible only to people with the link - or invited")
end
object :group_queries do
@desc "Get all groups"
field :groups, :paginated_group_list do
@@ -124,6 +140,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "")
arg(:visibility, :group_visibility,
description: "The visibility for the group",
default_value: :public
)
arg(:avatar, :picture_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
@@ -137,6 +158,26 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
resolve(&Group.create_group/3)
end
@desc "Update a group"
field :update_group, :group do
arg(:id, non_null(:id), description: "The group ID")
arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "")
arg(:avatar, :picture_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the group, either as an object or directly the ID of an existing Picture"
)
resolve(&Group.update_group/3)
end
@desc "Delete a group"
field :delete_group, :deleted_object do
arg(:group_id, non_null(:id))

View File

@@ -15,6 +15,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
field(:actor, :person, description: "Which profile is member of")
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")
end
enum :member_role_enum do

View File

@@ -6,7 +6,7 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
use Absinthe.Schema.Notation
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User

View File

@@ -1,74 +0,0 @@
defmodule Mobilizon.GraphQL.Schema.Conversations.ConversationType do
@moduledoc """
Schema representation for Conversation
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors
alias Mobilizon.GraphQL.Resolvers.Conversation
@desc "A conversation"
object :conversation do
field(:id, :id, description: "Internal ID for this conversation")
field(:title, :string)
field(:slug, :string)
field(:last_comment, :comment)
field :comments, :paginated_comment_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Conversation.get_comments_for_conversation/3)
description("The comments for the conversation")
end
field(:creator, :person, resolve: dataloader(Actors))
field(:actor, :actor, resolve: dataloader(Actors))
field(:inserted_at, :datetime)
field(:updated_at, :datetime)
end
object :paginated_conversation_list do
field(:elements, list_of(:conversation), description: "A list of conversation")
field(:total, :integer, description: "The total number of comments in the list")
end
object :conversation_queries do
@desc "Get a conversation"
field :conversation, type: :conversation do
arg(:id, non_null(:id))
resolve(&Conversation.get_conversation/3)
end
end
object :conversation_mutations do
@desc "Create a conversation"
field :create_conversation, type: :conversation do
arg(:title, non_null(:string))
arg(:text, non_null(:string))
arg(:actor_id, non_null(:id))
arg(:creator_id, non_null(:id))
resolve(&Conversation.create_conversation/3)
end
field :reply_to_conversation, type: :conversation do
arg(:conversation_id, non_null(:id))
arg(:text, non_null(:string))
resolve(&Conversation.reply_to_conversation/3)
end
field :update_conversation, type: :conversation do
arg(:title, non_null(:string))
arg(:conversation_id, non_null(:id))
resolve(&Conversation.update_conversation/3)
end
field :delete_conversation, type: :conversation do
arg(:conversation_id, non_null(:id))
# resolve(&Conversation.delete_conversation/3)
end
end
end

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.GraphQL.Schema.Conversations.CommentType do
defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do
@moduledoc """
Schema representation for Comment
"""
@@ -6,7 +6,7 @@ defmodule Mobilizon.GraphQL.Schema.Conversations.CommentType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.{Actors, Conversations}
alias Mobilizon.{Actors, Discussions}
alias Mobilizon.GraphQL.Resolvers.Comment
@desc "A comment"
@@ -21,13 +21,13 @@ defmodule Mobilizon.GraphQL.Schema.Conversations.CommentType do
field(:primaryLanguage, :string)
field(:replies, list_of(:comment)) do
resolve(dataloader(Conversations))
resolve(dataloader(Discussions))
end
field(:total_replies, :integer)
field(:in_reply_to_comment, :comment, resolve: dataloader(Conversations))
field(:in_reply_to_comment, :comment, resolve: dataloader(Discussions))
field(:event, :event, resolve: dataloader(Events))
field(:origin_comment, :comment, resolve: dataloader(Conversations))
field(:origin_comment, :comment, resolve: dataloader(Discussions))
field(:threadLanguages, non_null(list_of(:string)))
field(:actor, :person, resolve: dataloader(Actors))
field(:inserted_at, :datetime)

View File

@@ -0,0 +1,85 @@
defmodule Mobilizon.GraphQL.Schema.Discussions.DiscussionType do
@moduledoc """
Schema representation for discussion
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors
alias Mobilizon.GraphQL.Resolvers.Discussion
@desc "A discussion"
object :discussion do
field(:id, :id, description: "Internal ID for this discussion")
field(:title, :string)
field(:slug, :string)
field(:last_comment, :comment)
field :comments, :paginated_comment_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Discussion.get_comments_for_discussion/3)
description("The comments for the discussion")
end
field(:creator, :person, resolve: dataloader(Actors))
field(:actor, :actor, resolve: dataloader(Actors))
field(:inserted_at, :datetime)
field(:updated_at, :datetime)
end
object :paginated_discussion_list do
field(:elements, list_of(:discussion), description: "A list of discussion")
field(:total, :integer, description: "The total number of comments in the list")
end
object :discussion_queries do
@desc "Get a discussion"
field :discussion, type: :discussion do
arg(:id, :id)
arg(:slug, :string)
resolve(&Discussion.get_discussion/3)
end
end
object :discussion_mutations do
@desc "Create a discussion"
field :create_discussion, type: :discussion do
arg(:title, non_null(:string))
arg(:text, non_null(:string))
arg(:actor_id, non_null(:id))
arg(:creator_id, non_null(:id))
resolve(&Discussion.create_discussion/3)
end
field :reply_to_discussion, type: :discussion do
arg(:discussion_id, non_null(:id))
arg(:text, non_null(:string))
resolve(&Discussion.reply_to_discussion/3)
end
field :update_discussion, type: :discussion do
arg(:title, non_null(:string))
arg(:discussion_id, non_null(:id))
resolve(&Discussion.update_discussion/3)
end
field :delete_discussion, type: :discussion do
arg(:discussion_id, non_null(:id))
resolve(&Discussion.delete_discussion/3)
end
end
object :discussion_subscriptions do
field :discussion_comment_changed, :discussion do
arg(:slug, non_null(:string))
config(fn args, _ ->
{:ok, topic: args.slug}
end)
end
end
end

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import Mobilizon.GraphQL.Helpers.Error
alias Mobilizon.{Actors, Addresses, Conversations}
alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Picture, Tag}
alias Mobilizon.GraphQL.Schema
@@ -82,7 +82,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
)
field(:comments, list_of(:comment), description: "The comments in reply to the event") do
resolve(dataloader(Conversations))
resolve(dataloader(Discussions))
end
# field(:tracks, list_of(:track))

View File

@@ -0,0 +1,91 @@
defmodule Mobilizon.GraphQL.Schema.PostType do
@moduledoc """
Schema representation for Posts
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Post, Tag}
@desc "A post"
object :post do
field(:id, :id, description: "The post's ID")
field(:title, :string, description: "The post's title")
field(:slug, :string, description: "The post's slug")
field(:body, :string, description: "The post's body, as HTML")
field(:url, :string, description: "The post's URL")
field(:draft, :boolean, description: "Whether the post is a draft")
field(:author, :actor, description: "The post's author")
field(:attributed_to, :actor, description: "The post's group")
field(:visibility, :post_visibility, description: "The post's visibility")
field(:publish_at, :datetime, description: "When the post was published")
field(:inserted_at, :naive_datetime, description: "The post's creation date")
field(:updated_at, :naive_datetime, description: "The post's last update date")
field(:tags, list_of(:tag),
resolve: &Tag.list_tags_for_post/3,
description: "The post's tags"
)
end
object :paginated_post_list do
field(:elements, list_of(:post), description: "A list of posts")
field(:total, :integer, description: "The total number of posts in the list")
end
@desc "The list of visibility options for a post"
enum :post_visibility do
value(:public, description: "Publicly listed and federated. Can be shared.")
value(:unlisted, description: "Visible only to people with the link")
# value(:restricted, description: "Visible only after a moderator accepted")
value(:private,
description: "Visible only to people members of the group or followers of the person"
)
end
object :post_queries do
@desc "Get a post"
field :post, :post do
arg(:slug, non_null(:string))
resolve(&Post.get_post/3)
end
end
object :post_mutations do
@desc "Create a post"
field :create_post, :post do
arg(:attributed_to_id, non_null(:id))
arg(:title, non_null(:string))
arg(:body, :string)
arg(:draft, :boolean, default_value: false)
arg(:visibility, :post_visibility)
arg(:publish_at, :datetime)
arg(:tags, list_of(:string),
default_value: [],
description: "The list of tags associated to the post"
)
resolve(&Post.create_post/3)
end
@desc "Update a post"
field :update_post, :post do
arg(:id, non_null(:id))
arg(:title, :string)
arg(:body, :string)
arg(:attributed_to_id, :id)
arg(:draft, :boolean)
arg(:visibility, :post_visibility)
arg(:publish_at, :datetime)
arg(:tags, list_of(:string), description: "The list of tags associated to the post")
resolve(&Post.update_post/3)
end
@desc "Delete a post"
field :delete_post, :deleted_object do
arg(:id, non_null(:id))
resolve(&Post.delete_post/3)
end
end
end

View File

@@ -9,7 +9,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias Mobilizon.Reports.{Note, Report}
@@ -27,6 +27,9 @@ defmodule Mobilizon.Actors.Actor do
following_url: String.t(),
followers_url: String.t(),
shared_inbox_url: String.t(),
resources_url: String.t(),
posts_url: String.t(),
events_url: String.t(),
type: ActorType.t(),
name: String.t(),
domain: String.t(),
@@ -62,6 +65,10 @@ defmodule Mobilizon.Actors.Actor do
:shared_inbox_url,
:following_url,
:followers_url,
:posts_url,
:events_url,
:todos_url,
:discussions_url,
:type,
:name,
:domain,
@@ -96,6 +103,10 @@ defmodule Mobilizon.Actors.Actor do
:followers_url,
:members_url,
:resources_url,
:posts_url,
:todos_url,
:events_url,
:discussions_url,
:name,
:summary,
:manually_approves_followers,
@@ -117,6 +128,7 @@ defmodule Mobilizon.Actors.Actor do
schema "actors" do
field(:url, :string)
field(:outbox_url, :string)
field(:inbox_url, :string)
field(:following_url, :string)
@@ -124,7 +136,11 @@ defmodule Mobilizon.Actors.Actor do
field(:shared_inbox_url, :string)
field(:members_url, :string)
field(:resources_url, :string)
field(:posts_url, :string)
field(:events_url, :string)
field(:todos_url, :string)
field(:discussions_url, :string)
field(:type, ActorType, default: :Person)
field(:name, :string)
field(:domain, :string, default: nil)
@@ -344,7 +360,8 @@ defmodule Mobilizon.Actors.Actor do
def build_url("relay", :page, _args),
do: Endpoint |> Routes.activity_pub_url(:relay) |> URI.decode()
def build_url(preferred_username, endpoint, args) when endpoint in [:page, :resources] do
def build_url(preferred_username, endpoint, args)
when endpoint in [:page, :resources, :posts, :discussions, :events, :todos] do
endpoint = if endpoint == :page, do: :actor, else: endpoint
Endpoint
@@ -353,7 +370,7 @@ defmodule Mobilizon.Actors.Actor do
end
def build_url(preferred_username, endpoint, args)
when endpoint in [:outbox, :following, :followers, :members, :todos] do
when endpoint in [:outbox, :following, :followers, :members] do
Endpoint
|> Routes.activity_pub_url(endpoint, preferred_username, args)
|> URI.decode()

View File

@@ -55,6 +55,8 @@ defmodule Mobilizon.Actors do
@public_visibility [:public, :unlisted]
@administrator_roles [:creator, :administrator]
@moderator_roles [:moderator] ++ @administrator_roles
@member_roles [:member] ++ @moderator_roles
@actor_preloads [:user, :organized_events, :comments]
@doc """
@@ -118,6 +120,17 @@ defmodule Mobilizon.Actors do
end
end
@doc """
New function to replace `Mobilizon.Actors.get_actor_by_url/1` with
better signature
"""
@spec get_actor_by_url_2(String.t(), boolean) :: Actor.t() | nil
def get_actor_by_url_2(url, preload \\ false) do
Actor
|> Repo.get_by(url: url)
|> preload_followers(preload)
end
@doc """
Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to
preload the followers relation.
@@ -181,9 +194,17 @@ defmodule Mobilizon.Actors do
"""
@spec create_actor(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_actor(attrs \\ %{}) do
%Actor{}
|> Actor.changeset(attrs)
|> Repo.insert()
type = Map.get(attrs, :type, :Person)
case type do
:Person ->
%Actor{}
|> Actor.changeset(attrs)
|> Repo.insert()
:Group ->
create_group(attrs)
end
end
@doc """
@@ -238,7 +259,8 @@ defmodule Mobilizon.Actors do
name: name,
summary: summary,
avatar: transform_media_file(avatar),
banner: transform_media_file(banner)
banner: transform_media_file(banner),
last_refreshed_at: DateTime.utc_now()
]
],
conflict_target: [:url]
@@ -285,6 +307,7 @@ defmodule Mobilizon.Actors do
"""
@spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def perform(:delete_actor, %Actor{} = actor, options \\ @delete_actor_default_options) do
Logger.info("Going to delete actor #{actor.url}")
actor = Repo.preload(actor, @actor_preloads)
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
@@ -306,10 +329,18 @@ defmodule Mobilizon.Actors do
case Repo.transaction(multi) do
{:ok, %{actor: %Actor{} = actor}} ->
{:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}")
Logger.info("Deleted actor #{actor.url}")
{:ok, actor}
{:error, remove, error, _} when remove in [:remove_banner, :remove_avatar] ->
Logger.error("Error while deleting actor's banner or avatar")
Logger.error(inspect(error, pretty: true))
{:error, error}
err ->
Logger.error("Unknown error while deleting actor")
Logger.error(inspect(err, pretty: true))
{:error, err}
end
end
@@ -438,23 +469,47 @@ defmodule Mobilizon.Actors do
end
end
@spec get_local_group_by_url(String.t()) :: Actor.t()
def get_local_group_by_url(group_url) do
group_query()
|> where([q], q.url == ^group_url and is_nil(q.domain))
|> Repo.one()
end
@spec get_group_by_members_url(String.t()) :: Actor.t()
def get_group_by_members_url(members_url) do
group_query()
|> where([q], q.members_url == ^members_url)
|> Repo.one()
end
@doc """
Creates a group.
If the group is local, creates an admin actor as well from `creator_actor_id`.
"""
@spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_group(attrs \\ %{}) do
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
Multi.new()
|> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs))
|> Multi.insert(:add_admin_member, fn %{insert_group: group} ->
Member.changeset(%Member{}, %{
parent_id: group.id,
actor_id: attrs.creator_actor_id,
role: :administrator
})
end)
|> Repo.transaction() do
{:ok, group}
local = Map.get(attrs, :local, true)
if local do
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
Multi.new()
|> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs))
|> Multi.insert(:add_admin_member, fn %{insert_group: group} ->
Member.changeset(%Member{}, %{
parent_id: group.id,
actor_id: attrs.creator_actor_id,
role: :administrator
})
end)
|> Repo.transaction() do
{:ok, group}
end
else
%Actor{}
|> Actor.group_creation_changeset(attrs)
|> Repo.insert()
end
end
@@ -532,12 +587,7 @@ defmodule Mobilizon.Actors do
def is_member?(actor_id, parent_id) do
match?(
{:ok, %Member{}},
get_member(actor_id, parent_id, [
:member,
:moderator,
:administrator,
:creator
])
get_member(actor_id, parent_id, @member_roles)
)
end
@@ -552,6 +602,20 @@ defmodule Mobilizon.Actors do
|> Repo.one()
end
@spec get_single_group_member_actor(integer() | String.t()) :: Actor.t() | nil
def get_single_group_member_actor(group_id) do
Member
|> where(
[m],
m.parent_id == ^group_id and m.role in [^:member, ^:moderator, ^:administrator, ^:creator]
)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], is_nil(a.domain))
|> limit(1)
|> select([_m, a], a)
|> Repo.one()
end
@doc """
Creates a member.
"""
@@ -616,25 +680,26 @@ defmodule Mobilizon.Actors do
@doc """
Returns the list of members for a group.
"""
@spec list_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
group_id
|> members_for_group_query()
|> Page.build_page(page, limit)
end
@spec list_external_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_external_members_for_group(
@spec list_members_for_group(Actor.t(), list(atom()), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(
%Actor{id: group_id, type: :Group},
roles \\ [],
page \\ nil,
limit \\ nil
) do
group_id
|> members_for_group_query()
|> filter_external()
|> filter_member_role(roles)
|> Page.build_page(page, limit)
end
@spec list_external_actors_members_for_group(Actor.t()) :: list(Actor.t())
def list_external_actors_members_for_group(%Actor{id: group_id, type: :Group}) do
group_id
|> group_external_member_actor_query()
|> Repo.all()
end
@doc """
Returns the list of administrator members for a group.
"""
@@ -1141,6 +1206,26 @@ defmodule Mobilizon.Actors do
)
end
@spec group_external_member_actor_query(integer()) :: Ecto.Query.t()
defp group_external_member_actor_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], not is_nil(a.domain))
|> select([_m, a], a)
end
@spec filter_member_role(Ecto.Query.t(), list(atom()) | atom()) :: Ecto.Query.t()
def filter_member_role(query, []), do: query
def filter_member_role(query, roles) when is_list(roles) do
where(query, [m], m.role in ^roles)
end
def filter_member_role(query, role) when is_atom(role) do
from(m in query, where: m.role == ^role)
end
@spec administrator_members_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp administrator_members_for_group_query(group_id) do
from(
@@ -1296,13 +1381,22 @@ defmodule Mobilizon.Actors do
defp preload_followers(actor, true), do: Repo.preload(actor, [:followers])
defp preload_followers(actor, false), do: actor
defp delete_actor_organized_events(%Actor{organized_events: organized_events}) do
defp delete_actor_organized_events(%Actor{organized_events: organized_events} = actor) do
res =
Enum.map(organized_events, fn event ->
event =
Repo.preload(event, [:organizer_actor, :participants, :picture, :mentions, :comments])
Repo.preload(event, [
:organizer_actor,
:participants,
:picture,
:mentions,
:comments,
:attributed_to,
:tags,
:physical_address
])
ActivityPub.delete(event, false)
ActivityPub.delete(event, actor, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do
@@ -1312,13 +1406,21 @@ defmodule Mobilizon.Actors do
end
end
defp delete_actor_empty_comments(%Actor{comments: comments}) do
defp delete_actor_empty_comments(%Actor{comments: comments} = actor) do
res =
Enum.map(comments, fn comment ->
comment =
Repo.preload(comment, [:actor, :mentions, :event, :in_reply_to_comment, :origin_comment])
Repo.preload(comment, [
:actor,
:mentions,
:event,
:in_reply_to_comment,
:origin_comment,
:attributed_to,
:tags
])
ActivityPub.delete(comment, false)
ActivityPub.delete(comment, actor, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do

View File

@@ -119,7 +119,7 @@ defmodule Mobilizon.Config do
@spec instance_user_agent :: String.t()
def instance_user_agent,
do: "#{instance_name()} #{instance_hostname()} - Mobilizon #{instance_version()}"
do: "#{instance_hostname()} - Mobilizon #{instance_version()}"
@spec instance_federating :: String.t()
def instance_federating, do: instance_config()[:federating]

View File

@@ -1,53 +0,0 @@
defmodule Mobilizon.Conversations.Conversation.TitleSlug do
@moduledoc """
Module to generate the slug for conversations
"""
use EctoAutoslugField.Slug, from: :title, to: :slug
end
defmodule Mobilizon.Conversations.Conversation do
@moduledoc """
Represents a conversation
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Conversations.Conversation.TitleSlug
@type t :: %__MODULE__{
creator: Actor.t(),
actor: Actor.t(),
title: String.t(),
slug: String.t(),
last_comment: Comment.t(),
comments: list(Comment.t())
}
@required_attrs [:actor_id, :creator_id, :title, :last_comment_id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
schema "conversations" do
field(:title, :string)
field(:slug, TitleSlug.Type)
belongs_to(:creator, Actor)
belongs_to(:actor, Actor)
belongs_to(:last_comment, Comment)
has_many(:comments, Comment, foreign_key: :conversation_id)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = conversation, attrs) do
conversation
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> TitleSlug.maybe_generate_slug()
end
end

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.Conversations.Comment do
defmodule Mobilizon.Discussions.Comment do
@moduledoc """
Represents an actor comment (for instance on an event or on a group).
"""
@@ -8,7 +8,7 @@ defmodule Mobilizon.Conversations.Comment do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, CommentVisibility, Conversation}
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Mention
@@ -42,7 +42,7 @@ defmodule Mobilizon.Conversations.Comment do
:attributed_to_id,
:deleted_at,
:local,
:conversation_id
:discussion_id
]
@attrs @required_attrs ++ @optional_attrs
@@ -60,7 +60,7 @@ defmodule Mobilizon.Conversations.Comment do
belongs_to(:event, Event, foreign_key: :event_id)
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
belongs_to(:conversation, Conversation)
belongs_to(:discussion, Discussion, type: :binary_id)
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention)
@@ -69,7 +69,7 @@ defmodule Mobilizon.Conversations.Comment do
end
@doc """
Returns the id of the first comment in the conversation.
Returns the id of the first comment in the discussion.
"""
@spec get_thread_id(t) :: integer
def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do
@@ -98,6 +98,7 @@ defmodule Mobilizon.Conversations.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

View File

@@ -0,0 +1,102 @@
defmodule Mobilizon.Discussions.Discussion.TitleSlug do
@moduledoc """
Module to generate the slug for discussions
"""
use EctoAutoslugField.Slug, from: [:title, :id], to: :slug
def build_slug([title, id], %Ecto.Changeset{valid?: true}) do
[title, ShortUUID.encode!(id)]
|> Enum.join("-")
|> Slugger.slugify()
end
def build_slug(_sources, %Ecto.Changeset{valid?: false}), do: ""
end
defmodule Mobilizon.Discussions.Discussion do
@moduledoc """
Represents a discussion
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Discussions.Discussion.TitleSlug
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@type t :: %__MODULE__{
creator: Actor.t(),
actor: Actor.t(),
title: String.t(),
url: String.t(),
slug: String.t(),
last_comment: Comment.t(),
comments: list(Comment.t())
}
@required_attrs [:actor_id, :creator_id, :title, :last_comment_id, :url, :id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
@primary_key {:id, Ecto.UUID, autogenerate: true}
schema "discussions" do
field(:title, :string)
field(:slug, TitleSlug.Type)
field(:url, :string)
belongs_to(:creator, Actor)
belongs_to(:actor, Actor)
belongs_to(:last_comment, Comment)
has_many(:comments, Comment, foreign_key: :discussion_id)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = discussion, attrs) do
discussion
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> validate_required([:title, :id])
|> TitleSlug.maybe_generate_slug()
|> TitleSlug.unique_constraint()
|> maybe_generate_url()
|> validate_required(@required_attrs)
end
defp maybe_generate_id(%Ecto.Changeset{} = changeset) do
case fetch_field(changeset, :id) do
res when res in [:error, {:data, nil}] ->
put_change(changeset, :id, Ecto.UUID.generate())
_ ->
changeset
end
end
@spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp maybe_generate_url(%Ecto.Changeset{} = changeset) do
with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url),
{changes, slug} when changes in [:changes, :data] <-
fetch_field(changeset, :slug),
{_changes, actor_id} <-
fetch_field(changeset, :actor_id),
%Actor{preferred_username: preferred_username} <-
Actors.get_actor(actor_id),
url <- generate_url(preferred_username, slug) do
put_change(changeset, :url, url)
else
_ -> changeset
end
end
@spec generate_url(String.t(), String.t()) :: String.t()
defp generate_url(preferred_username, slug),
do: Routes.page_url(Endpoint, :discussion, preferred_username, slug)
end

View File

@@ -1,6 +1,6 @@
defmodule Mobilizon.Conversations do
defmodule Mobilizon.Discussions do
@moduledoc """
The conversations context
The discussions context
"""
import EctoEnum
@@ -9,7 +9,7 @@ defmodule Mobilizon.Conversations do
alias Ecto.Changeset
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, Conversation}
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Storage.{Page, Repo}
defenum(
@@ -42,10 +42,11 @@ defmodule Mobilizon.Conversations do
:origin_comment,
:replies,
:tags,
:mentions
:mentions,
:discussion
]
@conversation_preloads [
@discussion_preloads [
:last_comment,
:comments,
:creator,
@@ -231,21 +232,11 @@ defmodule Mobilizon.Conversations do
@doc """
Returns the list of public comments for the actor.
"""
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Comment.t()], integer}
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
comments =
actor_id
|> public_comments_for_actor_query()
|> Page.paginate(page, limit)
|> Repo.all()
count_comments =
actor_id
|> count_comments_query()
|> Repo.one()
{:ok, comments, count_comments}
actor_id
|> public_comments_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
@@ -263,10 +254,10 @@ defmodule Mobilizon.Conversations do
|> Repo.all()
end
@spec get_comments_for_conversation(integer, integer | nil, integer | nil) :: Page.t()
def get_comments_for_conversation(conversation_id, page \\ nil, limit \\ nil) do
@spec get_comments_for_discussion(integer, integer | nil, integer | nil) :: Page.t()
def get_comments_for_discussion(discussion_id, page \\ nil, limit \\ nil) do
Comment
|> where([c], c.conversation_id == ^conversation_id)
|> where([c], c.discussion_id == ^discussion_id)
|> order_by(asc: :inserted_at)
|> Page.build_page(page, limit)
end
@@ -277,80 +268,114 @@ defmodule Mobilizon.Conversations do
@spec count_local_comments :: integer
def count_local_comments, do: Repo.one(count_local_comments_query())
def get_conversation(conversation_id) do
Conversation
|> Repo.get(conversation_id)
|> Repo.preload(@conversation_preloads)
def get_discussion(discussion_id) do
Discussion
|> Repo.get(discussion_id)
|> Repo.preload(@discussion_preloads)
end
@spec find_conversations_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_conversations_for_actor(actor_id, page \\ nil, limit \\ nil) do
Conversation
@spec get_discussion_by_url(String.t() | nil) :: Discussion.t() | nil
def get_discussion_by_url(nil), do: nil
def get_discussion_by_url(discussion_url) do
Discussion
|> Repo.get_by(url: discussion_url)
|> Repo.preload(@discussion_preloads)
end
def get_discussion_by_slug(discussion_slug) do
Discussion
|> Repo.get_by(slug: discussion_slug)
|> Repo.preload(@discussion_preloads)
end
@spec find_discussions_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_discussions_for_actor(actor_id, page \\ nil, limit \\ nil) do
Discussion
|> where([c], c.actor_id == ^actor_id)
|> preload(^@conversation_preloads)
|> preload(^@discussion_preloads)
|> Page.build_page(page, limit)
end
@doc """
Creates a conversation.
Creates a discussion.
"""
@spec create_conversation(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_conversation(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
@spec create_discussion(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_discussion(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, discussion: %Discussion{} = discussion}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{actor_id: attrs.creator_id}))
Comment.changeset(
%Comment{},
Map.merge(attrs, %{actor_id: attrs.creator_id, attributed_to_id: attrs.actor_id})
)
)
|> Multi.insert(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
%Conversation{},
|> Multi.insert(:discussion, fn %{comment: %Comment{id: comment_id}} ->
Discussion.changeset(
%Discussion{},
Map.merge(attrs, %{last_comment_id: comment_id})
)
end)
|> Multi.update(:comment_conversation, fn %{
comment: %Comment{} = comment,
conversation: %Conversation{
id: conversation_id
}
} ->
Changeset.change(comment, %{conversation_id: conversation_id})
|> Multi.update(:comment_discussion, fn %{
comment: %Comment{} = comment,
discussion: %Discussion{
id: discussion_id,
url: discussion_url
}
} ->
Changeset.change(comment, %{discussion_id: discussion_id, url: discussion_url})
end)
|> Repo.transaction() do
{:ok, conversation}
{:ok, discussion}
end
end
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
def reply_to_discussion(%Discussion{id: discussion_id} = discussion, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, discussion: %Discussion{} = discussion}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{conversation_id: conversation_id}))
Comment.changeset(
%Comment{},
Map.merge(attrs, %{
discussion_id: discussion_id,
actor_id: Map.get(attrs, :creator_id, attrs.actor_id)
})
)
)
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
conversation,
|> Multi.update(:discussion, fn %{comment: %Comment{id: comment_id}} ->
Discussion.changeset(
discussion,
%{last_comment_id: comment_id}
)
end)
|> Repo.transaction() do
# For some reason conversation is not updated
{:ok, Map.put(conversation, :last_comment, comment)}
# Discussion is not updated
{:ok, Map.put(discussion, :last_comment, comment)}
end
end
@doc """
Update a conversation. Only their title for now.
Update a discussion. Only their title for now.
"""
@spec update_conversation(Conversation.t(), map()) ::
{:ok, Conversation.t()} | {:error, Changeset.t()}
def update_conversation(%Conversation{} = conversation, attrs \\ %{}) do
conversation
|> Conversation.changeset(attrs)
@spec update_discussion(Discussion.t(), map()) ::
{:ok, Discussion.t()} | {:error, Changeset.t()}
def update_discussion(%Discussion{} = discussion, attrs \\ %{}) do
discussion
|> Discussion.changeset(attrs)
|> Repo.update()
end
@doc """
Delete a discussion.
"""
@spec delete_discussion(Discussion.t()) :: {:ok, Discussion.t()} | {:error, Changeset.t()}
def delete_discussion(%Discussion{} = discussion) do
discussion
|> Repo.delete()
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
@@ -365,11 +390,6 @@ defmodule Mobilizon.Conversations do
|> preload_for_comment()
end
@spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
end
@spec count_local_comments_query :: Ecto.Query.t()
defp count_local_comments_query do
from(
@@ -382,6 +402,6 @@ defmodule Mobilizon.Conversations do
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
# @spec preload_for_conversation(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_conversation(query), do: preload(query, ^@conversation_preloads)
# @spec preload_for_discussion(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_discussion(query), do: preload(query, ^@discussion_preloads)
end

View File

@@ -13,7 +13,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.{Addresses, Events, Media, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{
EventOptions,

View File

@@ -7,7 +7,7 @@ defmodule Mobilizon.Events.EventOptions do
import Ecto.Changeset
alias Mobilizon.Conversations.CommentModeration
alias Mobilizon.Discussions.CommentModeration
alias Mobilizon.Events.{
EventOffer,

View File

@@ -380,24 +380,19 @@ defmodule Mobilizon.Events do
@doc """
Lists public events for the actor, with all associations loaded.
"""
@spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Event.t()], integer}
def list_public_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
events =
actor_id
|> event_for_actor_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Page.paginate(page, limit)
|> Repo.all()
@spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_public_events_for_actor(actor, page \\ nil, limit \\ nil)
events_count =
actor_id
|> count_events_for_actor_query()
|> Repo.one()
def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit),
do: list_organized_events_for_group(group, page, limit)
{:ok, events, events_count}
def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do
actor_id
|> event_for_actor_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Page.build_page(page, limit)
end
@spec list_organized_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
@@ -1321,15 +1316,6 @@ defmodule Mobilizon.Events do
)
end
@spec count_events_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp count_events_for_actor_query(actor_id) do
from(
e in Event,
select: count(e.id),
where: e.organizer_actor_id == ^actor_id
)
end
@spec count_local_events_query :: Ecto.Query.t()
defp count_local_events_query do
from(e in Event, select: count(e.id), where: e.local == ^true)

View File

@@ -19,7 +19,7 @@ defmodule Mobilizon.Events.Participant do
url: String.t(),
event: Event.t(),
actor: Actor.t(),
metadata: Map.t()
metadata: map()
}
@required_attrs [:url, :role, :event_id, :actor_id]

View File

@@ -6,7 +6,7 @@ defmodule Mobilizon.Mention do
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Storage.Repo

137
lib/mobilizon/posts/post.ex Normal file
View File

@@ -0,0 +1,137 @@
defmodule Mobilizon.Posts.Post.TitleSlug do
@moduledoc """
Module to generate the slug for posts
"""
use EctoAutoslugField.Slug, from: [:title, :id], to: :slug
def build_slug([title, id], %Ecto.Changeset{valid?: true}) do
[title, ShortUUID.encode!(id)]
|> Enum.join("-")
|> Slugger.slugify()
end
def build_slug(_sources, %Ecto.Changeset{valid?: false}), do: ""
end
defmodule Mobilizon.Posts.Post do
@moduledoc """
Module that represent Posts published by groups
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag
alias Mobilizon.Media.Picture
alias Mobilizon.Posts.Post.TitleSlug
alias Mobilizon.Posts.PostVisibility
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@type t :: %__MODULE__{
url: String.t(),
local: boolean,
slug: String.t(),
body: String.t(),
title: String.t(),
draft: boolean,
visibility: PostVisibility.t(),
publish_at: DateTime.t(),
author: Actor.t(),
attributed_to: Actor.t(),
picture: Picture.t(),
tags: [Tag.t()]
}
@primary_key {:id, Ecto.UUID, autogenerate: true}
schema "posts" do
field(:body, :string)
field(:draft, :boolean, default: false)
field(:local, :boolean, default: true)
field(:slug, TitleSlug.Type)
field(:title, :string)
field(:url, :string)
field(:publish_at, :utc_datetime)
field(:visibility, PostVisibility, default_value: :public)
belongs_to(:author, Actor)
belongs_to(:attributed_to, Actor)
belongs_to(:picture, Picture, on_replace: :update)
many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete)
timestamps()
end
@required_attrs [
:id,
:title,
:body,
:draft,
:slug,
:url,
:author_id,
:attributed_to_id
]
@optional_attrs [:picture_id, :local, :publish_at, :visibility]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(%__MODULE__{} = post, attrs) do
post
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> put_tags(attrs)
|> maybe_put_publish_date()
# Validate ID and title here because they're needed for slug
|> validate_required([:id, :title])
|> TitleSlug.maybe_generate_slug()
|> TitleSlug.unique_constraint()
|> maybe_generate_url()
|> validate_required(@required_attrs)
end
defp maybe_generate_id(%Ecto.Changeset{} = changeset) do
case fetch_field(changeset, :id) do
res when res in [:error, {:data, nil}] ->
put_change(changeset, :id, Ecto.UUID.generate())
_ ->
changeset
end
end
@spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp maybe_generate_url(%Ecto.Changeset{} = changeset) do
with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url),
{changes, id_and_slug} when changes in [:changes, :data] <-
fetch_field(changeset, :slug),
url <- generate_url(id_and_slug) do
put_change(changeset, :url, url)
else
_ -> changeset
end
end
@spec generate_url(String.t()) :: String.t()
defp generate_url(id_and_slug), do: Routes.page_url(Endpoint, :post, id_and_slug)
@spec put_tags(Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
defp put_tags(changeset, %{"tags" => tags}),
do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1))
defp put_tags(changeset, %{tags: tags}),
do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1))
defp put_tags(changeset, _), do: changeset
defp process_tag(tag), do: Tag.changeset(%Tag{}, tag)
defp maybe_put_publish_date(%Changeset{} = changeset) do
publish_at =
if get_field(changeset, :draft, true) == false,
do: DateTime.utc_now() |> DateTime.truncate(:second),
else: nil
put_change(changeset, :publish_at, publish_at)
end
end

View File

@@ -0,0 +1,135 @@
defmodule Mobilizon.Posts do
@moduledoc """
The Posts context.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag
alias Mobilizon.Posts.Post
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
require Logger
@post_preloads [:author, :attributed_to, :picture]
import EctoEnum
defenum(PostVisibility, :post_visibility, [
:public,
:unlisted,
:restricted,
:private
])
@doc """
Returns the list of recent posts for a group
"""
@spec get_posts_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_posts_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> do_get_posts_for_group()
|> Page.build_page(page, limit)
end
@spec get_public_posts_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_public_posts_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> do_get_posts_for_group()
|> where([p], p.visibility == ^:public and not p.draft)
|> Page.build_page(page, limit)
end
def do_get_posts_for_group(group_id) do
Post
|> where(attributed_to_id: ^group_id)
|> order_by(desc: :inserted_at)
|> preload([p], [:author, :attributed_to, :picture])
end
@doc """
Get a post by it's ID
"""
@spec get_post(integer | String.t()) :: Post.t() | nil
def get_post(nil), do: nil
def get_post(id), do: Repo.get(Post, id)
@spec get_post_with_preloads(integer | String.t()) :: Post.t() | nil
def get_post_with_preloads(id) do
Post
|> Repo.get(id)
|> Repo.preload(@post_preloads)
end
@spec get_post_by_slug(String.t()) :: Post.t() | nil
def get_post_by_slug(nil), do: nil
def get_post_by_slug(slug), do: Repo.get_by(Post, slug: slug)
@spec get_post_by_slug_with_preloads(String.t()) :: Post.t() | nil
def get_post_by_slug_with_preloads(slug) do
Post
|> Repo.get_by(slug: slug)
|> Repo.preload(@post_preloads)
end
@doc """
Get a post by it's URL
"""
@spec get_post_by_url(String.t()) :: Post.t() | nil
def get_post_by_url(url), do: Repo.get_by(Post, url: url)
@spec get_post_by_url_with_preloads(String.t()) :: Post.t() | nil
def get_post_by_url_with_preloads(url) do
Post
|> Repo.get_by(url: url)
|> Repo.preload(@post_preloads)
end
@doc """
Creates a post.
"""
@spec create_post(map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a post.
"""
@spec update_post(Post.t(), map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def update_post(%Post{} = post, attrs) do
post
|> Repo.preload(:tags)
|> Post.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a post
"""
@spec delete_post(Post.t()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def delete_post(%Post{} = post), do: Repo.delete(post)
@doc """
Returns the list of tags for the post.
"""
@spec list_tags_for_post(integer | String.t()) :: [Tag.t()]
def list_tags_for_post(post_id) do
{:ok, uuid} = Ecto.UUID.dump(post_id)
uuid
|> tags_for_post_query()
|> Repo.all()
end
@spec tags_for_post_query(integer) :: Ecto.Query.t()
defp tags_for_post_query(post_id) do
from(
t in Tag,
join: p in "posts_tags",
on: t.id == p.tag_id,
where: p.post_id == ^post_id
)
end
end

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.Reports.Report do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, ReportStatus}

View File

@@ -23,6 +23,7 @@ defmodule Mobilizon.Resources do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end
@@ -55,6 +56,7 @@ defmodule Mobilizon.Resources do
Resource
|> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end

View File

@@ -27,6 +27,7 @@ defmodule Mobilizon.Todos do
TodoList
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> preload([:actor])
|> Page.build_page(page, limit)
end

View File

@@ -7,6 +7,15 @@ defmodule Mobilizon.Users.Setting do
import Ecto.Changeset
alias Mobilizon.Users.{NotificationPendingNotificationDelay, User}
@type t :: %__MODULE__{
timezone: String.t(),
notification_on_day: boolean,
notification_each_week: boolean,
notification_before_event: boolean,
notification_pending_participation: NotificationPendingNotificationDelay.t(),
user: User.t()
}
@required_attrs [:user_id]
@optional_attrs [

View File

@@ -47,7 +47,7 @@ defmodule Mobilizon.Service.Export.Feed do
defp fetch_actor_event_feed(name) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name),
{:visibility, true} <- {:visibility, Actor.is_public_visibility(actor)},
{:ok, events, _count} <- Events.list_public_events_for_actor(actor) do
%Page{elements: events} <- Events.list_public_events_for_actor(actor) do
{:ok, build_actor_feed(actor, events)}
else
err ->

View File

@@ -49,7 +49,8 @@ defmodule Mobilizon.Service.Export.ICalendar do
@spec export_public_actor(Actor.t()) :: String.t()
def export_public_actor(%Actor{} = actor) do
with true <- Actor.is_public_visibility(actor),
{:ok, events, _} <- Events.list_public_events_for_actor(actor) do
%Page{elements: events} <-
Events.list_public_events_for_actor(actor) do
{:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}
end
end

View File

@@ -4,8 +4,8 @@ defmodule Mobilizon.Service.Geospatial.Addok do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -15,26 +15,18 @@ defmodule Mobilizon.Service.Geospatial.Addok do
@default_country Application.get_env(:mobilizon, __MODULE__) |> get_in([:default_country]) ||
"France"
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Addok implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
process_data(features)
else
_ -> []
@@ -47,14 +39,11 @@ defmodule Mobilizon.Service.Geospatial.Addok do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
process_data(features)
else
_ -> []

View File

@@ -7,6 +7,7 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
alias Mobilizon.Addresses.Address
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -28,11 +29,6 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
@api_key_missing_message "API Key required to use Google Maps"
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -43,12 +39,11 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for reverse geocode with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, [], @http_options),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"results" => results, "status" => "OK"} <- body do
Enum.map(results, fn entry -> process_data(entry, options) end)
else
{:ok, %{"status" => "REQUEST_DENIED", "error_message" => error_message}} ->
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
end
end
@@ -63,15 +58,14 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, [], @http_options),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"results" => results, "status" => "OK"} <- body do
results |> Enum.map(fn entry -> process_data(entry, options) end)
else
{:ok, %{"status" => "REQUEST_DENIED", "error_message" => error_message}} ->
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
{:ok, %{"results" => [], "status" => "ZERO_RESULTS"}} ->
%{"results" => [], "status" => "ZERO_RESULTS"} ->
[]
end
end
@@ -165,18 +159,17 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for details with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, [], @http_options),
{:ok, %{"result" => %{"name" => name}, "status" => "OK"}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"result" => %{"name" => name}, "status" => "OK"} <- body do
name
else
{:ok, %{"status" => "REQUEST_DENIED", "error_message" => error_message}} ->
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
{:ok, %{"status" => "INVALID_REQUEST"}} ->
%{"status" => "INVALID_REQUEST"} ->
raise ArgumentError, message: "Invalid Request"
{:ok, %{"results" => [], "status" => "ZERO_RESULTS"}} ->
%{"results" => [], "status" => "ZERO_RESULTS"} ->
nil
end
end

View File

@@ -10,8 +10,8 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -21,11 +21,6 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
@api_key_missing_message "API Key required to use MapQuest"
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
MapQuest implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -35,25 +30,21 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
api_key = Keyword.get(options, :api_key, @api_key)
limit = Keyword.get(options, :limit, 10)
open_data = Keyword.get(options, :open_data, true)
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
prefix = if open_data, do: "open", else: "www"
if is_nil(api_key), do: raise(ArgumentError, message: @api_key_missing_message)
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(
with {:ok, %{status: 200, body: body}} <-
BaseClient.get(
"https://#{prefix}.mapquestapi.com/geocoding/v1/reverse?key=#{api_key}&location=#{
lat
},#{lon}&maxResults=#{limit}",
headers,
@http_options
},#{lon}&maxResults=#{limit}"
),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
%{"results" => results, "info" => %{"statuscode" => 0}} <- body do
results |> Enum.map(&process_data/1)
else
{:ok, %HTTPoison.Response{status_code: 403, body: err}} ->
{:ok, %{status: 403, body: err}} ->
raise(ArgumentError, message: err)
end
end
@@ -64,8 +55,6 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
limit = Keyword.get(options, :limit, 10)
api_key = Keyword.get(options, :api_key, @api_key)
@@ -82,12 +71,11 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
Logger.debug("Asking MapQuest for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"results" => results, "info" => %{"statuscode" => 0}} <- body do
results |> Enum.map(&process_data/1)
else
{:ok, %HTTPoison.Response{status_code: 403, body: err}} ->
{:ok, %{status: 403, body: err}} ->
raise(ArgumentError, message: err)
end
end

View File

@@ -8,8 +8,8 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -17,25 +17,17 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Mimirsbrunn implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(number(), number(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking Mimirsbrunn for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
{:ok, %{"features" => features}} <- Jason.decode(body) do
process_data(features)
else
_ -> []
@@ -48,14 +40,11 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking Mimirsbrunn for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
{:ok, %{"features" => features}} <- Jason.decode(body) do
process_data(features)
else
_ -> []

View File

@@ -4,8 +4,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -14,25 +14,17 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@api_key Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Nominatim implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking Nominatim for geocode with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
features |> process_data() |> Enum.filter(& &1)
else
_ -> []
@@ -45,14 +37,11 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking Nominatim for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
features |> process_data() |> Enum.filter(& &1)
else
_ -> []

View File

@@ -6,8 +6,8 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -15,25 +15,17 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Pelias implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(number(), number(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking Pelias for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
{:ok, %{"features" => features}} <- Jason.decode(body) do
process_data(features)
else
_ -> []
@@ -46,14 +38,11 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking Pelias for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
{:ok, %{"features" => features}} <- Jason.decode(body) do
process_data(features)
else
_ -> []

View File

@@ -4,8 +4,8 @@ defmodule Mobilizon.Service.Geospatial.Photon do
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Config
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.BaseClient
require Logger
@@ -13,11 +13,6 @@ defmodule Mobilizon.Service.Geospatial.Photon do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Photon implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -26,14 +21,11 @@ defmodule Mobilizon.Service.Geospatial.Photon do
"""
@spec geocode(number(), number(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking photon for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
process_data(features)
else
_ -> []
@@ -46,14 +38,11 @@ defmodule Mobilizon.Service.Geospatial.Photon do
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking photon for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
with {:ok, %{status: 200, body: body}} <- BaseClient.get(url),
%{"features" => features} <- body do
process_data(features)
else
_ -> []

View File

@@ -15,7 +15,6 @@ defmodule Mobilizon.Service.Geospatial.Provider do
## Shared options
* `:user_agent` User-Agent string to send to the backend. Defaults to `"Mobilizon"` or `Mobilizon.Config.instance_user_agent/0`
* `:lang` Lang in which to prefer results. Used as a request parameter or
through an `Accept-Language` HTTP header. Defaults to `"en"`.
* `:country_code` An ISO 3166 country code. String or `nil`

View File

@@ -0,0 +1,38 @@
defmodule Mobilizon.Service.HTTP.ActivityPub do
@moduledoc """
Tesla HTTP Client that is preconfigured to get and post ActivityPub content
"""
alias Mobilizon.Config
@adapter Application.get_env(:tesla, __MODULE__, [])[:adapter] || Tesla.Adapter.Hackney
@default_opts [
recv_timeout: 20_000
]
@user_agent Config.instance_user_agent()
def client(options \\ []) do
headers = Keyword.get(options, :headers, [])
opts = Keyword.merge(@default_opts, Keyword.get(options, :opts, []))
middleware = [
{Tesla.Middleware.Headers,
[{"User-Agent", @user_agent}, {"Accept", "application/activity+json"}] ++ headers},
Tesla.Middleware.FollowRedirects,
{Tesla.Middleware.Timeout, timeout: 10_000},
{Tesla.Middleware.JSON, decode_content_types: "application/activity+json"}
]
adapter = {@adapter, opts}
Tesla.client(middleware, adapter)
end
def get(client, url) do
Tesla.get(client, url)
end
def post(client, url, data) do
Tesla.post(client, url, data)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Mobilizon.Service.HTTP.BaseClient do
@moduledoc """
Tesla HTTP Basic Client
"""
use Tesla
alias Mobilizon.Config
@default_opts [
recv_timeout: 20_000
]
adapter(Tesla.Adapter.Hackney, @default_opts)
@user_agent Config.instance_user_agent()
plug(Tesla.Middleware.FollowRedirects)
plug(Tesla.Middleware.Timeout, timeout: 10_000)
plug(Tesla.Middleware.Headers, [{"User-Agent", @user_agent}])
def get(url) do
get(url)
end
def post(url, data) do
post(url, data)
end
end

View File

@@ -1,6 +1,6 @@
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Conversations.Comment do
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Discussions.Comment do
alias Phoenix.HTML.Tag
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
def build_tags(%Comment{} = comment, _locale \\ "en") do
[

View File

@@ -2,10 +2,9 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
alias Phoenix.HTML
alias Phoenix.HTML.Tag
alias Mobilizon.Events.Event
alias Mobilizon.Service.Formatter.HTML, as: HTMLFormatter
alias Mobilizon.Web.JsonLD.ObjectView
alias Mobilizon.Web.MediaProxy
import Mobilizon.Web.Gettext
import Mobilizon.Service.Metadata.Utils, only: [process_description: 2, strip_tags: 1]
def build_tags(%Event{} = event, locale \\ "en") do
event = Map.put(event, :description, process_description(event.description, locale))
@@ -41,24 +40,10 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
]
end
defp process_description(nil, locale), do: process_description("", locale)
defp process_description("", locale) do
Gettext.put_locale(locale)
gettext("The event organizer didn't add any description.")
end
defp process_description(description, _locale) do
description
|> HTMLFormatter.strip_tags()
|> String.slice(0..200)
|> (&"#{&1}").()
end
# Insert JSON-LD schema by hand because Tag.content_tag wants to escape it
defp json(%Event{title: title} = event) do
"event.json"
|> ObjectView.render(%{event: %{event | title: HTMLFormatter.strip_tags(title)}})
|> ObjectView.render(%{event: %{event | title: strip_tags(title)}})
|> Jason.encode!()
end
end

View File

@@ -0,0 +1,34 @@
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Posts.Post do
alias Phoenix.HTML
alias Phoenix.HTML.Tag
alias Mobilizon.Posts.Post
alias Mobilizon.Web.JsonLD.ObjectView
import Mobilizon.Service.Metadata.Utils, only: [process_description: 2, strip_tags: 1]
def build_tags(%Post{} = post, locale \\ "en") do
post = Map.put(post, :body, process_description(post.body, locale))
tags = [
Tag.tag(:meta, property: "og:title", content: post.title),
Tag.tag(:meta, property: "og:url", content: post.url),
Tag.tag(:meta, property: "og:description", content: post.body),
Tag.tag(:meta, property: "og:type", content: "article"),
Tag.tag(:meta, property: "twitter:card", content: "summary"),
# Tell Search Engines what's the origin
Tag.tag(:link, rel: "canonical", href: post.url)
]
tags ++
[
Tag.tag(:meta, property: "twitter:card", content: "summary_large_image"),
~s{<script type="application/ld+json">#{json(post)}</script>} |> HTML.raw()
]
end
# Insert JSON-LD schema by hand because Tag.content_tag wants to escape it
defp json(%Post{title: title} = post) do
"post.json"
|> ObjectView.render(%{post: %{post | title: strip_tags(title)}})
|> Jason.encode!()
end
end

View File

@@ -3,10 +3,34 @@ defmodule Mobilizon.Service.Metadata.Utils do
Tools to convert tags to string.
"""
alias Mobilizon.Service.Formatter.HTML, as: HTMLFormatter
alias Phoenix.HTML
import Mobilizon.Web.Gettext
@slice_limit 200
@spec stringify_tags(Enum.t()) :: String.t()
def stringify_tags(tags), do: Enum.reduce(tags, "", &stringify_tag/2)
defp stringify_tag(tag, acc) when is_tuple(tag), do: acc <> HTML.safe_to_string(tag)
defp stringify_tag(tag, acc) when is_binary(tag), do: acc <> tag
@spec strip_tags(String.t()) :: String.t()
def strip_tags(text), do: HTMLFormatter.strip_tags(text)
@spec process_description(String.t(), String.t(), integer()) :: String.t()
def process_description(description, locale \\ "en", limit \\ @slice_limit)
def process_description(nil, locale, limit), do: process_description("", locale, limit)
def process_description("", locale, _limit) do
Gettext.put_locale(locale)
gettext("The event organizer didn't add any description.")
end
def process_description(description, _locale, limit) do
description
|> HTMLFormatter.strip_tags()
|> String.slice(0..limit)
|> (&"#{&1}").()
end
end

View File

@@ -16,24 +16,24 @@ defmodule Mobilizon.Service.RichMedia.Favicon do
ssl: [{:versions, [:"tlsv1.2"]}]
]
@spec fetch(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
@spec fetch(String.t(), Enum.t()) :: {:ok, String.t()} | {:error, any()}
def fetch(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
case HTTPoison.get(url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code in 200..299 ->
case Tesla.get(url, headers: headers, opts: @options) do
{:ok, %{status: code, body: body}} when code in 200..299 ->
find_favicon_url(url, body, headers)
{:ok, %HTTPoison.Response{}} ->
{:ok, %{}} ->
{:error, "Error while fetching the page"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
{:error, err} ->
{:error, err}
end
end
@spec find_favicon_url(String.t(), String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
@spec find_favicon_url(String.t(), String.t(), Enum.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_url(url, body, headers) do
Logger.debug("finding favicon URL for #{url}")
@@ -85,20 +85,20 @@ defmodule Mobilizon.Service.RichMedia.Favicon do
end
end
@spec find_favicon_in_root(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
@spec find_favicon_in_root(String.t(), Enum.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_in_root(url, headers) do
uri = URI.parse(url)
favicon_url = "#{uri.scheme}://#{uri.host}/favicon.ico"
case HTTPoison.head(favicon_url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code}} when code in 200..299 ->
case Tesla.head(favicon_url, headers: headers, opts: @options) do
{:ok, %{status: code}} when code in 200..299 ->
{:ok, favicon_url}
{:ok, %HTTPoison.Response{}} ->
{:ok, %{}} ->
{:error, "Error while doing a HEAD request on the favicon"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
{:error, err} ->
{:error, err}
end
end
end

View File

@@ -12,7 +12,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do
timeout: 10_000,
recv_timeout: 20_000,
follow_redirect: true,
# TODO: Remove me once Hackney/HTTPoison fixes their shit with TLS1.3 and OTP 23
# TODO: Remove me once Hackney/HTTPoison fixes their issue with TLS1.3 and OTP 23
ssl: [{:versions, [:"tlsv1.2"]}]
]
@@ -46,7 +46,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do
{:error, "Cachex error: #{inspect(e)}"}
end
@spec parse_url(String.t(), List.t()) :: {:ok, map()} | {:error, any()}
@spec parse_url(String.t(), Enum.t()) :: {:ok, map()} | {:error, any()}
defp parse_url(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
@@ -54,12 +54,12 @@ defmodule Mobilizon.Service.RichMedia.Parser do
try do
with {:ok, _} <- prevent_local_address(url),
{:ok, %HTTPoison.Response{body: body, status_code: code, headers: response_headers}}
{:ok, %{body: body, status: code, headers: response_headers}}
when code in 200..299 <-
HTTPoison.get(
Tesla.get(
url,
headers,
@options
headers: headers,
opts: @options
),
{:is_html, _response_headers, true} <-
{:is_html, response_headers, is_html(response_headers)} do
@@ -87,7 +87,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do
end
end
@spec get_data_for_media(List.t(), String.t()) :: map()
@spec get_data_for_media(Enum.t(), String.t()) :: map()
defp get_data_for_media(response_headers, url) do
data = %{title: get_filename_from_headers(response_headers) || get_filename_from_url(url)}
@@ -98,21 +98,21 @@ defmodule Mobilizon.Service.RichMedia.Parser do
end
end
@spec is_html(List.t()) :: boolean
defp is_html(headers) do
@spec is_html(Enum.t()) :: boolean
def is_html(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["text/html", "application/xhtml"])
end
@spec is_image(List.t()) :: boolean
@spec is_image(Enum.t()) :: boolean
defp is_image(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["image/"])
end
@spec content_type_header_matches(String.t() | nil, List.t()) :: boolean
@spec content_type_header_matches(String.t() | nil, Enum.t()) :: boolean
defp content_type_header_matches(header, content_types)
defp content_type_header_matches(nil, _content_types), do: false
@@ -120,15 +120,17 @@ defmodule Mobilizon.Service.RichMedia.Parser do
Enum.any?(content_types, fn content_type -> String.starts_with?(header, content_type) end)
end
@spec get_header(List.t(), String.t()) :: String.t() | nil
@spec get_header(Enum.t(), String.t()) :: String.t() | nil
defp get_header(headers, key) do
key = String.downcase(key)
case List.keyfind(headers, key, 0) do
{^key, value} -> String.downcase(value)
nil -> nil
end
end
@spec get_filename_from_headers(List.t()) :: String.t() | nil
@spec get_filename_from_headers(Enum.t()) :: String.t() | nil
defp get_filename_from_headers(headers) do
case get_header(headers, "Content-Disposition") do
nil -> nil
@@ -138,12 +140,16 @@ defmodule Mobilizon.Service.RichMedia.Parser do
@spec get_filename_from_url(String.t()) :: String.t()
defp get_filename_from_url(url) do
%URI{path: path} = URI.parse(url)
case URI.parse(url) do
%URI{path: nil} ->
nil
path
|> String.split("/", trim: true)
|> Enum.at(-1)
|> URI.decode()
%URI{path: path} ->
path
|> String.split("/", trim: true)
|> Enum.at(-1)
|> URI.decode()
end
end
# The following is taken from https://github.com/elixir-plug/plug/blob/65986ad32f9aaae3be50dc80cbdd19b326578da7/lib/plug/parsers/multipart.ex#L207

View File

@@ -42,7 +42,7 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do
end
defp get_oembed_data(url) do
with {:ok, %HTTPoison.Response{body: json}} <- HTTPoison.get(url, [], @http_options),
with {:ok, %{body: json}} <- Tesla.get(url, opts: @http_options),
{:ok, data} <- Jason.decode(json),
data <- data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) do
{:ok, data}

View File

@@ -34,7 +34,7 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OGP do
|> Map.put(:height, get_integer_value(data, :"image:height"))
end
@spec get_integer_value(Map.t(), atom()) :: integer() | nil
@spec get_integer_value(map(), atom()) :: integer() | nil
defp get_integer_value(data, key) do
with value when not is_nil(value) <- Map.get(data, key),
{value, ""} <- Integer.parse(value) do

View File

@@ -3,7 +3,7 @@ defmodule Mobilizon.Service.Statistics do
A module that provides cached statistics
"""
alias Mobilizon.{Conversations, Events, Users}
alias Mobilizon.{Discussions, Events, Users}
def get_cached_value(key) do
case Cachex.fetch(:statistics, key, fn key ->
@@ -26,6 +26,6 @@ defmodule Mobilizon.Service.Statistics do
end
defp create_cache(:local_comments) do
Conversations.count_local_comments()
Discussions.count_local_comments()
end
end

View File

@@ -98,7 +98,7 @@ defmodule Mobilizon.Service.Workers.Notification do
else
err ->
require Logger
Logger.error(inspect(err))
Logger.debug(inspect(err))
end
end

View File

@@ -3,11 +3,12 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
ActivityPub related cache.
"""
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos, Tombstone}
alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos, Tombstone}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Web.Endpoint
@@ -61,7 +62,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
{:commit, Comment.t()} | {:ignore, nil}
def get_comment_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "comment_" <> uuid, fn "comment_" <> uuid ->
case Conversations.get_comment_from_uuid_with_preload(uuid) do
case Discussions.get_comment_from_uuid_with_preload(uuid) do
%Comment{} = comment ->
{:commit, comment}
@@ -88,6 +89,40 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
end)
end
@doc """
Gets a post by its slug, with all associations loaded.
"""
@spec get_post_by_slug_with_preload(String.t()) ::
{:commit, Post.t()} | {:ignore, nil}
def get_post_by_slug_with_preload(slug) do
Cachex.fetch(@cache, "post_" <> slug, fn "post_" <> slug ->
case Posts.get_post_by_slug_with_preloads(slug) do
%Post{} = post ->
{:commit, post}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a discussion by its slug, with all associations loaded.
"""
@spec get_discussion_by_slug_with_preload(String.t()) ::
{:commit, Discussion.t()} | {:ignore, nil}
def get_discussion_by_slug_with_preload(slug) do
Cachex.fetch(@cache, "discussion_" <> slug, fn "discussion_" <> slug ->
case Discussions.get_discussion_by_slug(slug) do
%Discussion{} = discussion ->
{:commit, discussion}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a todo list by its UUID, with all associations loaded.
"""

View File

@@ -23,5 +23,7 @@ defmodule Mobilizon.Web.Cache do
defdelegate get_resource_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_post_by_slug_with_preload(slug), to: ActivityPub
defdelegate get_discussion_by_slug_with_preload(slug), to: ActivityPub
defdelegate get_relay, to: ActivityPub
end

Some files were not shown because too many files have changed in this diff Show More