4.1 KiB · text 5af55d9
defmodule GitGud.Federation.RemoteActors do
@moduledoc """
Fetches remote ActivityPub actors and caches them as `Actor` rows
with `kind = "remote"`.
Used by:
* HTTP signature verification — to resolve `keyId` to a public key.
* Inbox processing — to associate an incoming activity's `actor`
URL with a known row.
Moderation: a fetch against a blocked instance/actor short-circuits
without making a network request.
"""
import Ecto.Query, warn: false
alias GitGud.Federation
alias GitGud.Federation.Actor
alias GitGud.Repo
@user_agent "gitgud-federation"
@max_age_seconds 60 * 60 * 24
@doc """
Resolve `actor_url` to an `%Actor{}`.
Returns the cached row if it's fresh; refreshes on miss or stale.
`keyId` URLs that contain a fragment (`#main-key`) are normalized to
the actor URL.
"""
def fetch(actor_url) when is_binary(actor_url) do
url = normalize_key_id(actor_url)
case Federation.screen_inbound(url) do
{:drop, reason} ->
{:error, {:dropped, reason}}
:ok ->
case Federation.get_actor_by_url(url) do
%Actor{last_fetched_at: t} = actor when not is_nil(t) ->
if stale?(t), do: refresh(actor), else: {:ok, actor}
%Actor{} = actor ->
refresh(actor)
nil ->
do_fetch_and_insert(url)
end
end
end
defp stale?(%DateTime{} = t),
do: DateTime.diff(DateTime.utc_now(), t) > @max_age_seconds
defp normalize_key_id(url) do
case URI.parse(url) do
%URI{fragment: f} when is_binary(f) and f != "" ->
url
|> URI.parse()
|> Map.put(:fragment, nil)
|> URI.to_string()
_ ->
url
end
end
defp do_fetch_and_insert(url) do
case fetch_doc(url) do
{:ok, doc} ->
attrs = doc_to_attrs(doc) |> Map.put(:actor_url, url)
%Actor{}
|> Actor.remote_changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:actor_url],
returning: true
)
{:error, _} = err ->
err
end
end
defp refresh(%Actor{actor_url: url} = actor) do
case fetch_doc(url) do
{:ok, doc} ->
attrs = doc_to_attrs(doc)
actor |> Actor.remote_changeset(attrs) |> Repo.update()
{:error, _} ->
# Network blip: serve what we have, but bump the error so
# callers can decide.
{:ok, actor}
end
end
# ── HTTP fetch ───────────────────────────────────────────────────────
defp fetch_doc(url) do
case Req.get(url,
headers: [
{"accept", "application/activity+json"},
{"user-agent", @user_agent}
],
receive_timeout: 10_000,
retry: false
) do
{:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %Req.Response{status: 200, body: body}} when is_binary(body) ->
Jason.decode(body)
{:ok, %Req.Response{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
end
# ── doc → schema attrs ───────────────────────────────────────────────
@doc false
def doc_to_attrs(%{} = doc) do
pub_key = Map.get(doc, "publicKey") || %{}
%{
actor_type: Map.get(doc, "type", "Person"),
preferred_username: Map.get(doc, "preferredUsername"),
name: Map.get(doc, "name"),
summary: Map.get(doc, "summary"),
inbox_url: Map.get(doc, "inbox"),
outbox_url: Map.get(doc, "outbox"),
followers_url: Map.get(doc, "followers"),
following_url: Map.get(doc, "following"),
shared_inbox_url: get_in(doc, ["endpoints", "sharedInbox"]),
public_key_pem: Map.get(pub_key, "publicKeyPem"),
public_key_id: Map.get(pub_key, "id"),
remote_doc: doc,
last_fetched_at: DateTime.utc_now(:second)
}
end
end