Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-10-17 16:41:31 +02:00
parent 0613f7f736
commit b5672cee7e
108 changed files with 5221 additions and 1318 deletions

View File

@@ -14,7 +14,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
]
@type create_entities ::
:event | :comment | :discussion | :actor | :todo_list | :todo | :resource | :post
:event
| :comment
| :discussion
| :conversation
| :actor
| :todo_list
| :todo
| :resource
| :post
@doc """
Create an activity of type `Create`
@@ -50,18 +58,27 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
end
end
@map_types %{
:event => Types.Events,
:comment => Types.Comments,
:discussion => Types.Discussions,
:conversation => Types.Conversations,
:actor => Types.Actors,
:todo_list => Types.TodoLists,
:todo => Types.Todos,
:resource => Types.Resources,
:post => Types.Posts
}
@spec do_create(create_entities(), map(), map()) ::
{:ok, Entity.t(), Activity.t()} | {:error, Ecto.Changeset.t() | atom()}
defp do_create(type, args, additional) do
case type do
: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)
mod = Map.get(@map_types, type)
if is_nil(mod) do
{:error, :type_not_supported}
else
mod.create(args, additional)
end
end

View File

@@ -5,6 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.{Actors, Discussions, Events, Share}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Federation.ActivityPub.Types.Entity
@@ -38,6 +39,10 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
%{"to" => maybe_add_group_members([], actor), "cc" => []}
end
def get_audience(%Conversation{participants: participants}) do
%{"to" => Enum.map(participants, & &1.url), "cc" => []}
end
# Deleted comments are just like tombstones
def get_audience(%Comment{deleted_at: deleted_at}) when not is_nil(deleted_at) do
%{"to" => [@ap_public], "cc" => []}

View File

@@ -177,7 +177,7 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:error, :content_not_json}
{:ok, %Tesla.Env{} = res} ->
Logger.debug("Resource returned bad HTTP code #{inspect(res)}")
Logger.debug("Resource returned bad HTTP code (#{res.status}) #{inspect(res)}")
{:error, :http_error}
{:error, err} ->

View File

@@ -68,24 +68,26 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes")
case Converter.Comment.as_to_model_data(object) do
%{visibility: visibility, event_id: event_id}
when visibility != :public and event_id != nil ->
Logger.info("Tried to reply to an event with a private comment - ignore")
:error
case Discussions.get_comment_from_url_with_preload(object["id"]) do
{:error, :comment_not_found} ->
case Converter.Comment.as_to_model_data(object) do
%{visibility: visibility} = object_data
when visibility === :private ->
Actions.Create.create(:conversation, object_data, false)
object_data when is_map(object_data) ->
case Discussions.get_comment_from_url_with_preload(object_data.url) do
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
object_data when is_map(object_data) ->
case Discussions.get_comment_from_url_with_preload(object_data.url) do
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
end
end
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
{:error, err} ->
{:error, err}
end

View File

@@ -0,0 +1,207 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Conversations do
@moduledoc false
# alias Mobilizon.Conversations.ConversationParticipant
alias Mobilizon.{Actors, Conversations, Discussions}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityPub.{Audience, Permission}
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Activity.Conversation, as: ConversationActivity
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, Conversation.t(), ActivityStream.t()}
| {:error, :conversation_not_found | :last_comment_not_found | Ecto.Changeset.t()}
def create(%{conversation_id: conversation_id} = args, additional)
when not is_nil(conversation_id) do
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
args = prepare_args(args)
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
case Conversations.get_conversation(conversation_id) do
%Conversation{} = conversation ->
case Conversations.reply_to_conversation(conversation, args) do
{:ok, %Conversation{last_comment_id: last_comment_id} = conversation} ->
ConversationActivity.insert_activity(conversation, subject: "conversation_replied")
maybe_publish_graphql_subscription(conversation)
case Discussions.get_comment_with_preload(last_comment_id) do
%Comment{} = last_comment ->
comment_as_data = Convertible.model_to_as(last_comment)
audience = Audience.get_audience(conversation)
create_data = make_create_data(comment_as_data, Map.merge(audience, additional))
{:ok, conversation, create_data}
nil ->
{:error, :last_comment_not_found}
end
{:error, _, %Ecto.Changeset{} = err, _} ->
{:error, err}
end
nil ->
{:error, :discussion_not_found}
end
end
@impl Entity
def create(args, additional) do
with args when is_map(args) <- prepare_args(args) do
case Conversations.create_conversation(args) do
{:ok, %Conversation{} = conversation} ->
ConversationActivity.insert_activity(conversation, subject: "conversation_created")
conversation_as_data = Convertible.model_to_as(conversation)
audience = Audience.get_audience(conversation)
create_data = make_create_data(conversation_as_data, Map.merge(audience, additional))
{:ok, conversation, create_data}
{:error, _, %Ecto.Changeset{} = err, _} ->
{:error, err}
end
end
end
@impl Entity
@spec update(Conversation.t(), map(), map()) ::
{:ok, Conversation.t(), ActivityStream.t()} | {:error, Ecto.Changeset.t()}
def update(%Conversation{} = old_conversation, args, additional) do
case Conversations.update_conversation(old_conversation, args) do
{:ok, %Conversation{} = new_conversation} ->
# ConversationActivity.insert_activity(new_conversation,
# subject: "conversation_renamed",
# old_conversation: old_conversation
# )
conversation_as_data = Convertible.model_to_as(new_conversation)
audience = Audience.get_audience(new_conversation)
update_data = make_update_data(conversation_as_data, Map.merge(audience, additional))
{:ok, new_conversation, update_data}
{:error, %Ecto.Changeset{} = err} ->
{:error, err}
end
end
@impl Entity
@spec delete(Conversation.t(), Actor.t(), boolean, map()) ::
{:error, Ecto.Changeset.t()} | {:ok, ActivityStream.t(), Actor.t(), Conversation.t()}
def delete(
%Conversation{} = _conversation,
%Actor{} = _actor,
_local,
_additionnal
) do
{:error, :not_applicable}
end
# @spec actor(Conversation.t()) :: Actor.t() | nil
# def actor(%ConversationParticipant{actor_id: actor_id}), do: Actors.get_actor(actor_id)
# @spec group_actor(Conversation.t()) :: Actor.t() | nil
# def group_actor(%Conversation{actor_id: actor_id}), do: Actors.get_actor(actor_id)
@spec permissions(Conversation.t()) :: Permission.t()
def permissions(%Conversation{}) do
%Permission{access: :member, create: :member, update: :moderator, delete: :moderator}
end
@spec maybe_publish_graphql_subscription(Conversation.t()) :: :ok
defp maybe_publish_graphql_subscription(%Conversation{} = conversation) do
Absinthe.Subscription.publish(Endpoint, conversation,
conversation_comment_changed: conversation.id
)
:ok
end
@spec prepare_args(map) :: map | {:error, :empty_participants}
defp prepare_args(args) do
{text, mentions, _tags} =
APIUtils.make_content_html(
args |> Map.get(:text, "") |> String.trim(),
# Can't put additional tags on a comment
[],
"text/html"
)
mentions =
(args |> Map.get(:mentions, []) |> prepare_mentions()) ++
ConverterUtils.fetch_mentions(mentions)
if Enum.empty?(mentions) do
{:error, :empty_participants}
else
event = Map.get(args, :event, get_event(Map.get(args, :event_id)))
participants =
(mentions ++
[
%{actor_id: args.actor_id},
%{
actor_id:
if(is_nil(event),
do: nil,
else: event.attributed_to_id || event.organizer_actor_id
)
}
])
|> Enum.reduce(
[],
fn %{actor_id: actor_id}, acc ->
case Actors.get_actor(actor_id) do
nil -> acc
actor -> acc ++ [actor]
end
end
)
|> Enum.uniq_by(& &1.id)
args
|> Map.put(:text, text)
|> Map.put(:mentions, mentions)
|> Map.put(:participants, participants)
end
end
@spec prepare_mentions(list(String.t())) :: list(%{actor_id: String.t()})
defp prepare_mentions(mentions) do
Enum.reduce(mentions, [], &prepare_mention/2)
end
@spec prepare_mention(String.t() | map(), list()) :: list(%{actor_id: String.t()})
defp prepare_mention(%{actor_id: _} = mention, mentions) do
mentions ++ [mention]
end
defp prepare_mention(mention, mentions) do
case ActivityPubActor.find_or_make_actor_from_nickname(mention) do
{:ok, %Actor{id: actor_id}} ->
mentions ++ [%{actor_id: actor_id}]
{:error, _} ->
mentions
end
end
defp get_event(nil), do: nil
defp get_event(event_id) do
case Mobilizon.Events.get_event(event_id) do
{:ok, event} -> event
_ -> nil
end
end
end

View File

@@ -22,6 +22,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@actor_types ["Group", "Person", "Application"]
@all_actor_types @actor_types ++ ["Organization", "Service"]
@ap_public_audience "https://www.w3.org/ns/activitystreams#Public"
# Wraps an object into an activity
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
@@ -491,8 +492,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
if public do
Logger.debug("Making announce data for a public object")
{[actor.followers_url, object_actor_url],
["https://www.w3.org/ns/activitystreams#Public"]}
{[actor.followers_url, object_actor_url], [@ap_public_audience]}
else
Logger.debug("Making announce data for a private object")
@@ -539,7 +539,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"actor" => url,
"object" => activity,
"to" => [actor.followers_url, actor.url],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"]
"cc" => [@ap_public_audience]
}
if activity_id, do: Map.put(data, "id", activity_id), else: data

View File

@@ -47,9 +47,6 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
case maybe_fetch_actor_and_attributed_to_id(object) do
{:ok, %Actor{id: actor_id, domain: actor_domain}, attributed_to} ->
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
data = %{
text: object["content"],
url: object["id"],
@@ -70,14 +67,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
is_announcement: Map.get(object, "isAnnouncement", false)
}
Logger.debug("Converted object before fetching parents")
Logger.debug(inspect(data))
data = maybe_fetch_parent_object(object, data)
Logger.debug("Converted object after fetching parents")
Logger.debug(inspect(data))
data
maybe_fetch_parent_object(object, data)
{:error, err} ->
{:error, err}
@@ -147,19 +137,22 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
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]
defp determine_to(%CommentModel{visibility: :private, mentions: mentions} = _comment) do
Enum.map(mentions, fn mention -> mention.actor.url end)
end
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
defp determine_to(%CommentModel{visibility: :public} = comment) do
if is_nil(comment.attributed_to) do
["https://www.w3.org/ns/activitystreams#Public"]
else
[comment.attributed_to.url]
end
end
defp determine_to(%CommentModel{} = comment) do
[comment.actor.followers_url]
end
defp maybe_fetch_parent_object(object, data) do
# We fetch the parent object
Logger.debug("We're fetching the parent object")
@@ -170,9 +163,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
{:ok, %Event{id: id} = event} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
data
|> Map.put(:event_id, id)
|> Map.put(:event, event)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
@@ -182,6 +178,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|> 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)
|> Map.put(:conversation_id, comment.conversation_id)
# Reply to a discucssion (Discussion)
{:ok,

View File

@@ -0,0 +1,68 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Conversation do
@moduledoc """
Comment converter.
This module allows to convert conversations from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Conversation, as: ConversationConverter
alias Mobilizon.Storage.Repo
import Mobilizon.Service.Guards, only: [is_valid_string: 1]
require Logger
@behaviour Converter
defimpl Convertible, for: Conversation do
defdelegate model_to_as(comment), to: ConversationConverter
end
@doc """
Make an AS comment object from an existing `conversation` structure.
"""
@impl Converter
@spec model_to_as(Conversation.t()) :: map
def model_to_as(%Conversation{} = conversation) do
conversation = Repo.preload(conversation, [:participants, last_comment: [:actor]])
%{
"type" => "Note",
"to" => Enum.map(conversation.participants, & &1.url),
"cc" => [],
"content" => conversation.last_comment.text,
"mediaType" => "text/html",
"actor" => conversation.last_comment.actor.url,
"id" => conversation.last_comment.url,
"publishedAt" => conversation.inserted_at
}
end
@impl Converter
@spec as_to_model_data(map) :: map() | {:error, atom()}
def as_to_model_data(%{"type" => "Note", "name" => name} = object) when is_valid_string(name) do
with %{actor_id: actor_id, creator_id: creator_id} <- extract_actors(object) do
%{actor_id: actor_id, creator_id: creator_id, title: name, url: object["id"]}
end
end
@spec extract_actors(map()) ::
%{actor_id: String.t(), creator_id: String.t()} | {:error, atom()}
defp extract_actors(%{"actor" => creator_url, "attributedTo" => actor_url} = _object)
when is_valid_string(creator_url) and is_valid_string(actor_url) do
with {:ok, %Actor{id: creator_id, suspended: false}} <-
ActivityPubActor.get_or_fetch_actor_by_url(creator_url),
{:ok, %Actor{id: actor_id, suspended: false}} <-
ActivityPubActor.get_or_fetch_actor_by_url(actor_url) do
%{actor_id: actor_id, creator_id: creator_id}
else
{:error, error} -> {:error, error}
{:ok, %Actor{url: ^creator_url}} -> {:error, :creator_suspended}
{:ok, %Actor{url: ^actor_url}} -> {:error, :actor_suspended}
end
end
end

View File

@@ -242,12 +242,15 @@ defmodule Mobilizon.Federation.WebFinger do
@spec domain_from_federated_actor(String.t()) :: {:ok, String.t()} | {:error, :host_not_found}
defp domain_from_federated_actor(actor) do
case String.split(actor, "@") do
[_name, ""] ->
{:error, :host_not_found}
[_name, domain] ->
{:ok, domain}
_e ->
host = URI.parse(actor).host
if is_nil(host), do: {:error, :host_not_found}, else: {:ok, host}
if is_nil(host) or host == "", do: {:error, :host_not_found}, else: {:ok, host}
end
end