# Portions of this file are derived from Pleroma: # Copyright © 2017-2018 Pleroma Authors <https://pleroma.social> # SPDX-License-Identifier: AGPL-3.0-only # Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/activity_pub/utils.ex defmodule Mobilizon.Federation.ActivityPub.Utils do @moduledoc """ Various ActivityPub related utils. """ alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Medias.Media alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Federator} alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.Federation.ActivityStream.Converter alias Mobilizon.Federation.HTTPSignatures alias Mobilizon.Web.Endpoint require Logger @actor_types ["Group", "Person", "Application"] @all_actor_types @actor_types ++ ["Organization", "Service"] # Wraps an object into an activity @spec create_activity(map(), boolean()) :: {:ok, Activity.t()} def create_activity(map, local) when is_map(map) do with map <- lazy_put_activity_defaults(map) do {:ok, %Activity{ data: map, local: local, actor: map["actor"], recipients: get_recipients(map) }} end end # Get recipients for an activity or object @spec get_recipients(map()) :: list() defp get_recipients(data) do Map.get(data, "to", []) ++ Map.get(data, "cc", []) end # Some implementations send the actor URI as the actor field, others send the entire actor object, # so figure out what the actor's URI is based on what we have. @spec get_url(map() | String.t() | list(String.t()) | any()) :: String.t() | nil def get_url(%{"id" => id}), do: id def get_url(id) when is_binary(id), do: id def get_url(ids) when is_list(ids), do: get_url(hd(ids)) def get_url(_), do: nil @spec make_json_ld_header :: map() def make_json_ld_header do %{ "@context" => [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", %{ "@language" => "und", "sc" => "http://schema.org#", "ical" => "http://www.w3.org/2002/12/cal/ical#", "pt" => "https://joinpeertube.org/ns#", "toot" => "http://joinmastodon.org/ns#", "discoverable" => "toot:discoverable", "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers", "Hashtag" => "as:Hashtag", "category" => "sc:category", "uuid" => "sc:identifier", "maximumAttendeeCapacity" => "sc:maximumAttendeeCapacity", "remainingAttendeeCapacity" => "sc:remainingAttendeeCapacity", "location" => %{ "@id" => "sc:location", "@type" => "sc:Place" }, "PostalAddress" => "sc:PostalAddress", "address" => %{ "@id" => "sc:address", "@type" => "sc:PostalAddress" }, "addressCountry" => "sc:addressCountry", "addressRegion" => "sc:addressRegion", "postalCode" => "sc:postalCode", "addressLocality" => "sc:addressLocality", "streetAddress" => "sc:streetAddress", "mz" => "https://joinmobilizon.org/ns#", "repliesModerationOptionType" => %{ "@id" => "mz:repliesModerationOptionType", "@type" => "rdfs:Class" }, "repliesModerationOption" => %{ "@id" => "mz:repliesModerationOption", "@type" => "mz:repliesModerationOptionType" }, "commentsEnabled" => %{ "@type" => "sc:Boolean", "@id" => "pt:commentsEnabled" }, "joinModeType" => %{ "@id" => "mz:joinModeType", "@type" => "rdfs:Class" }, "joinMode" => %{ "@id" => "mz:joinMode", "@type" => "mz:joinModeType" }, "anonymousParticipationEnabled" => %{ "@id" => "mz:anonymousParticipationEnabled", "@type" => "sc:Boolean" }, "participationMessage" => %{ "@id" => "mz:participationMessage", "@type" => "sc:Text" }, "participantCount" => %{ "@id" => "mz:participantCount", "@type" => "sc:Integer" }, "memberCount" => %{ "@id" => "mz:memberCount", "@type" => "sc:Integer" }, "isOnline" => %{ "@type" => "sc:Boolean", "@id" => "mz:isOnline" }, "PropertyValue" => "sc:PropertyValue", "value" => "sc:value", "propertyID" => "sc:propertyID", "inLanguage" => "sc:inLanguage", "timezone" => %{ "@id" => "mz:timezone", "@type" => "sc:Text" }, "discussions" => %{ "@id" => "mz:discussions", "@type" => "@id" }, "events" => %{ "@id" => "mz:events", "@type" => "@id" }, "members" => %{ "@id" => "mz:members", "@type" => "@id" }, "openness" => %{ "@id" => "mz:openness", "@type" => "@id" }, "posts" => %{ "@id" => "mz:posts", "@type" => "@id" }, "resources" => %{ "@id" => "mz:resources", "@type" => "@id" }, "todos" => %{ "@id" => "mz:todos", "@type" => "@id" } } ] } end @spec make_date :: String.t() def make_date do DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() end @doc """ Enqueues an activity for federation if it's local """ @spec maybe_federate(activity :: Activity.t()) :: :ok def maybe_federate(%Activity{local: true} = activity) do Logger.debug("Maybe federate an activity") if Mobilizon.Config.get!([:instance, :federating]) do priority = case activity.data["type"] do "Delete" -> 10 "Create" -> 1 _ -> 5 end Federator.enqueue(:publish, activity, priority) end :ok end 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. """ @spec maybe_relay_if_group_activity(Activity.t(), Actor.t() | nil | list(Actor.t())) :: :ok def maybe_relay_if_group_activity(activity, attributed_to \\ nil) def maybe_relay_if_group_activity( %Activity{data: %{"object" => object}}, _attributed_to ) when is_map(object) do Logger.debug("Maybe relay if group activity (object is map)") Logger.debug(inspect(object)) 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{data: %{"object" => object}}, %Actor{url: attributed_to_url} ) when is_binary(object) and is_binary(attributed_to_url) do Logger.debug("Maybe relay if group activity (object is binary)") do_maybe_relay_if_group_activity(object, attributed_to_url) end def maybe_relay_if_group_activity(_activity, _attributedTo) do Logger.debug("Will not replay : not a group activity") :ok end # TODO: Is this a map or a String? @spec do_maybe_relay_if_group_activity(map() | String.t(), list(String.t()) | String.t()) :: :ok defp do_maybe_relay_if_group_activity(object, attributed_to) when is_list(attributed_to), do: do_maybe_relay_if_group_activity(object, hd(attributed_to)) defp do_maybe_relay_if_group_activity(object, attributed_to) when is_binary(attributed_to) do Logger.debug("Let's try to relay group activity") id = "#{Endpoint.url()}/announces/#{Ecto.UUID.generate()}" case Actors.get_local_group_by_url(attributed_to) do %Actor{} = group -> case Actions.Announce.announce(group, object, id, true, false) do {:ok, _activity, _object} -> Logger.info("Forwarded activity to external members of the group") :ok {:error, err} -> Logger.info("Failed to forward activity to external members of the group") Logger.debug(inspect(err)) :error end nil -> :ok end end defp do_maybe_relay_if_group_activity(_, attributed_to) do Logger.debug("Will not relay group activity, attributed to is : #{inspect(attributed_to)}") end @spec remote_actors(list(String.t())) :: list(Actor.t()) def remote_actors(recipients) do recipients |> Enum.map(fn url -> ActivityPubActor.get_or_fetch_actor_by_url(url) end) |> Enum.map(fn {status, actor} -> case status do :ok -> actor _ -> nil end end) |> Enum.map(& &1) |> Enum.filter(fn actor -> actor && !is_nil(actor.domain) end) end @doc """ Adds an id and a published data if they aren't there, also adds it to an included object """ @spec lazy_put_activity_defaults(map()) :: map() 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} else map end end @doc """ Adds an id and published date if they aren't there. """ def lazy_put_object_defaults(map) when is_map(map) do Map.put_new_lazy(map, "published", &make_date/0) end @spec get_actor(map()) :: String.t() | nil def get_actor(%{"actor" => actor}) when is_binary(actor) do actor end def get_actor(%{"actor" => [actor | tail] = actor_list} = object) when is_list(actor_list) and length(actor_list) > 0 do res = try do object |> Map.put("actor", actor) |> get_actor() rescue ArgumentError -> nil end case res do id when is_binary(id) -> id _ -> object |> Map.put("actor", tail) |> get_actor() end end def get_actor(%{"actor" => %{"id" => id, "type" => type}}) when is_binary(id) and type in @all_actor_types do id end def get_actor(%{"actor" => _, "attributedTo" => actor}) when not is_nil(actor) do get_actor(%{"actor" => actor}) end def get_actor(%{"actor" => %{"id" => id, "type" => type}}) when is_binary(id) do raise ArgumentError, message: "Object contains an actor object with invalid type: #{inspect(type)}" end def get_actor(%{"actor" => nil, "attributedTo" => nil} = object) do raise ArgumentError, message: "Object contains both actor and attributedTo fields being null: #{inspect(object)}" end def get_actor(%{"actor" => _}) do raise ArgumentError, message: "Object contains not actor information" end @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) """ @spec origin_check?(String.t(), map()) :: boolean() def origin_check?(id, %{"type" => "Tombstone", "id" => tombstone_id}), do: id == tombstone_id 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) and is_list(actor) and length(actor) > 0 do origin_check?(id, Map.put(params, "actor", hd(actor))) end 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") are_same_origin?(id, actor) end def origin_check?(id, %{"type" => type, "id" => actor_id} = _params) when type in ["Actor", "Person", "Group", "Application"], do: id == actor_id def origin_check?(_id, %{"actor" => nil} = _args), do: false def origin_check?(_id, _args), do: false @spec are_same_origin?(String.t(), String.t()) :: boolean() def are_same_origin?(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 @spec compare_uris?(URI.t(), URI.t()) :: boolean() defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host && id_uri.port == other_uri.port @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) 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) @doc """ Return AS Link data from * a `Plug.Upload` struct, stored an returned * a `Media`, directly returned * a map containing media information, stored, saved and returned Save media data from %Plug.Upload{} and return AS Link data. """ def make_media_data(%Plug.Upload{} = media, opts) do case Mobilizon.Web.Upload.store(media, opts) do {:ok, media} -> media {:error, _err} -> nil end end def make_media_data(media) when is_map(media) do with {:ok, %{url: url} = uploaded} <- Mobilizon.Web.Upload.store(media.file), {:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)}, {:ok, %Media{file: _file} = media} <- Mobilizon.Medias.create_media(%{ file: Map.take(uploaded, [:url, :name, :content_type, :size]), metadata: Map.take(uploaded, [:width, :height, :blurhash]), actor_id: media.actor_id }) do Converter.Media.model_to_as(media) else {:media_exists, %Media{file: _file} = media} -> Converter.Media.model_to_as(media) err -> err end end def make_media_data(nil), do: nil @doc """ Make announce activity data for the given actor and object """ def make_announce_data(actor, object, activity_id, public \\ true) def make_announce_data( %Actor{} = actor, %{"id" => url, "type" => type} = _object, activity_id, public ) when type in @actor_types do do_make_announce_data(actor, url, url, activity_id, public) end def make_announce_data( %Actor{} = actor, %{"actor" => object_actor_url} = object, activity_id, public ) do do_make_announce_data( actor, object_actor_url, object, activity_id, public ) end defp do_make_announce_data( %Actor{type: actor_type} = actor, object_actor_url, object, activity_id, public ) do {to, cc} = 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"]} else Logger.debug("Making announce data for a private object") if actor_type == :Group do Logger.debug("Making announce data for a group private object") to = Map.get(object, "to", []) ++ Map.get(object, "cc", []) ++ [actor.followers_url, actor.members_url] {to, []} else Logger.debug("Making announce data for a private object") {[actor.followers_url], []} end end data = %{ "type" => "Announce", "actor" => actor.url, "object" => object, "to" => to, "cc" => cc } data = if object["attributedTo"], do: Map.put(data, "attributedTo", object["attributedTo"]), else: data if activity_id, do: Map.put(data, "id", activity_id), else: data end @doc """ Make unannounce activity data for the given actor and object """ def make_unannounce_data( %Actor{url: url} = actor, activity, activity_id ) do data = %{ "type" => "Undo", "actor" => url, "object" => activity, "to" => [actor.followers_url, actor.url], "cc" => ["https://www.w3.org/ns/activitystreams#Public"] } if activity_id, do: Map.put(data, "id", activity_id), else: data end #### Unfollow-related helpers @spec make_unfollow_data(Actor.t(), Actor.t(), map(), String.t()) :: map() def make_unfollow_data( %Actor{url: follower_url}, %Actor{url: followed_url}, follow_activity, activity_id ) do data = %{ "type" => "Undo", "actor" => follower_url, "to" => [followed_url], "object" => follow_activity.data } if activity_id, do: Map.put(data, "id", activity_id), else: data end #### Create-related helpers @doc """ Make create activity data """ @spec make_create_data(map(), map()) :: map() def make_create_data(object, additional \\ %{}) do Logger.debug("Making create data") Logger.debug(inspect(object)) Logger.debug(inspect(additional)) %{ "type" => "Create", "to" => object["to"], "cc" => object["cc"], "attributedTo" => object["attributedTo"] || object["actor"], "actor" => object["actor"], "object" => object, "published" => make_date(), "id" => object["id"] <> "/activity" } |> Map.merge(additional) end @doc """ Make update activity data """ @spec make_update_data(map(), map()) :: map() def make_update_data(object, additional \\ %{}) do Logger.debug("Making update data") Logger.debug(inspect(object)) Logger.debug(inspect(additional)) %{ "type" => "Update", "to" => object["to"], "cc" => object["cc"], "attributedTo" => object["attributedTo"] || object["actor"], "actor" => object["actor"], "object" => object, "id" => object["id"] <> "/activity" } |> Map.merge(additional) end @doc """ Make accept join activity data """ @spec make_accept_join_data(map(), map()) :: map() def make_accept_join_data(object, additional \\ %{}) do %{ "type" => "Accept", "to" => object["to"], "cc" => object["cc"], "object" => object, "id" => object["id"] <> "/activity" } |> Map.merge(additional) end @doc """ Make add activity data """ @spec make_add_data(map(), map(), map()) :: map() def make_add_data(object, target, additional \\ %{}) do Logger.debug("Making add data") Logger.debug(inspect(object)) Logger.debug(inspect(additional)) %{ "type" => "Add", "to" => object["to"], "cc" => object["cc"], "actor" => object["actor"], "object" => object, "target" => Map.get(target, :url, target), "id" => object["id"] <> "/add" } |> Map.merge(additional) end @doc """ Make move activity data """ @spec make_move_data(map(), map(), map(), map()) :: map() def make_move_data(object, origin, target, additional \\ %{}) do Logger.debug("Making move data") Logger.debug(inspect(object)) Logger.debug(inspect(origin)) Logger.debug(inspect(target)) Logger.debug(inspect(additional)) %{ "type" => "Move", "to" => object["to"], "cc" => object["cc"], "actor" => object["actor"], "object" => object, "origin" => if(is_nil(origin), do: origin, else: Map.get(origin, :url, origin)), "target" => if(is_nil(target), do: target, else: Map.get(target, :url, target)), "id" => object["id"] <> "/move" } |> Map.merge(additional) end @doc """ Converts PEM encoded keys to a public key representation """ @spec pem_to_public_key_pem(String.t()) :: String.t() def pem_to_public_key_pem(pem) do public_key = pem_to_public_key(pem) public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key) :public_key.pem_encode([public_key]) end @spec pem_to_public_key(String.t()) :: {:RSAPublicKey, any(), any()} defp pem_to_public_key(pem) do [key_code] = :public_key.pem_decode(pem) key = :public_key.pem_entry_decode(key_code) case key do {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} -> {:RSAPublicKey, modulus, exponent} {:RSAPublicKey, modulus, exponent} -> {:RSAPublicKey, modulus, exponent} end end @spec make_signature(Actor.t(), String.t(), DateTime.t()) :: list({atom(), String.t()}) defp make_signature(actor, id, date) do uri = URI.parse(id) signature = actor |> HTTPSignatures.Signature.sign(%{ "(request-target)": "get #{uri.path}", host: uri.host, date: date }) [{:Signature, signature}] end @doc """ Sign a request with an actor. """ @spec sign_fetch(Enum.t(), Actor.t(), String.t(), String.t(), Keyword.t()) :: Enum.t() def sign_fetch(headers, actor, id, date, options \\ []) do if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) and Keyword.get(options, :ignore_sign_object_fetches, false) == false do headers ++ make_signature(actor, id, date) else headers end end @doc """ Add the Date header to the request if we sign object fetches """ @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}] else 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 @spec label_in_collection?(any(), any()) :: boolean() defp label_in_collection?(url, coll) when is_binary(coll), do: url == coll defp label_in_collection?(url, coll) when is_list(coll), do: url in coll defp label_in_collection?(_, _), do: false @spec label_in_message?(String.t(), map()) :: boolean() def label_in_message?(label, params), do: [params["to"], params["cc"], params["bto"], params["bcc"]] |> Enum.any?(&label_in_collection?(label, &1)) @spec unaddressed_message?(map()) :: boolean() def unaddressed_message?(params), do: [params["to"], params["cc"], params["bto"], params["bcc"]] |> Enum.all?(&is_nil(&1)) @spec recipient_in_message(Actor.t(), Actor.t(), map()) :: boolean() def recipient_in_message(%Actor{url: url} = _recipient, %Actor{} = _actor, params), do: label_in_message?(url, params) || unaddressed_message?(params) defp extract_list(target) when is_binary(target), do: [target] defp extract_list(lst) when is_list(lst), do: lst defp extract_list(_), do: [] def maybe_splice_recipient(url, params) do need_splice? = !label_in_collection?(url, params["to"]) && !label_in_collection?(url, params["cc"]) if need_splice? do cc_list = extract_list(params["cc"]) Map.put(params, "cc", [url | cc_list]) else params end end @schemes_with_no_port ["http", "https"] def create_full_domain_string(%URI{host: host, port: nil}), do: host def create_full_domain_string(%URI{host: host, port: port}) do if port in Enum.map(@schemes_with_no_port, &URI.default_port/1) do host else "#{host}:#{port}" end end end