defmodule Clacks.ActivityPub.Federator do require Logger alias Clacks.{Repo, Actor, User, Keys} import Ecto.Query @public "https://www.w3.org/ns/activitystreams#Public" @spec federate_to_followers(activity :: map(), actor :: Actor.t()) :: :ok | {:error, any()} def federate_to_followers(activity, actor) do Repo.all( from a in Actor, where: fragment("?->>'id'", a.data) in ^actor.followers, select: a.data ) |> Enum.map(&inbox_for(activity, &1)) |> Enum.uniq() |> Enum.reduce_while(:ok, fn inbox, _acc -> case federate(activity, inbox) do {:error, _} = err -> {:halt, err} _ -> {:cont, :ok} end end) end @spec federate(activity :: map(), inbox :: String.t()) :: :ok | {:error, any()} def federate(%{"actor" => actor_id} = activity, inbox) do Logger.debug("Federating #{activity["id"]} to #{inbox}") %{host: inbox_host, path: inbox_path} = URI.parse(inbox) {:ok, body} = Jason.encode(activity) digest = "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body)) date = signature_timestamp() signature_params = %{ "(request-target)": "post #{inbox_path}", host: inbox_host, "content-length": byte_size(body), digest: digest } {private_key, key_id} = private_key_for_actor(actor_id) signature_string = HTTPSignatures.sign(private_key, key_id, signature_params) headers = [ {"Content-Type", "application/activity+json"}, {"Date", date}, {"Signature", signature_string}, {"Digest", digest} ] opts = [hackney: Application.get_env(:clacks, :hackney_opts, [])] case HTTPoison.post(inbox, body, headers, opts) do {:ok, %HTTPoison.Response{status_code: status_code}} when status_code in 200..299 -> :ok {:error, _} = err -> err end end # see https://www.w3.org/TR/activitypub/#shared-inbox-delivery @spec inbox_for(activity :: map(), actor :: map()) :: String.t() defp inbox_for(activity, actor) do cond do @public in activity["to"] or @public in activity["cc"] -> shared_inbox_for(actor) actor.data["followers"] in activity["to"] or actor.data["followers"] in activity["cc"] -> shared_inbox_for(actor) true -> actor["inbox"] end end @spec shared_inbox_for(actor :: map()) :: String.t() defp shared_inbox_for(%{"endpoints" => %{"sharedInbox" => shared}}), do: shared defp shared_inbox_for(%{"inbox" => inbox}), do: inbox @spec signature_timestamp() :: String.t() defp signature_timestamp(date \\ NaiveDateTime.utc_now()) do Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") end defp private_key_for_actor(ap_id) do %Actor{user: %User{private_key: pem}} = Actor.get_by_ap_id(ap_id) |> Repo.preload(:user) {:ok, private_key, _} = Keys.keys_from_private_key_pem(pem) {private_key, ap_id <> "#main-key"} end end