Some things that I've long since forgotten. Let's hope they work

This commit is contained in:
Shadowfacts 2021-08-25 16:11:08 -04:00
parent de495059d5
commit b6d7f45c60
17 changed files with 149 additions and 65 deletions

View File

@ -2,7 +2,7 @@ defmodule Clacks.Activity do
require Logger require Logger
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Clacks.Repo alias Clacks.{Repo, Object}
import Ecto.Query import Ecto.Query
@type t() :: %__MODULE__{} @type t() :: %__MODULE__{}
@ -34,10 +34,17 @@ defmodule Clacks.Activity do
}) })
end end
@spec get(id :: String.t()) :: t() | nil @spec get(id :: String.t(), opts :: Keyword.t()) :: t() | nil
def get(id) do def get(id, opts \\ []) do
if Keyword.get(opts, :with_object, false) do
__MODULE__
|> where([a], a.id == ^id)
|> preload_object()
|> Repo.one()
else
Repo.get(__MODULE__, id) Repo.get(__MODULE__, id)
end end
end
@spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil @spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil
def get_by_ap_id(ap_id, force_refetch \\ false) do def get_by_ap_id(ap_id, force_refetch \\ false) do
@ -55,10 +62,32 @@ defmodule Clacks.Activity do
@spec get_by_object_ap_id(object_id :: String.t()) :: t() | nil @spec get_by_object_ap_id(object_id :: String.t()) :: t() | nil
def get_by_object_ap_id(object_id) do def get_by_object_ap_id(object_id) do
Repo.one( __MODULE__
from a in __MODULE__, |> where(
where: fragment("?->'object'->>'id'", a.data) == ^object_id [a],
fragment("COALESCE(?->'object'->>'id', ?->>'object')", a.data, a.data) == ^object_id
) )
|> preload_object()
|> Repo.one()
end
@spec join_with_object(Ecto.Queryable.t()) :: Ecto.Query.t()
def join_with_object(query) do
join(query, :inner, [a], o in Object,
as: :object,
on:
fragment("?->>'id' = COALESCE(?->'object'->>'id', ?->>'object')", o.data, a.data, a.data)
)
end
@spec preload_object(Ecto.Queryable.t()) :: Ecto.Query.t()
def preload_object(query) do
if Ecto.Query.has_named_binding?(query, :object) do
query
else
join_with_object(query)
end
|> preload([_a, object: object], object: object)
end end
@spec fetch(ap_id :: String.t()) :: t() | nil @spec fetch(ap_id :: String.t()) :: t() | nil
@ -91,4 +120,15 @@ defmodule Clacks.Activity do
end end
end end
end end
@spec data_with_object(activity :: Activity.t()) :: map()
def data_with_object(%__MODULE__{
data: %{"object" => object_id} = data,
object: %Object{data: object_data}
})
when is_binary(object_id) do
Map.put(data, "object", object_data)
end
def data_with_object(%___MODULE__{data: data}), do: data
end end

View File

@ -78,20 +78,20 @@ defmodule Clacks.ActivityPub do
} }
end end
@spec create( @spec internal_create(
object :: map(), object :: map(),
id :: String.t() | nil, id :: String.t() | nil,
actor :: String.t() | nil, actor :: String.t() | nil,
to :: [String.t()] | nil, to :: [String.t()] | nil,
cc :: [String.t()] | nil cc :: [String.t()] | nil
) :: map() ) :: map()
def create(object, id \\ nil, actor \\ nil, to \\ nil, cc \\ nil) do def internal_create(object, id \\ nil, actor \\ nil, to \\ nil, cc \\ nil) do
%{ %{
"@context" => @context, "@context" => @context,
"id" => id || activity_id(Ecto.UUID.generate()), "id" => id || activity_id(Ecto.UUID.generate()),
"actor" => actor || object["actor"], "actor" => actor || object["actor"],
"type" => "Create", "type" => "Create",
"object" => object, "object" => object["id"],
"to" => to || object["to"], "to" => to || object["to"],
"cc" => cc || object["cc"] "cc" => cc || object["cc"]
} }
@ -103,7 +103,7 @@ defmodule Clacks.ActivityPub do
%{ %{
"@context" => @context, "@context" => @context,
"type" => "Create", "type" => "Create",
"object" => object, "object" => object["id"],
"actor" => object["actor"] || object["attributedTo"], "actor" => object["actor"] || object["attributedTo"],
"to" => object["to"], "to" => object["to"],
"cc" => object["cc"] "cc" => object["cc"]

View File

@ -1,19 +1,39 @@
defmodule Clacks.ActivityPub.Federator do defmodule Clacks.ActivityPub.Federator do
require Logger require Logger
alias Clacks.{Repo, Actor, User, Keys} alias Clacks.{Repo, Actor, User, Keys, Activity}
import Ecto.Query import Ecto.Query
@public "https://www.w3.org/ns/activitystreams#Public" @public "https://www.w3.org/ns/activitystreams#Public"
@spec federate_to_followers(activity :: map(), actor :: Actor.t()) :: :ok | {:error, any()} @spec federate_to_involved(activity :: Activity.t(), actor :: Actor.t()) ::
def federate_to_followers(activity, actor) do :ok | {:error, any()}
Repo.all( def federate_to_involved(%Activity{data: %{"to" => to, "cc" => cc}} = activity, actor) do
from a in Actor, where: fragment("?->>'id'", a.data) in ^actor.followers, select: a.data activity_for_federating = Activity.data_with_object(activity)
)
|> Enum.map(&inbox_for(activity, &1)) addressed =
(to ++ cc)
|> Enum.uniq()
|> List.delete(@public)
addressed_actors =
if actor.data["followers"] in addressed do
addressed = List.delete(addressed, actor.data["followers"])
[actor.followers | addressed]
else
addressed
end
Actor
|> where([a], a.ap_id in ^addressed_actors)
|> select([a], %{
shared_inbox: fragment("?->'endpoints'->>'sharedInbox'", a.data),
inbox: fragment("?->>'inbox'", a.data)
})
|> Repo.all()
|> Enum.map(&inbox_for(activity_for_federating, &1))
|> Enum.uniq() |> Enum.uniq()
|> Enum.reduce_while(:ok, fn inbox, _acc -> |> Enum.reduce_while(:ok, fn inbox, _acc ->
case federate(activity, inbox) do case do_federate(activity_for_federating, inbox) do
{:error, _} = err -> {:error, _} = err ->
{:halt, err} {:halt, err}
@ -23,8 +43,14 @@ defmodule Clacks.ActivityPub.Federator do
end) end)
end end
@spec federate(activity :: map(), inbox :: String.t()) :: :ok | {:error, any()} @spec federate(Activity.t(), String.t()) :: :ok | {:error, any()}
def federate(%{"actor" => actor_id} = activity, inbox) do def federate(activity, inbox) do
activity_for_federating = Activity.data_with_object(activity)
do_federate(activity_for_federating, inbox)
end
@spec do_federate(activity :: map(), inbox :: String.t()) :: :ok | {:error, any()}
defp do_federate(%{"actor" => actor_id} = activity, inbox) do
Logger.debug("Federating #{activity["id"]} to #{inbox}") Logger.debug("Federating #{activity["id"]} to #{inbox}")
%{host: inbox_host, path: inbox_path} = URI.parse(inbox) %{host: inbox_host, path: inbox_path} = URI.parse(inbox)
@ -71,13 +97,13 @@ defmodule Clacks.ActivityPub.Federator do
shared_inbox_for(actor) shared_inbox_for(actor)
true -> true ->
actor["inbox"] actor.inbox
end end
end end
@spec shared_inbox_for(actor :: map()) :: String.t() @spec shared_inbox_for(actor :: map()) :: String.t()
defp shared_inbox_for(%{"endpoints" => %{"sharedInbox" => shared}}), do: shared defp shared_inbox_for(%{shared_inbox: shared}) when not is_nil(shared), do: shared
defp shared_inbox_for(%{"inbox" => inbox}), do: inbox defp shared_inbox_for(%{inbox: inbox}), do: inbox
@spec signature_timestamp() :: String.t() @spec signature_timestamp() :: String.t()
defp signature_timestamp(date \\ NaiveDateTime.utc_now()) do defp signature_timestamp(date \\ NaiveDateTime.utc_now()) do

View File

@ -44,7 +44,7 @@ defmodule Clacks.ActivityPub.Fetcher do
data data
else else
{:error, reason} -> {:error, reason} ->
Logger.warn("Couldn't fetch AP object at #{uri}: #{inspect(reason)}") Logger.warn("Couldn't fetch AP object at #{uri}: #{reason}")
nil nil
end end
end end

View File

@ -5,7 +5,14 @@ defmodule Clacks.Actor do
import Ecto.Query import Ecto.Query
alias Clacks.Repo alias Clacks.Repo
@type t() :: %__MODULE__{} @type t() :: %__MODULE__{
ap_id: String.t(),
nickname: String.t(),
local: boolean(),
data: map(),
followers: [String.t()],
user: Clacks.User.t()
}
@primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true}

View File

@ -4,9 +4,18 @@ defmodule Clacks.Inbox do
defp store_activity(%{"actor" => actor, "id" => ap_id} = activity, local \\ false) defp store_activity(%{"actor" => actor, "id" => ap_id} = activity, local \\ false)
when is_binary(actor) do when is_binary(actor) do
activity_without_embedded_object =
case Map.get(activity, "object") do
%{"id" => object_id} ->
Map.put(activity, "object", object_id)
_ ->
activity
end
changeset = changeset =
Activity.changeset(Activity.get_cached_by_ap_id(ap_id) || %Activity{}, %{ Activity.changeset(Activity.get_cached_by_ap_id(ap_id) || %Activity{}, %{
data: activity, data: activity_without_embedded_object,
local: local, local: local,
actor: actor actor: actor
}) })
@ -61,7 +70,7 @@ defmodule Clacks.Inbox do
Logger.error("Couldn't store Accept activity: #{inspect(changeset)}") Logger.error("Couldn't store Accept activity: #{inspect(changeset)}")
{:error, "Couldn't store Accept activity"} {:error, "Couldn't store Accept activity"}
{:ok, _accept} -> {:ok, accept} ->
ActivityPub.Federator.federate(accept, follower.data["inbox"]) ActivityPub.Federator.federate(accept, follower.data["inbox"])
end end
end end

View File

@ -53,15 +53,15 @@ defmodule Clacks.Object do
Repo.one(from o in __MODULE__, where: fragment("?->>'id'", o.data) == ^ap_id) Repo.one(from o in __MODULE__, where: fragment("?->>'id'", o.data) == ^ap_id)
end end
@spec fetch(ap_id :: String.t(), synthesize_create :: boolean(), return :: :object | :activity) :: @spec fetch(url :: String.t(), synthesize_create :: boolean(), return :: :object | :activity) ::
t() | Activity.t() | nil t() | Activity.t() | nil
def fetch(ap_id, synthesize_create \\ true, return \\ :object) do def fetch(url, synthesize_create \\ true, return \\ :object) do
case Clacks.ActivityPub.Fetcher.fetch_object(ap_id) do case Clacks.ActivityPub.Fetcher.fetch_object(url) do
nil -> nil ->
nil nil
data -> %{"id" => ap_id} = data ->
existing = get_cached_by_ap_id(data["id"]) existing = get_cached_by_ap_id(ap_id)
changeset = changeset =
changeset(existing || %__MODULE__{}, %{ changeset(existing || %__MODULE__{}, %{
@ -87,7 +87,7 @@ defmodule Clacks.Object do
}) })
{:ok, create} = Repo.insert_or_update(changeset) {:ok, create} = Repo.insert_or_update(changeset)
create %Clacks.Activity{create | object: object}
else else
nil nil
end end

View File

@ -21,6 +21,7 @@ defmodule Clacks.Timeline do
|> restrict_to_public(only_public) |> restrict_to_public(only_public)
|> paginate(params) |> paginate(params)
|> limit(^Map.get(params, "limit", 20)) |> limit(^Map.get(params, "limit", 20))
|> Activity.preload_object()
|> join_with_announced_or_liked() |> join_with_announced_or_liked()
|> select( |> select(
[activity, announced, announced_actor], [activity, announced, announced_actor],
@ -52,6 +53,7 @@ defmodule Clacks.Timeline do
|> restrict_to_types(@timeline_types) |> restrict_to_types(@timeline_types)
|> paginate(params) |> paginate(params)
|> limit(^Map.get(params, "limit", 20)) |> limit(^Map.get(params, "limit", 20))
|> Activity.preload_object()
|> join_with_announced_or_liked() |> join_with_announced_or_liked()
|> select( |> select(
[activity, actor, announced, announced_actor], [activity, actor, announced, announced_actor],
@ -70,6 +72,7 @@ defmodule Clacks.Timeline do
|> restrict_to_types(@timeline_types) |> restrict_to_types(@timeline_types)
|> paginate(params) |> paginate(params)
|> limit(^Map.get(params, "limit", 20)) |> limit(^Map.get(params, "limit", 20))
|> Activity.preload_object()
|> join_with_actors() |> join_with_actors()
|> join_with_announced_or_liked() |> join_with_announced_or_liked()
|> select( |> select(

View File

@ -3,7 +3,12 @@ defmodule Clacks.User do
import Ecto.Changeset import Ecto.Changeset
alias Clacks.Repo alias Clacks.Repo
@type t() :: %__MODULE__{} @type t() :: %__MODULE__{
username: String.t(),
private_key: String.t(),
password_hash: String.t(),
actor: Clacks.Actor.t()
}
schema "users" do schema "users" do
field :username, :string field :username, :string

View File

@ -17,7 +17,9 @@ defmodule Clacks.UserActionsHelper do
def post_status(author, content, content_type, in_reply_to_ap_id) def post_status(author, content, content_type, in_reply_to_ap_id)
when is_binary(in_reply_to_ap_id) do when is_binary(in_reply_to_ap_id) do
case Activity.get_by_ap_id(in_reply_to_ap_id) do activity = Activity.get_by_ap_id(in_reply_to_ap_id) |> Activity.preload_object()
case activity do
nil -> nil ->
{:error, "Could find post to reply to with AP ID '#{in_reply_to_ap_id}'"} {:error, "Could find post to reply to with AP ID '#{in_reply_to_ap_id}'"}
@ -33,7 +35,7 @@ defmodule Clacks.UserActionsHelper do
note_changeset = Object.changeset_for_creating(note) note_changeset = Object.changeset_for_creating(note)
{:ok, _object} = Repo.insert(note_changeset) {:ok, _object} = Repo.insert(note_changeset)
%{"id" => ap_id} = create = ActivityPub.create(note) %{"id" => create_ap_id} = create = ActivityPub.internal_create(note, author.actor.ap_id)
case ActivityPub.Helper.save_and_federate(create, author.actor) do case ActivityPub.Helper.save_and_federate(create, author.actor) do
{:ok, activity} -> {:ok, activity} ->
@ -42,7 +44,7 @@ defmodule Clacks.UserActionsHelper do
{:ok, activity} {:ok, activity}
:error -> :error ->
{:error, "Unable to save and federate activity with ID '#{ap_id}'"} {:error, "Unable to save and federate activity with ID '#{create_ap_id}'"}
end end
end end
@ -75,7 +77,9 @@ defmodule Clacks.UserActionsHelper do
{nil, nil, nil} {nil, nil, nil}
%Activity{ %Activity{
object: %Object{
data: %{"id" => in_reply_to_ap_id, "context" => context, "actor" => in_reply_to_actor} data: %{"id" => in_reply_to_ap_id, "context" => context, "actor" => in_reply_to_actor}
}
} -> } ->
{context, in_reply_to_ap_id, in_reply_to_actor} {context, in_reply_to_ap_id, in_reply_to_actor}
end end

View File

@ -7,9 +7,9 @@ defmodule Clacks.Worker.Federate do
require Logger require Logger
def perform(%{"id" => activity_id, "actor_id" => actor_id}, _job) do def perform(%{"id" => activity_id, "actor_id" => actor_id}, _job) do
%Activity{data: activity_data} = Repo.get(Activity, activity_id) activity = Repo.get(Activity, activity_id)
actor = Repo.get(Actor, actor_id) actor = Repo.get(Actor, actor_id)
:ok = ActivityPub.Federator.federate_to_followers(activity_data, actor) :ok = ActivityPub.Federator.federate_to_involved(activity, actor)
end end
end end

View File

@ -6,10 +6,7 @@ defmodule Clacks.Worker.SendWebmention do
require Logger require Logger
@spec enqueue_for_activity(Activity.t()) :: :ok @spec enqueue_for_activity(Activity.t()) :: :ok
def enqueue_for_activity( def enqueue_for_activity(%Activity{object: %{"content" => content} = note} = activity) do
%Activity{data: %{"type" => "Create", "object" => %{"content" => content} = note}} =
activity
) do
tags = Map.get(note, "tag", []) tags = Map.get(note, "tag", [])
tag_hrefs = Enum.map(tags, fn %{"href" => href} -> href end) tag_hrefs = Enum.map(tags, fn %{"href" => href} -> href end)

View File

@ -57,16 +57,12 @@ defmodule ClacksWeb.FrontendController do
with %Activity{ with %Activity{
local: true, local: true,
data: object: %Object{data: %{"type" => "Note", "attributedTo" => author_id}}
%{ } = activity <- Activity.get(id, with_object: true),
"type" => "Create",
"object" => %{"type" => "Note", "attributedTo" => author_id}
} = data
} = activity <- Activity.get(id),
%Actor{} = author <- Actor.get_by_ap_id(author_id) do %Actor{} = author <- Actor.get_by_ap_id(author_id) do
case conn.assigns[:format] do case conn.assigns[:format] do
"activity+json" -> "activity+json" ->
json(conn, data) json(conn, Activity.data_with_object(activity))
"html" -> "html" ->
render(conn, "status.html", %{ render(conn, "status.html", %{
@ -177,7 +173,7 @@ defmodule ClacksWeb.FrontendController do
redirect(conn, to: ClacksWeb.FrontendView.local_actor_link(followee)) redirect(conn, to: ClacksWeb.FrontendView.local_actor_link(followee))
else else
follow = ActivityPub.follow(current_user.actor.ap_id, followee.ap_id) follow = ActivityPub.follow(current_user.actor.ap_id, followee.ap_id)
ActivityPub.Helper.save_and_federate(follow, current_user.actor) {:ok, _activity} = ActivityPub.Helper.save_and_federate(follow, current_user.actor)
conn conn
|> put_flash(:info, "Follow request sent") |> put_flash(:info, "Follow request sent")
@ -235,7 +231,7 @@ defmodule ClacksWeb.FrontendController do
status_results = status_results =
with %Activity{ with %Activity{
actor: actor_id, actor: actor_id,
data: %{"type" => "Create", "object" => %{"type" => "Note"}} object: %Object{}
} = activity <- Object.fetch(q, true, :activity), } = activity <- Object.fetch(q, true, :activity),
actor <- Actor.get_by_ap_id(actor_id) do actor <- Actor.get_by_ap_id(actor_id) do
[{activity, actor}] [{activity, actor}]

View File

@ -10,9 +10,9 @@
<li> <li>
<%= if status.data["type"] == "Announce" do %> <%= if status.data["type"] == "Announce" do %>
<% {announced_status, announced_actor} = announced %> <% {announced_status, announced_actor} = announced %>
<%= render "_action_status.html", conn: @conn, action: :announce, action_activity: status, action_actor: author, original_activity: announced_status, original_note: announced_status.data["object"], original_actor: announced_actor %> <%= render "_action_status.html", conn: @conn, action: :announce, action_activity: status, action_actor: author, original_activity: announced_status, original_note: announced_status.object.data, original_actor: announced_actor %>
<% else %> <% else %>
<%= render "_status.html", conn: @conn, author: author, status: status, note: status.data["object"] %> <%= render "_status.html", conn: @conn, author: author, status: status, note: status.object.data %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>

View File

@ -26,6 +26,6 @@
<hr> <hr>
<%= for {status, author} <- @status_results do %> <%= for {status, author} <- @status_results do %>
<%= render "_status.html", conn: @conn, author: author, status: status, note: status.data["object"] %> <%= render "_status.html", conn: @conn, author: author, status: status, note: status.object.data %>
<% end %> <% end %>
<% end %> <% end %>

View File

@ -1,8 +1,8 @@
<%= render "_status.html", conn: @conn, author: @author, status: @status, note: @status.data["object"] %> <%= render "_status.html", conn: @conn, author: @author, status: @status, note: @status.object.data %>
<%= unless is_nil(@current_user) do %> <%= unless is_nil(@current_user) do %>
<hr> <hr>
<%= render "_post_form.html", conn: @conn, in_reply_to: @status.data["object"]["id"], placeholder: "Reply", content: mentions_for_replying_to(@conn, @status) %> <%= render "_post_form.html", conn: @conn, in_reply_to: @status.object.data["id"], placeholder: "Reply", content: mentions_for_replying_to(@conn, @status) %>
<hr> <hr>
<% end %> <% end %>

View File

@ -1,6 +1,6 @@
defmodule ClacksWeb.FrontendView do defmodule ClacksWeb.FrontendView do
use ClacksWeb, :view use ClacksWeb, :view
alias Clacks.{Actor, Activity, Repo, Notification} alias Clacks.{Actor, Activity, Repo, Notification, Object}
alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint alias ClacksWeb.Endpoint
require Logger require Logger
@ -98,7 +98,7 @@ defmodule ClacksWeb.FrontendView do
@spec mentions_for_replying_to(Activity.t()) :: String.t() @spec mentions_for_replying_to(Activity.t()) :: String.t()
defp mentions_for_replying_to(conn, %Activity{ defp mentions_for_replying_to(conn, %Activity{
data: %{"object" => %{"actor" => actor, "tag" => tags}} object: %Object{data: %{"actor" => actor, "tag" => tags}}
}) do }) do
current_user = conn.assigns[:user] |> Repo.preload(:actor) current_user = conn.assigns[:user] |> Repo.preload(:actor)
@ -135,10 +135,7 @@ defmodule ClacksWeb.FrontendView do
@spec render_status_content(activity :: Activity.t()) :: String.t() @spec render_status_content(activity :: Activity.t()) :: String.t()
defp render_status_content(%Activity{ defp render_status_content(%Activity{
data: %{ object: %Object{data: %{"type" => "Note", "content" => content} = note}
"type" => "Create",
"object" => %{"type" => "Note", "content" => content} = note
}
}) do }) do
with %{"tag" => tags} <- note, with %{"tag" => tags} <- note,
{:ok, tree} <- Floki.parse_fragment(content) do {:ok, tree} <- Floki.parse_fragment(content) do