Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85827980
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
66 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
index 1aaf4cb15..31b9474b0 100644
--- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex
@@ -1,203 +1,232 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.Activity.Queries
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
import Ecto.Changeset
import Ecto.Query
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
quote do
unquote do
import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields
message_fields()
activity_fields()
end
end
field(:deleted_activity_id, ObjectValidators.ObjectID)
end
def cast_data(data) do
%__MODULE__{}
|> cast(data, __schema__(:fields))
end
def add_deleted_activity_id(cng) do
object =
cng
|> get_field(:object)
case get_create_by_object_ap_id(object) do
%Activity{id: id} -> put_change(cng, :deleted_activity_id, id)
_ -> cng
end
end
@deletable_object_types ~w{
Answer
Article
Audio
ChatMessage
Event
Note
Page
Question
Video
}
defp validate_data(cng) do
cng
|> validate_required([:id, :type, :actor, :to, :cc, :object])
|> validate_inclusion(:type, ["Delete"])
|> validate_delete_actor(:actor)
|> validate_modification_rights(:messages_delete)
|> validate_delete_target()
|> add_deleted_activity_id()
end
def do_not_federate?(cng) do
!same_domain?(cng)
end
def cast_and_validate(data) do
data
|> cast_data
|> validate_data
end
def classify_target(target, options \\ [])
def classify_target(%{"object" => object_id}, options), do: classify_target(object_id, options)
def classify_target(%{"id" => object_id}, options), do: classify_target(object_id, options)
def classify_target(object_id, options) when is_binary(object_id) do
case User.get_cached_by_ap_id(object_id) do
%User{} = user ->
classify_user_target(object_id, user, options)
_ ->
classify_object_target(object_id, options)
end
end
def classify_target(object_id, _options), do: %{state: :missing, object_id: object_id}
defp classify_user_target(object_id, user, options) do
case get_latest_delete_by_object_ap_id(object_id, options[:ignore_activity_id]) do
+ %Activity{} = existing_delete when user.is_active ->
+ %{
+ state: :user,
+ object_id: object_id,
+ user: user,
+ existing_delete: existing_delete
+ }
+
%Activity{} = existing_delete ->
duplicate_target(object_id, existing_delete)
_ ->
%{state: :user, object_id: object_id, user: user}
end
end
defp classify_object_target(object_id, options) do
case Object.get_by_ap_id(object_id) do
%Object{data: %{"type" => "Tombstone"}} = object ->
- case get_latest_delete_by_object_ap_id(object_id, options[:ignore_activity_id]) do
- %Activity{} = existing_delete ->
+ create_activity = get_create_by_object_ap_id(object_id)
+
+ case {get_latest_delete_by_object_ap_id(object_id, options[:ignore_activity_id]),
+ create_activity} do
+ {%Activity{} = existing_delete, %Activity{}} ->
+ %{
+ state: :live_object,
+ object_id: object_id,
+ object: object,
+ create_activity: create_activity,
+ existing_delete: existing_delete
+ }
+
+ {%Activity{} = existing_delete, _} ->
duplicate_target(object_id, existing_delete, object)
_ ->
%{
state: :live_object,
object_id: object_id,
object: object,
- create_activity: get_create_by_object_ap_id(object_id)
+ create_activity: create_activity
}
end
%Object{data: %{"type" => type}} = object when type in @deletable_object_types ->
%{state: :live_object, object_id: object_id, object: object}
+ |> maybe_put_existing_delete(object_id, options)
%Object{} = object ->
%{state: :invalid_type, object_id: object_id, object: object}
nil ->
case get_create_by_object_ap_id(object_id) do
%Activity{} = create_activity ->
%{
state: :pruned_object_with_create,
object_id: object_id,
create_activity: create_activity
}
+ |> maybe_put_existing_delete(object_id, options)
_ ->
%{state: :missing, object_id: object_id}
end
end
end
defp duplicate_target(object_id, existing_delete, object \\ nil) do
%{
state: :tombstone_duplicate,
object_id: object_id,
object: object,
existing_delete: existing_delete
}
end
+ defp maybe_put_existing_delete(target, object_id, options) do
+ case get_latest_delete_by_object_ap_id(object_id, options[:ignore_activity_id]) do
+ %Activity{} = existing_delete -> Map.put(target, :existing_delete, existing_delete)
+ _ -> target
+ end
+ end
+
defp get_latest_delete_by_object_ap_id(object_id, ignored_activity_id) do
query =
Activity
|> Queries.by_object_id(object_id)
|> Queries.by_type("Delete")
query =
if ignored_activity_id do
where(query, [activity], activity.id != ^ignored_activity_id)
else
query
end
query
|> order_by([activity], desc: activity.id)
|> limit(1)
|> Repo.one()
end
@doc false
def get_create_by_object_ap_id(object_id) when is_binary(object_id) do
Activity
|> Queries.by_object_id(object_id)
|> Queries.by_type("Create")
|> order_by([activity], desc: activity.id)
|> limit(1)
|> Repo.one()
end
def get_create_by_object_ap_id(_), do: nil
defp validate_delete_target(cng) do
validate_change(cng, :object, fn field_name, object_id ->
case classify_target(object_id) do
%{state: :missing} -> [{field_name, "can't find object"}]
%{state: :invalid_type} -> [{field_name, "object not in allowed types"}]
_ -> []
end
end)
end
defp validate_delete_actor(cng, field_name) do
validate_change(cng, field_name, fn field_name, actor ->
case User.get_cached_by_ap_id(actor) do
%User{} -> []
_ -> [{field_name, "can't find user"}]
end
end)
end
end
diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex
index d794eb3d9..b2ff04ed6 100644
--- a/lib/pleroma/web/activity_pub/pipeline.ex
+++ b/lib/pleroma/web/activity_pub/pipeline.ex
@@ -1,257 +1,309 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Pipeline do
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Utils
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
alias Pleroma.Web.ActivityPub.SideEffects
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Federator
defp side_effects, do: Config.get([:pipeline, :side_effects], SideEffects)
defp federator, do: Config.get([:pipeline, :federator], Federator)
defp object_validator, do: Config.get([:pipeline, :object_validator], ObjectValidator)
defp mrf, do: Config.get([:pipeline, :mrf], MRF)
defp activity_pub, do: Config.get([:pipeline, :activity_pub], ActivityPub)
defp config, do: Config.get([:pipeline, :config], Config)
@type results :: {:ok, Activity.t() | Object.t(), keyword()}
@type errors :: {:error | :reject, any()}
# Delete errors must roll back persistence and database-backed side effects.
@spec common_pipeline(map(), keyword()) :: results() | errors()
def common_pipeline(object, meta) do
transaction = fn ->
with :ok <- lock_delete_target(object) do
object
|> rollback_delete_error(do_common_pipeline(object, meta))
else
{:error, _} = error -> Repo.rollback(error)
end
end
case run_transaction(object, transaction) do
{:ok, {:ok, activity, meta}} ->
+ invalidate_delete_caches_after_commit(meta)
side_effects().handle_after_transaction(meta)
{:ok, activity, meta}
{:ok, {:error, _} = error} ->
return_error(object, error)
{:ok, {:reject, _} = error} ->
return_error(object, error)
{:error, {:error, _} = error} ->
return_error(object, error)
{:error, {:reject, _} = error} ->
return_error(object, error)
{:error, e} ->
invalidate_delete_caches(object)
{:error, e}
end
end
defp run_transaction(object, transaction) do
try do
Repo.transaction(transaction, Utils.query_timeout())
rescue
exception ->
invalidate_delete_caches(object)
reraise exception, __STACKTRACE__
catch
kind, reason ->
invalidate_delete_caches(object)
:erlang.raise(kind, reason, __STACKTRACE__)
end
end
defp rollback_delete_error(%{"type" => "Delete"}, {:error, _} = error),
do: Repo.rollback(error)
defp rollback_delete_error(%{"type" => "Delete"}, {:reject, _} = error),
do: Repo.rollback(error)
defp rollback_delete_error(_object, result), do: result
def do_common_pipeline(%{__struct__: _}, _meta), do: {:error, :is_struct}
def do_common_pipeline(message, meta) do
with {_, {:ok, validated_message, meta}} <-
{:validate, object_validator().validate(message, meta)},
message <- validated_message,
{_, {:ok, message, meta}} <- {:mrf, mrf().pipeline_filter(message, meta)},
{_, :ok} <-
{:delete_identity, ensure_delete_identity(validated_message, message)},
- {_, :continue} <-
- {:idempotent_delete, maybe_skip_idempotent_delete(message, meta)},
- {_, {:ok, message, meta}} <- {:persist, activity_pub().persist(message, meta)},
- {_, {:ok, message, meta}} <- {:side_effects, side_effects().handle(message, meta)},
- {_, {:ok, _}} <- {:federation, maybe_federate(message, meta)} do
+ {_, {:ok, message, meta, delete_mode}} <-
+ {:idempotent_delete, prepare_idempotent_delete(message, meta)},
+ {_, {:ok, message, meta}} <-
+ {:persist, maybe_persist(message, meta, delete_mode)},
+ {_, {:ok, message, meta}} <-
+ {:side_effects, maybe_handle_side_effects(message, meta, delete_mode)},
+ {_, {:ok, _}} <- {:federation, maybe_federate(message, meta, delete_mode)} do
{:ok, message, meta}
else
{:mrf, {:reject, message, _}} -> {:reject, message}
{:delete_identity, {:error, reason}} -> {:error, reason}
- {:idempotent_delete, {:ok, activity, meta}} -> {:ok, activity, meta}
{:idempotent_delete, {:error, reason}} -> {:error, reason}
e -> {:error, e}
end
end
- defp maybe_skip_idempotent_delete(message, meta) do
+ defp prepare_idempotent_delete(message, meta) do
case Keyword.get(meta, :delete_target) do
%{state: :tombstone_duplicate, existing_delete: %Activity{} = activity} ->
if activity.data["id"] == message["id"] and activity.data["actor"] == message["actor"] do
- {:ok, activity, meta}
+ {:ok, activity, meta, :skip}
+ else
+ {:error, :already_deleted}
+ end
+
+ %{existing_delete: %Activity{} = activity} ->
+ if activity.data["id"] == message["id"] and activity.data["actor"] == message["actor"] do
+ {:ok, activity, meta, :resume}
else
{:error, :already_deleted}
end
_ ->
- :continue
+ {:ok, message, meta, :new}
end
end
+ defp maybe_persist(message, meta, :new), do: activity_pub().persist(message, meta)
+ defp maybe_persist(%Activity{} = activity, meta, _mode), do: {:ok, activity, meta}
+
+ defp maybe_handle_side_effects(message, meta, :skip), do: {:ok, message, meta}
+ defp maybe_handle_side_effects(message, meta, _mode), do: side_effects().handle(message, meta)
+
defp lock_delete_target(%{"type" => "Delete", "object" => object}) do
case object_id(object) do
object_id when is_binary(object_id) ->
case Repo.query(
"SELECT pg_advisory_xact_lock(hashtext('pleroma:delete'), hashtext($1))",
[object_id]
) do
{:ok, _result} -> :ok
{:error, reason} -> {:error, reason}
end
_ ->
:ok
end
end
defp lock_delete_target(_), do: :ok
defp ensure_delete_identity(
%{"type" => "Delete", "id" => id, "actor" => actor, "object" => object},
%{
"type" => "Delete",
"id" => id,
"actor" => actor,
"object" => filtered_object
}
) do
if object_id(object) == object_id(filtered_object) do
:ok
else
{:error, :delete_identity_changed}
end
end
defp ensure_delete_identity(%{"type" => "Delete"}, _),
do: {:error, :delete_identity_changed}
defp ensure_delete_identity(_, %{"type" => "Delete"}),
do: {:error, :delete_identity_changed}
defp ensure_delete_identity(_, _), do: :ok
defp object_id(%{"id" => object_id}), do: object_id
defp object_id(object_id) when is_binary(object_id), do: object_id
defp object_id(_), do: nil
defp return_error(object, {:error, {stage, _reason}} = error)
when stage in [:side_effects, :federation] do
invalidate_delete_caches(object)
error
end
defp return_error(_object, error), do: error
defp invalidate_delete_caches(%{"type" => "Delete", "object" => object}) do
object
|> object_id()
|> invalidate_delete_target_caches()
end
defp invalidate_delete_caches(_), do: :ok
+ defp invalidate_delete_caches_after_commit(meta) do
+ case Keyword.get(meta, :delete_target) do
+ %{
+ state: :live_object,
+ object: %Object{} = object,
+ create_activity: %Activity{data: %{"actor" => actor}}
+ } ->
+ invalidate_deleted_object_caches(object, actor)
+
+ %{state: :live_object, object: %Object{} = object} ->
+ invalidate_deleted_object_caches(object, object.data["actor"])
+
+ %{
+ state: :pruned_object_with_create,
+ create_activity: %Activity{data: %{"actor" => actor}}
+ } ->
+ invalidate_user_cache(actor)
+
+ %{state: :user, user: %User{} = user} ->
+ User.invalidate_cache(user)
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp invalidate_deleted_object_caches(object, actor) do
+ Object.invalid_object_cache(object)
+ invalidate_target_actor_cache(object.data["id"], actor)
+ invalidate_object_cache(object.data["inReplyTo"])
+ invalidate_object_cache(object.data["quoteUrl"])
+ end
+
defp invalidate_delete_target_caches(object_id) when is_binary(object_id) do
case Object.get_by_ap_id(object_id) do
%Object{} = object ->
Object.invalid_object_cache(object)
invalidate_target_actor_cache(object_id, object.data["actor"])
invalidate_object_cache(object.data["inReplyTo"])
invalidate_object_cache(object.data["quoteUrl"])
nil ->
invalidate_user_cache(object_id)
invalidate_create_actor_cache(object_id)
end
end
defp invalidate_delete_target_caches(_), do: :ok
defp invalidate_user_cache(ap_id) when is_binary(ap_id) do
case User.get_by_ap_id(ap_id) do
%User{} = user -> User.invalidate_cache(user)
_ -> :ok
end
end
defp invalidate_user_cache(_), do: :ok
defp invalidate_target_actor_cache(_object_id, actor) when is_binary(actor),
do: invalidate_user_cache(actor)
defp invalidate_target_actor_cache(object_id, _actor),
do: invalidate_create_actor_cache(object_id)
defp invalidate_create_actor_cache(object_id) do
case DeleteValidator.get_create_by_object_ap_id(object_id) do
%Activity{data: %{"actor" => actor}} -> invalidate_user_cache(actor)
_ -> :ok
end
end
defp invalidate_object_cache(ap_id) when is_binary(ap_id) do
case Object.get_by_ap_id(ap_id) do
%Object{} = object -> Object.invalid_object_cache(object)
_ -> :ok
end
end
defp invalidate_object_cache(_), do: :ok
+ defp maybe_federate(_message, _meta, :skip), do: {:ok, :not_federated}
+
+ defp maybe_federate(message, meta, _mode), do: maybe_federate(message, meta)
+
defp maybe_federate(%Object{}, _), do: {:ok, :not_federated}
defp maybe_federate(%Activity{} = activity, meta) do
with {:ok, local} <- Keyword.fetch(meta, :local) do
do_not_federate = meta[:do_not_federate] || !config().get([:instance, :federating])
if !do_not_federate and local and not Visibility.local_public?(activity) do
activity =
if object = Keyword.get(meta, :object_data) do
%{activity | data: Map.put(activity.data, "object", object)}
else
activity
end
federator().publish(activity)
{:ok, :federated}
else
{:ok, :not_federated}
end
else
_e -> {:error, :badarg}
end
end
end
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
index a3ecc806a..7cee78982 100644
--- a/lib/pleroma/web/activity_pub/transmogrifier.ex
+++ b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -1,1190 +1,1191 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier do
@moduledoc """
A module to handle coding from internal to wire ActivityPub and back.
"""
@behaviour Pleroma.Web.ActivityPub.Transmogrifier.API
alias Pleroma.Activity
+ alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Emoji
alias Pleroma.Maps
alias Pleroma.Object
alias Pleroma.Object.Containment
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Federator
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
require Pleroma.Constants
@supported_types Pleroma.Constants.activity_types() ++
Pleroma.Constants.object_types() ++ Pleroma.Constants.actor_types()
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
def fix_object(object, options \\ []) do
object
|> strip_internal_fields()
|> fix_actor()
|> fix_url()
|> fix_attachments()
|> fix_context()
|> fix_in_reply_to(options)
|> fix_emoji()
|> fix_tag()
|> fix_content_map()
|> fix_addressing()
|> fix_summary()
|> fix_history(&fix_object/1)
end
defp maybe_fix_object(%{"attributedTo" => _} = object), do: fix_object(object)
defp maybe_fix_object(object), do: object
defp fix_history(%{"formerRepresentations" => %{"orderedItems" => list}} = obj, fix_fun)
when is_list(list) do
update_in(obj["formerRepresentations"]["orderedItems"], fn h -> Enum.map(h, fix_fun) end)
end
defp fix_history(obj, _), do: obj
defp fix_recursive(obj, fun) do
# unlike Erlang, Elixir does not support recursive inline functions
# which would allow us to avoid reconstructing this on every recursion
rec_fun = fn
obj when is_map(obj) -> fix_recursive(obj, fun)
# there may be simple AP IDs in history (or object field)
obj -> obj
end
obj
|> fun.()
|> fix_history(rec_fun)
|> then(fn
%{"object" => object} = doc when is_map(object) ->
update_in(doc["object"], rec_fun)
apdoc ->
apdoc
end)
end
def fix_summary(%{"summary" => nil} = object) do
Map.put(object, "summary", "")
end
def fix_summary(%{"summary" => _} = object) do
# summary is present, nothing to do
object
end
def fix_summary(object), do: Map.put(object, "summary", "")
def fix_addressing_list(map, field) do
addrs = map[field]
cond do
is_list(addrs) ->
Map.put(map, field, Enum.filter(addrs, &is_binary/1))
is_binary(addrs) ->
Map.put(map, field, [addrs])
true ->
Map.put(map, field, [])
end
end
@doc """
Bovine compatibility
https://codeberg.org/bovine/bovine/issues/53
"""
def fix_addressing_public(map, field) do
addrs = Map.get(map, field, []) |> List.wrap()
Map.put(
map,
field,
Enum.map(addrs, fn
"Public" -> Pleroma.Constants.as_public()
"as:Public" -> Pleroma.Constants.as_public()
x -> x
end)
)
end
# if directMessage flag is set to true, leave the addressing alone
def fix_explicit_addressing(%{"directMessage" => true} = object, _follower_collection),
do: object
def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, follower_collection) do
explicit_mentions =
Utils.determine_explicit_mentions(object) ++
[Pleroma.Constants.as_public(), follower_collection]
explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end)
explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end)
final_cc =
(cc ++ explicit_cc)
|> Enum.filter(& &1)
|> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
|> Enum.uniq()
object
|> Map.put("to", explicit_to)
|> Map.put("cc", final_cc)
end
# if as:Public is addressed, then make sure the followers collection is also addressed
# so that the activities will be delivered to local users.
def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
recipients = to ++ cc
if followers_collection not in recipients do
cond do
Pleroma.Constants.as_public() in cc ->
to = to ++ [followers_collection]
Map.put(object, "to", to)
Pleroma.Constants.as_public() in to ->
cc = cc ++ [followers_collection]
Map.put(object, "cc", cc)
true ->
object
end
else
object
end
end
def fix_addressing(object) do
{:ok, %User{follower_address: follower_collection}} =
object
|> Containment.get_actor()
|> User.get_or_fetch_by_ap_id()
object
|> fix_addressing_list("to")
|> fix_addressing_list("cc")
|> fix_addressing_list("bto")
|> fix_addressing_list("bcc")
|> fix_addressing_public("to")
|> fix_addressing_public("cc")
|> fix_addressing_public("bto")
|> fix_addressing_public("bcc")
|> fix_explicit_addressing(follower_collection)
|> fix_implicit_addressing(follower_collection)
end
def fix_actor(%{"attributedTo" => actor} = object) do
actor = Containment.get_actor(%{"actor" => actor})
# TODO: Remove actor field for Objects
object
|> Map.put("actor", actor)
|> Map.put("attributedTo", actor)
end
def fix_in_reply_to(object, options \\ [])
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
when not is_nil(in_reply_to) do
in_reply_to_id = prepare_in_reply_to(in_reply_to)
object =
cond do
is_list(in_reply_to) ->
if not_empty_string(in_reply_to_id) do
Map.put(object, "inReplyTo", in_reply_to_id)
else
Map.delete(object, "inReplyTo")
end
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_binary(in_reply_to["href"]) ->
Map.put(object, "inReplyTo", in_reply_to["href"])
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_nil(in_reply_to["href"]) ->
Map.delete(object, "inReplyTo")
true ->
object
end
depth = (options[:depth] || 0) + 1
if not_empty_string(in_reply_to_id) and Federator.allowed_thread_distance?(depth) do
with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
%Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
object
|> Map.put("inReplyTo", replied_object.data["id"])
|> Map.put("context", replied_object.data["context"] || object["conversation"])
|> Map.drop(["conversation", "inReplyToAtomUri"])
else
_ ->
object
end
else
object
end
end
def fix_in_reply_to(object, _options), do: object
def fix_quote_url_and_maybe_fetch(object, options \\ []) do
quote_url =
case CommonFixes.fix_quote_url(object) do
%{"quoteUrl" => quote_url} -> quote_url
_ -> nil
end
with {:quoting?, true} <- {:quoting?, not is_nil(quote_url)},
{:ok, quoted_object} <- get_obj_helper(quote_url, options),
%Activity{} <- Activity.get_create_by_object_ap_id(quoted_object.data["id"]) do
Map.put(object, "quoteUrl", quoted_object.data["id"])
else
{:quoting?, _} ->
object
_ ->
object
end
end
defp prepare_in_reply_to(in_reply_to) do
cond do
is_bitstring(in_reply_to) ->
in_reply_to
is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
in_reply_to["id"]
is_map(in_reply_to) && is_bitstring(in_reply_to["href"]) ->
in_reply_to["href"]
is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
Enum.at(in_reply_to, 0)
is_list(in_reply_to) && is_map(Enum.at(in_reply_to, 0)) ->
in_reply_to
|> Enum.at(0)
|> prepare_in_reply_to()
true ->
""
end
end
def fix_context(object) do
context = object["context"] || object["conversation"] || Utils.generate_context_id()
object
|> Map.put("context", context)
|> Map.drop(["conversation"])
end
defp valid_http_url?(href) when is_binary(href) do
match?({:ok, _}, ObjectValidators.Uri.cast(href))
end
defp valid_http_url?(_), do: false
def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
attachments =
Enum.map(attachment, fn
data when is_map(data) ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
url_data = if is_map(url), do: url, else: %{}
media_type =
cond do
is_bitstring(url_data["mediaType"]) &&
url_data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
url_data["mediaType"]
is_bitstring(data["mediaType"]) &&
data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
true ->
nil
end
href =
cond do
is_binary(url) -> url
is_binary(url_data["href"]) -> url_data["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
if href && valid_http_url?(href) do
attachment_url =
%{
"href" => href,
"type" => Map.get(url_data, "type", "Link")
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", url_data["width"] || data["width"])
|> Maps.put_if_present("height", url_data["height"] || data["height"])
%{
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
nil
end
_ ->
nil
end)
|> Enum.filter(& &1)
Map.put(object, "attachment", attachments)
end
def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
object
|> Map.put("attachment", [attachment])
|> fix_attachments()
end
def fix_attachments(object), do: object
def fix_url(%{"url" => url} = object) when is_map(url) do
Map.put(object, "url", url["href"])
end
def fix_url(%{"url" => url} = object) when is_list(url) do
first_element = Enum.at(url, 0)
url_string =
cond do
is_bitstring(first_element) -> first_element
is_map(first_element) -> first_element["href"] || ""
true -> ""
end
Map.put(object, "url", url_string)
end
def fix_url(object), do: object
defp emoji_icon_url(icon) when is_binary(icon), do: icon
defp emoji_icon_url(%{"url" => url}) when is_binary(url), do: url
defp emoji_icon_url(%{"url" => %{"href" => href}}) when is_binary(href), do: href
defp emoji_icon_url(%{"url" => [first | _]}) do
cond do
is_binary(first) -> first
is_map(first) -> first["href"] || first["url"] || first["id"]
true -> nil
end
end
defp emoji_icon_url(%{"href" => href}) when is_binary(href), do: href
defp emoji_icon_url(_), do: nil
defp valid_emoji_icon_url(icon) do
case emoji_icon_url(icon) do
url when is_binary(url) -> if valid_http_url?(url), do: url
_ -> nil
end
end
def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
emoji =
tags
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Emoji" and is_binary(data["name"]) and data["icon"]
end)
|> Enum.reduce(%{}, fn data, mapping ->
name = String.trim(data["name"], ":")
case valid_emoji_icon_url(data["icon"]) do
url when is_binary(url) -> Map.put(mapping, name, url)
_ -> mapping
end
end)
Map.put(object, "emoji", emoji)
end
def fix_emoji(%{"tag" => %{"type" => "Emoji", "name" => name} = tag} = object)
when is_binary(name) do
name = String.trim(name, ":")
with url when is_binary(url) <- valid_emoji_icon_url(tag["icon"]) do
Map.put(object, "emoji", %{name => url})
else
_ -> object
end
end
def fix_emoji(object), do: object
def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
tags =
tag
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Hashtag" and is_binary(data["name"])
end)
|> Enum.map(fn
%{"name" => "#" <> hashtag} -> String.downcase(hashtag)
%{"name" => hashtag} -> String.downcase(hashtag)
end)
Map.put(object, "tag", tag ++ tags)
end
def fix_tag(%{"tag" => %{} = tag} = object) do
object
|> Map.put("tag", [tag])
|> fix_tag
end
def fix_tag(object), do: object
# prefer content over contentMap
def fix_content_map(%{"content" => content} = object) when not_empty_string(content), do: object
# content map usually only has one language so this will do for now.
def fix_content_map(%{"contentMap" => content_map} = object) do
content_groups = Map.to_list(content_map)
{_, content} = Enum.at(content_groups, 0)
Map.put(object, "content", content)
end
def fix_content_map(object), do: object
defp fix_type(%{"type" => "Note", "inReplyTo" => reply_id, "name" => _} = object, options)
when is_binary(reply_id) do
options = Keyword.put(options, :fetch, true)
with %Object{data: %{"type" => "Question"}} <- Object.normalize(reply_id, options) do
Map.put(object, "type", "Answer")
else
_ -> object
end
end
defp fix_type(object, _options), do: object
# Reduce the object list to find the reported user.
defp get_reported(objects) do
Enum.reduce_while(objects, nil, fn ap_id, _ ->
with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
{:halt, user}
else
_ -> {:cont, nil}
end
end)
end
defp reject_third_party_report(%User{local: false}, %User{local: false} = account) do
{:reject, "[Transmogrifier] third-party report: #{account.ap_id}"}
end
defp reject_third_party_report(_, _), do: :ok
def handle_incoming(data, options \\ []) do
data
|> fix_recursive(&strip_internal_fields/1)
|> fix_recursive(&fix_type_as_string/1)
|> handle_incoming_normalized(options)
end
defp fix_type_as_string(data) do
data
|> normalize_type()
|> normalize_tag_types()
end
defp normalize_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @supported_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: data
end
defp normalize_type(data), do: data
defp normalize_tag_types(%{"tag" => tags} = data) when is_list(tags) do
Map.put(data, "tag", Enum.map(tags, &normalize_tag_type/1))
end
defp normalize_tag_types(%{"tag" => tag} = data) when is_map(tag) do
Map.put(data, "tag", normalize_tag_type(tag))
end
defp normalize_tag_types(data), do: data
defp normalize_tag_type(tag) when is_map(tag), do: TagValidator.normalize(tag)
defp normalize_tag_type(tag), do: tag
# Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
# with nil ID.
defp handle_incoming_normalized(
%{"type" => "Flag", "object" => objects, "actor" => actor} = data,
_options
) do
with context <- data["context"] || Utils.generate_context_id(),
content <- data["content"] || "",
objects <- List.wrap(objects),
%User{} = actor <- User.get_cached_by_ap_id(actor),
# Reduce the object list to find the reported user.
%User{} = account <- get_reported(objects),
:ok <- reject_third_party_report(actor, account),
# Remove the reported user from the object list.
statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
%{
actor: actor,
context: context,
account: account,
statuses: statuses,
content: content,
additional: %{"cc" => [account.ap_id]}
}
|> ActivityPub.flag()
end
end
# disallow objects with bogus IDs
defp handle_incoming_normalized(%{"id" => nil}, _options), do: :error
defp handle_incoming_normalized(%{"id" => ""}, _options), do: :error
# length of https:// = 8, should validate better, but good enough for now.
defp handle_incoming_normalized(%{"id" => id}, _options)
when is_binary(id) and byte_size(id) < 8,
do: :error
defp handle_incoming_normalized(
%{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data,
options
) do
actor = Containment.get_actor(data)
data =
Map.put(data, "actor", actor)
|> fix_addressing
with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
reply_depth = (options[:depth] || 0) + 1
options = Keyword.put(options, :depth, reply_depth)
object = fix_object(object, options)
params = %{
to: data["to"],
object: object,
actor: user,
context: nil,
local: false,
published: data["published"],
additional: Map.take(data, ["cc", "id"])
}
ActivityPub.listen(params)
else
_e -> :error
end
end
@misskey_reactions %{
"like" => "👍",
"love" => "❤️",
"laugh" => "😆",
"hmm" => "🤔",
"surprise" => "😮",
"congrats" => "🎉",
"angry" => "💢",
"confused" => "😥",
"rip" => "😇",
"pudding" => "🍮",
"star" => "⭐"
}
# Rewrite misskey likes into EmojiReacts
defp handle_incoming_normalized(
%{
"type" => "Like",
"content" => content
} = data,
options
)
when is_binary(content) do
data
|> Map.put("type", "EmojiReact")
|> handle_incoming_normalized(options)
end
defp handle_incoming_normalized(
%{
"type" => "Like",
"_misskey_reaction" => reaction
} = data,
options
) do
data
|> Map.put("content", @misskey_reactions[reaction] || reaction)
|> handle_incoming_normalized(options)
end
defp handle_incoming_normalized(
%{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data,
options
)
when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page Image} do
fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
object =
data["object"]
|> strip_internal_fields()
|> fix_type(fetch_options)
|> fix_in_reply_to(fetch_options)
|> fix_quote_url_and_maybe_fetch(fetch_options)
data = Map.put(data, "object", object)
options = Keyword.put(options, :local, false)
with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
nil <- Activity.get_create_by_object_ap_id(obj_id),
{:ok, activity, _} <- Pipeline.common_pipeline(data, options) do
{:ok, activity}
else
%Activity{} = activity -> {:ok, activity}
e -> e
end
end
defp handle_incoming_normalized(%{"type" => type} = data, _options)
when type in ~w{Like EmojiReact Announce Add Remove} do
with :ok <- ObjectValidator.fetch_actor_and_object(data),
{:ok, activity, _meta} <-
Pipeline.common_pipeline(data, local: false) do
{:ok, activity}
else
e -> {:error, e}
end
end
defp handle_incoming_normalized(
%{"type" => type} = data,
_options
)
when type in ~w{Update Block Follow Accept Reject} do
fixed_obj = maybe_fix_object(data["object"])
data = if fixed_obj != nil, do: %{data | "object" => fixed_obj}, else: data
with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
{:ok, activity, _} <-
Pipeline.common_pipeline(data, local: false) do
{:ok, activity}
end
end
defp handle_incoming_normalized(
%{"type" => "Delete"} = data,
_options
) do
case Pipeline.common_pipeline(data, local: false) do
{:ok, activity, _} -> {:ok, activity}
{:error, _} = error -> error
{:reject, _} = error -> error
e -> {:error, e}
end
end
defp handle_incoming_normalized(
%{
"type" => "Undo",
"object" => %{"type" => "Follow", "object" => followed},
"actor" => follower,
"id" => id
} = _data,
_options
) do
with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
{:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
{:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
User.unfollow(follower, followed)
{:ok, activity}
else
_e -> :error
end
end
defp handle_incoming_normalized(
%{
"type" => "Undo",
"object" => %{"type" => type}
} = data,
_options
)
when type in ["Like", "EmojiReact", "Announce", "Block"] do
with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
{:ok, activity}
end
end
# For Undos that don't have the complete object attached, try to find it in our database.
defp handle_incoming_normalized(
%{
"type" => "Undo",
"object" => object
} = activity,
options
)
when is_binary(object) do
with %Activity{data: data} <- Activity.get_by_ap_id(object) do
activity
|> Map.put("object", data)
|> handle_incoming_normalized(options)
else
_e -> :error
end
end
defp handle_incoming_normalized(
%{
"type" => "Move",
"actor" => origin_actor,
"object" => origin_actor,
"target" => target_actor
},
_options
) do
with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor),
{:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor),
true <- origin_actor in target_user.also_known_as do
ActivityPub.move(origin_user, target_user, false)
else
_e -> :error
end
end
# Rewrite dislikes into the thumbs down emoji
defp handle_incoming_normalized(%{"type" => "Dislike"} = data, options) do
data
|> Map.put("type", "EmojiReact")
|> Map.put("content", "👎")
|> handle_incoming_normalized(options)
end
defp handle_incoming_normalized(
%{"type" => "Undo", "object" => %{"type" => "Dislike"}} = data,
options
) do
data
|> put_in(["object", "type"], "EmojiReact")
|> put_in(["object", "content"], "👎")
|> handle_incoming_normalized(options)
end
defp handle_incoming_normalized(_, _), do: :error
@spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil
def get_obj_helper(id, options \\ []) do
options = Keyword.put(options, :fetch, true)
case Object.normalize(id, options) do
%Object{} = object -> {:ok, object}
_ -> nil
end
end
@spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil
def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{
ap_id: ap_id
})
when attributed_to == ap_id do
with {:ok, activity} <-
handle_incoming(%{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => attributed_to,
"object" => data
}) do
{:ok, Object.normalize(activity, fetch: false)}
else
_ -> get_obj_helper(object_id)
end
end
def get_embedded_obj_helper(object_id, _) do
get_obj_helper(object_id)
end
def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
with false <- String.starts_with?(in_reply_to, "http"),
{:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
else
_e -> object
end
end
def set_reply_to_uri(obj), do: obj
@doc """
Fedibird compatibility
https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac
"""
def set_quote_url(%{"quoteUrl" => quote_url} = object) when is_binary(quote_url) do
Map.put(object, "quoteUri", quote_url)
end
def set_quote_url(obj), do: obj
@doc """
Inline first page of the `replies` collection,
containing any replies in chronological order.
"""
def set_replies(%{"type" => type} = obj_data)
when type in Pleroma.Constants.status_object_types() do
with obj_ap_id when is_binary(obj_ap_id) <- obj_data["id"],
limit when limit > 0 <-
Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
collection <-
Pleroma.Web.ActivityPub.ObjectView.render("object_replies.json", %{
render_params: %{object_ap_id: obj_data["id"], limit: limit, skip_ap_ctx: true}
}) do
Map.put(obj_data, "replies", collection)
else
0 -> Map.put(obj_data, "replies", obj_data["id"] <> "/replies")
_ -> obj_data
end
end
def set_replies(obj_data), do: obj_data
defp set_voters_count(%{"voters" => [_ | _] = voters} = obj) do
Map.merge(obj, %{"votersCount" => length(voters)})
end
defp set_voters_count(obj), do: obj
# Prepares and sanitizes the object for federation.
def prepare_object(object) do
object
|> add_hashtags
|> add_mention_tags
|> add_emoji_tags
|> add_attributed_to
|> prepare_attachments
|> set_conversation
|> set_reply_to_uri
|> set_quote_url
|> set_replies
|> set_voters_count
|> CommonFixes.maybe_add_content_map()
|> strip_internal_fields
|> strip_internal_tags
|> set_type
|> maybe_process_history
end
defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do
processed_history =
Enum.map(
history,
fn
item when is_map(item) -> prepare_object(item)
item -> item
end
)
put_in(object, ["formerRepresentations", "orderedItems"], processed_history)
end
defp maybe_process_history(object) do
object
end
# @doc
# """
# internal -> Mastodon
# """
def prepare_activity(%{"type" => activity_type, "object" => object_id} = data)
when activity_type in ["Create", "Listen"] do
object =
object_id
|> Object.normalize(fetch: false)
|> Map.get(:data)
data =
data
|> Map.put("object", prepare_object(object))
|> Map.merge(Utils.make_json_ld_header(object))
|> Map.delete("bcc")
{:ok, data}
end
def prepare_activity(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
when objtype in Pleroma.Constants.updatable_object_types() do
data =
data
|> Map.put("object", prepare_object(object))
|> Map.merge(Utils.make_json_ld_header(object))
|> Map.delete("bcc")
{:ok, data}
end
def prepare_activity(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
when objtype in Pleroma.Constants.actor_types() do
object =
object
|> maybe_fix_user_object()
|> strip_internal_fields()
data =
data
|> Map.put("object", object)
|> strip_internal_fields()
|> Map.merge(Utils.make_json_ld_header(object))
|> Map.delete("bcc")
{:ok, data}
end
def prepare_activity(%{"type" => "Update", "object" => %{}} = data) do
raise "Requested to serve an Update for non-updateable object type: #{inspect(data)}"
end
def prepare_activity(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
object =
object_id
|> Object.normalize(fetch: false)
data =
if Visibility.private?(object) && object.data["actor"] == ap_id do
data |> Map.put("object", object |> Map.get(:data) |> prepare_object)
else
data |> maybe_fix_object_url
end
data =
data
|> strip_internal_fields
|> Map.merge(Utils.make_json_ld_header())
|> Map.delete("bcc")
{:ok, data}
end
# Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
# because of course it does.
def prepare_activity(%{"type" => "Accept"} = data) do
with follow_activity <- Activity.normalize(data["object"]) do
object = %{
"actor" => follow_activity.actor,
"object" => follow_activity.data["object"],
"id" => follow_activity.data["id"],
"type" => "Follow"
}
data =
data
|> Map.put("object", object)
|> Map.merge(Utils.make_json_ld_header())
{:ok, data}
end
end
def prepare_activity(%{"type" => "Reject"} = data) do
with follow_activity <- Activity.normalize(data["object"]) do
object = %{
"actor" => follow_activity.actor,
"object" => follow_activity.data["object"],
"id" => follow_activity.data["id"],
"type" => "Follow"
}
data =
data
|> Map.put("object", object)
|> Map.merge(Utils.make_json_ld_header())
{:ok, data}
end
end
def prepare_activity(%{"type" => "Flag"} = data) do
with {:ok, stripped_activity} <- Utils.strip_report_status_data(data),
stripped_activity <- Utils.maybe_anonymize_reporter(stripped_activity),
stripped_activity <- Map.merge(stripped_activity, Utils.make_json_ld_header()) do
{:ok, stripped_activity}
end
end
def prepare_activity(%{"type" => _type} = data) do
data =
data
|> strip_internal_fields
|> maybe_fix_object_url
|> Map.merge(Utils.make_json_ld_header(data))
{:ok, data}
end
def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do
with false <- String.starts_with?(object, "http"),
{:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)},
%{data: %{"external_url" => external_url}} when not is_nil(external_url) <-
relative_object do
Map.put(data, "object", external_url)
else
{:fetch, _} ->
data
_ ->
data
end
end
def maybe_fix_object_url(data), do: data
def add_hashtags(object) do
tags =
(object["tag"] || [])
|> Enum.map(fn
# Expand internal representation tags into AS2 tags.
tag when is_binary(tag) ->
%{
"href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
"name" => "##{tag}",
"type" => "Hashtag"
}
# Do not process tags which are already AS2 tag objects.
tag when is_map(tag) ->
tag
end)
Map.put(object, "tag", tags)
end
# TODO These should be added on our side on insertion, it doesn't make much
# sense to regenerate these all the time
def add_mention_tags(object) do
to = object["to"] || []
cc = object["cc"] || []
mentioned = User.get_users_from_set(to ++ cc, local_only: false)
mentions = Enum.map(mentioned, &build_mention_tag/1)
tags = object["tag"] || []
Map.put(object, "tag", tags ++ mentions)
end
defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do
%{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
end
def take_emoji_tags(%User{emoji: emoji}) do
emoji
|> Map.to_list()
|> Enum.map(&Emoji.build_emoji_tag/1)
end
# TODO: we should probably send mtime instead of unix epoch time for updated
def add_emoji_tags(%{"emoji" => emoji} = object) do
tags = object["tag"] || []
out = Enum.map(emoji, &Emoji.build_emoji_tag/1)
Map.put(object, "tag", tags ++ out)
end
def add_emoji_tags(object), do: object
def set_conversation(object) do
Map.put(object, "conversation", object["context"])
end
def set_type(%{"type" => "Answer"} = object) do
Map.put(object, "type", "Note")
end
def set_type(object), do: object
def add_attributed_to(object) do
attributed_to = object["attributedTo"] || object["actor"]
Map.put(object, "attributedTo", attributed_to)
end
# TODO: Revisit this
def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object
def prepare_attachments(object) do
attachments =
object
|> Map.get("attachment", [])
|> Enum.map(fn data ->
[%{"mediaType" => media_type, "href" => href} = url | _] = data["url"]
%{
"url" => href,
"mediaType" => media_type,
"name" => data["name"],
"type" => "Document"
}
|> Maps.put_if_present("width", url["width"])
|> Maps.put_if_present("height", url["height"])
|> Maps.put_if_present("blurhash", data["blurhash"])
end)
Map.put(object, "attachment", attachments)
end
def strip_internal_fields(object) do
Map.drop(object, Pleroma.Constants.object_internal_fields())
end
defp strip_internal_tags(%{"tag" => tags} = object) do
tags = Enum.filter(tags, fn x -> is_map(x) end)
Map.put(object, "tag", tags)
end
defp strip_internal_tags(object), do: object
def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do
Map.put(data, "url", url["href"])
end
def maybe_fix_user_url(data), do: data
def maybe_fix_user_object(data), do: maybe_fix_user_url(data)
end
diff --git a/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs
index 119183924..ba8c3158f 100644
--- a/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs
+++ b/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs
@@ -1,289 +1,376 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.MRFMock
+ alias Pleroma.Web.ActivityPub.SideEffects
+ alias Pleroma.Web.ActivityPub.SideEffectsMock
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.CommonAPI
alias Pleroma.LoggerMock
import Mox
import Pleroma.Factory
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
defp delete_data(activity, deleting_user) do
File.read!("test/fixtures/mastodon-delete.json")
|> Jason.decode!()
|> Map.put("actor", deleting_user.ap_id)
|> put_in(["object", "id"], activity.data["object"])
end
defp delete_count_for_object(object_id) do
Activity
|> where([activity], fragment("?->>'type' = ?", activity.data, "Delete"))
|> where([activity], fragment("associated_object_id(?) = ?", activity.data, ^object_id))
|> Repo.aggregate(:count)
end
test "it works for incoming deletes" do
activity = insert(:note_activity)
deleting_user = insert(:user)
data = delete_data(activity, deleting_user)
{:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
Transmogrifier.handle_incoming(data)
assert id == data["id"]
# We delete the Create activity because we base our timelines on it.
# This should be changed after we unify objects and activities
refute Activity.get_by_id(activity.id)
assert actor == deleting_user.ap_id
# Objects are replaced by a tombstone object.
object = Object.normalize(activity.data["object"], fetch: false)
assert object.data["type"] == "Tombstone"
end
test "it treats duplicate incoming deletes as idempotent" do
activity = insert(:note_activity)
deleting_user = insert(:user)
object_id = activity.data["object"]
data = delete_data(activity, deleting_user)
{:ok, %Activity{} = first_delete} = Transmogrifier.handle_incoming(data)
assert delete_count_for_object(object_id) == 1
assert {:ok, %Activity{id: id}} = Transmogrifier.handle_incoming(data)
assert id == first_delete.id
spoofed_replay = Map.put(data, "actor", insert(:user).ap_id)
assert {:error, :already_deleted} = Transmogrifier.handle_incoming(spoofed_replay)
duplicate_data = Map.put(data, "id", data["id"] <> "/duplicate")
assert {:error, :already_deleted} = Transmogrifier.handle_incoming(duplicate_data)
refute Activity.get_by_ap_id(duplicate_data["id"])
assert delete_count_for_object(object_id) == 1
end
test "it ignores stale live objects in cache when detecting duplicate deletes" do
activity = insert(:note_activity)
deleting_user = insert(:user)
stale_object = Object.get_by_ap_id(activity.data["object"])
data = delete_data(activity, deleting_user)
assert {:ok, %Activity{}} = Transmogrifier.handle_incoming(data)
Object.set_cache(stale_object)
duplicate_data = Map.put(data, "id", data["id"] <> "/stale-cache")
assert {:error, :already_deleted} = Transmogrifier.handle_incoming(duplicate_data)
assert delete_count_for_object(activity.data["object"]) == 1
end
+ test "it invalidates caches again after a Delete commits" do
+ user = insert(:user)
+ {:ok, parent} = CommonAPI.post(user, %{status: "parent"})
+ {:ok, activity} = CommonAPI.post(user, %{status: "reply", in_reply_to_id: parent.id})
+
+ object = Object.get_by_ap_id(activity.data["object"])
+ parent_object = Object.get_by_ap_id(parent.data["object"])
+ cached_user = User.get_by_id(user.id)
+ data = delete_data(activity, user)
+
+ SideEffectsMock
+ |> expect(:handle, fn delete, meta ->
+ result = SideEffects.handle(delete, meta)
+
+ Object.set_cache(object)
+ Object.set_cache(parent_object)
+ User.set_cache(cached_user)
+
+ result
+ end)
+
+ assert {:ok, %Activity{}} = Transmogrifier.handle_incoming(data)
+ assert Object.get_cached_by_ap_id(object.data["id"]).data["type"] == "Tombstone"
+ assert Object.get_cached_by_ap_id(parent_object.data["id"]).data["repliesCount"] == 0
+ assert User.get_cached_by_ap_id(user.ap_id).note_count == 1
+ end
+
+ test "it completes an exact replay of a persisted Delete with missing side effects" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "retry persisted Delete"})
+ object_id = activity.data["object"]
+ data = delete_data(activity, user)
+
+ assert {:ok, persisted_delete, _meta} = ActivityPub.persist(data, local: false)
+ assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object_id)
+
+ assert {:ok, %Activity{id: delete_id}} = Transmogrifier.handle_incoming(data)
+ assert delete_id == persisted_delete.id
+ assert delete_count_for_object(object_id) == 1
+ assert %Object{data: %{"type" => "Tombstone"}} = Object.get_by_ap_id(object_id)
+ refute Activity.get_by_id(activity.id)
+ end
+
+ test "it completes a persisted Delete when a tombstone still has its Create" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "retry tombstone Delete"})
+ object = Object.get_by_ap_id(activity.data["object"])
+ data = delete_data(activity, user)
+
+ {:ok, cached_user} = User.add_pinned_object_id(user, object.data["id"])
+ assert {:ok, _tombstone} = Object.swap_object_with_tombstone(object)
+ Object.invalid_object_cache(object)
+ assert {:ok, persisted_delete, _meta} = ActivityPub.persist(data, local: false)
+
+ SideEffectsMock
+ |> expect(:handle, fn delete, meta ->
+ result = SideEffects.handle(delete, meta)
+ User.set_cache(cached_user)
+ result
+ end)
+
+ assert {:ok, %Activity{id: delete_id}} = Transmogrifier.handle_incoming(data)
+ assert delete_id == persisted_delete.id
+ assert delete_count_for_object(object.data["id"]) == 1
+ refute Activity.get_by_id(activity.id)
+ refute Map.has_key?(User.get_cached_by_ap_id(user.ap_id).pinned_objects, object.data["id"])
+ end
+
test "it rolls back failed delete side effects so the activity can be retried" do
activity = insert(:note_activity)
deleting_user = insert(:user)
object = Object.get_by_ap_id(activity.data["object"])
data = delete_data(activity, deleting_user)
{:ok, object} =
object
|> Object.change(%{data: Map.delete(object.data, "actor")})
|> Repo.update()
Cachex.del(:object_cache, "object:#{object.data["id"]}")
LoggerMock
|> expect(:error, fn message -> assert message =~ "The object doesn't have an actor" end)
assert {:error, {:side_effects, {:error, :no_object_actor}}} =
Transmogrifier.handle_incoming(data)
refute Activity.get_by_ap_id(data["id"])
assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object.data["id"])
assert Activity.get_by_id(activity.id)
current_object = Object.get_by_ap_id(object.data["id"])
{:ok, _object} =
current_object
|> Object.change(%{data: Map.put(current_object.data, "actor", deleting_user.ap_id)})
|> Repo.update()
Cachex.del(:object_cache, "object:#{object.data["id"]}")
assert {:ok, %Activity{data: %{"id" => delete_id}}} =
Transmogrifier.handle_incoming(data)
assert delete_id == data["id"]
end
test "it invalidates caches when an exception rolls back mutated Delete side effects" do
deleting_user = insert(:user)
{:ok, activity} = CommonAPI.post(deleting_user, %{status: "rollback cached state"})
object = Object.get_by_ap_id(activity.data["object"])
data = delete_data(activity, deleting_user)
{:ok, object} =
object
|> Object.change(%{data: Map.put(object.data, "inReplyTo", %{})})
|> Repo.update()
Cachex.del(:object_cache, "object:#{object.data["id"]}")
assert_raise Protocol.UndefinedError, fn ->
Transmogrifier.handle_incoming(data)
end
assert User.get_by_id(deleting_user.id).note_count == 1
assert User.get_cached_by_ap_id(deleting_user.ap_id).note_count == 1
assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object.data["id"])
assert Activity.get_by_id(activity.id)
refute Activity.get_by_ap_id(data["id"])
end
test "it rejects MRF rewrites of the Delete actor or target" do
activity = insert(:note_activity)
other_activity = insert(:note_activity)
deleting_user = insert(:user)
other_deleting_user = insert(:user)
data = delete_data(activity, deleting_user)
MRFMock
|> expect(:pipeline_filter, fn message, meta ->
{:ok, Map.put(message, "object", other_activity.data["object"]), meta}
end)
assert {:error, :delete_identity_changed} = Transmogrifier.handle_incoming(data)
assert Activity.get_by_id(activity.id)
assert Activity.get_by_id(other_activity.id)
refute Activity.get_by_ap_id(data["id"])
MRFMock
|> expect(:pipeline_filter, fn message, meta ->
{:ok, Map.put(message, "actor", other_deleting_user.ap_id), meta}
end)
assert {:error, :delete_identity_changed} =
data
|> Map.update!("id", &(&1 <> "/actor-rewrite"))
|> Transmogrifier.handle_incoming()
assert Activity.get_by_id(activity.id)
MRFMock
|> expect(:pipeline_filter, fn message, meta ->
{:ok, Map.update!(message, "id", &(&1 <> "/mrf-rewrite")), meta}
end)
assert {:error, :delete_identity_changed} = Transmogrifier.handle_incoming(data)
assert Activity.get_by_id(activity.id)
end
test "it works for incoming when the object has been pruned" do
activity = insert(:note_activity)
{:ok, object} =
Object.normalize(activity.data["object"], fetch: false)
|> Repo.delete()
# TODO: mock cachex
Cachex.del(:object_cache, "object:#{object.data["id"]}")
deleting_user = insert(:user)
data = delete_data(activity, deleting_user)
{:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
Transmogrifier.handle_incoming(data)
assert id == data["id"]
# We delete the Create activity because we base our timelines on it.
# This should be changed after we unify objects and activities
refute Activity.get_by_id(activity.id)
assert actor == deleting_user.ap_id
end
test "it handles a first Delete for a canonical tombstone without an actor" do
activity = insert(:note_activity)
object = Object.get_by_ap_id(activity.data["object"])
deleting_user = User.get_cached_by_ap_id(object.data["actor"])
data = delete_data(activity, deleting_user)
assert {:ok, tombstone} = Object.swap_object_with_tombstone(object)
refute tombstone.data["actor"]
Cachex.del(:object_cache, "object:#{object.data["id"]}")
assert {:ok, %Activity{data: %{"id" => delete_id}}} =
Transmogrifier.handle_incoming(data)
assert delete_id == data["id"]
refute Activity.get_by_id(activity.id)
end
test "it fails for incoming deletes with spoofed origin" do
activity = insert(:note_activity)
%{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
data =
activity
|> delete_data(%{ap_id: ap_id})
assert {:error, {:validate, {:error, _}}} = Transmogrifier.handle_incoming(data)
end
test "it works for incoming user deletes" do
%{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Jason.decode!()
{:ok, first_delete} = Transmogrifier.handle_incoming(data)
assert {:ok, %Activity{id: id}} = Transmogrifier.handle_incoming(data)
assert id == first_delete.id
duplicate_data = Map.update!(data, "id", &(&1 <> "/duplicate"))
assert {:error, :already_deleted} = Transmogrifier.handle_incoming(duplicate_data)
assert delete_count_for_object(ap_id) == 1
ObanHelpers.perform_all()
refute User.get_cached_by_ap_id(ap_id).is_active
end
+ test "it completes an exact replay of a persisted user Delete" do
+ user = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
+
+ data =
+ File.read!("test/fixtures/mastodon-delete-user.json")
+ |> Jason.decode!()
+
+ assert {:ok, persisted_delete, _meta} = ActivityPub.persist(data, local: false)
+ assert User.get_cached_by_ap_id(user.ap_id).is_active
+
+ assert {:ok, %Activity{id: delete_id}} = Transmogrifier.handle_incoming(data)
+ assert delete_id == persisted_delete.id
+ assert delete_count_for_object(user.ap_id) == 1
+ refute User.get_cached_by_ap_id(user.ap_id).is_active
+ end
+
test "it fails for incoming user deletes with spoofed origin" do
%{ap_id: ap_id} = insert(:user)
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Jason.decode!()
|> Map.put("actor", ap_id)
assert {:error, {:validate, {:error, _}}} = Transmogrifier.handle_incoming(data)
assert User.get_cached_by_ap_id(ap_id)
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 31, 1:14 AM (19 h, 14 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800841
Default Alt Text
(66 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline