10.3 KiB · text 5af55d9
defmodule GitGud.Federation do
@moduledoc """
ActivityPub / ForgeFed federation surface.
This module owns the *actor materialization* and URL minting paths.
The actor table is the bridge between local entities (users, orgs,
repositories) and the AP network: every local entity gets one local
actor row with a freshly minted RSA keypair; every remote actor we
learn about gets one remote row.
Outbound activities and inbound dispatch live in submodules; this
module is the high-traffic API.
## Configuration
config :git_gud, GitGud.Federation,
# The instance's canonical scheme + host. Used to mint actor
# URLs that survive front-end changes (don't bake the request
# URL in — generated URLs are stored on creation).
base_url: "https://gitgud.example",
# Open: anyone may federate.
# Allowlist: only hosts in `fed_instance_allowlist` may deliver.
federation_mode: :open
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Federation.Actor
alias GitGud.Federation.ActorBlock
alias GitGud.Federation.InstanceAllowlist
alias GitGud.Federation.InstanceBlock
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
# ── config ───────────────────────────────────────────────────────────
@doc "Canonical base URL of this instance (scheme + host[:port])."
def base_url do
Application.get_env(:git_gud, __MODULE__, [])
|> Keyword.get(:base_url) ||
Application.fetch_env!(:git_gud, GitGudWeb.Endpoint)
|> Keyword.get(:url, [])
|> derive_base_url()
end
defp derive_base_url(url_cfg) do
host = Keyword.get(url_cfg, :host, "localhost")
scheme = Keyword.get(url_cfg, :scheme, "http")
port = Keyword.get(url_cfg, :port)
case {scheme, port} do
{"https", nil} -> "https://#{host}"
{"http", nil} -> "http://#{host}"
{s, p} -> "#{s}://#{host}:#{p}"
end
end
@doc """
Federation mode. Defaults to `:allowlist` — a fresh instance accepts
inbound activity only from hosts you've explicitly added to
`fed_instance_allowlist`. To run in spec-default "open" mode:
config :git_gud, GitGud.Federation, federation_mode: :open
The choice is per-instance; per-actor blocks layer on top regardless.
"""
def federation_mode do
Application.get_env(:git_gud, __MODULE__, [])
|> Keyword.get(:federation_mode, :allowlist)
end
# ── URL minting ──────────────────────────────────────────────────────
def user_actor_url(%User{} = user),
do: base_url() <> "/@" <> Storage.owner_handle(user)
def org_actor_url(%Organization{handle: h}), do: base_url() <> "/orgs/" <> h
def repo_actor_url(%Repository{} = repo) do
repo = Repo.preload(repo, [:owner, :organization])
base_url() <> "/r/" <> Storage.repo_handle(repo) <> "/" <> repo.name
end
def inbox_url(actor_url), do: actor_url <> "/inbox"
def outbox_url(actor_url), do: actor_url <> "/outbox"
def followers_url(actor_url), do: actor_url <> "/followers"
def following_url(actor_url), do: actor_url <> "/following"
def shared_inbox_url, do: base_url() <> "/inbox"
def public_key_id(actor_url), do: actor_url <> "#main-key"
# ── actor materialization ────────────────────────────────────────────
@doc """
Return (creating if necessary) the local actor row for a user/org/repo.
Keypair generation happens on first call. Safe under concurrent first
calls — the unique index on `actor_url` is the safety net.
"""
def ensure_local_actor(%User{} = user), do: do_ensure_local(:user, user)
def ensure_local_actor(%Organization{} = org), do: do_ensure_local(:org, org)
def ensure_local_actor(%Repository{} = repo), do: do_ensure_local(:repo, repo)
defp do_ensure_local(kind, entity) do
case find_local_actor(kind, entity) do
%Actor{} = actor ->
{:ok, actor}
nil ->
attrs = build_local_attrs(kind, entity)
attrs
|> Actor.local_changeset()
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:actor_url],
returning: true
)
end
end
defp find_local_actor(:user, %User{id: id}), do: Repo.get_by(Actor, local_user_id: id)
defp find_local_actor(:org, %Organization{id: id}),
do: Repo.get_by(Actor, local_organization_id: id)
defp find_local_actor(:repo, %Repository{id: id}),
do: Repo.get_by(Actor, local_repository_id: id)
defp build_local_attrs(:user, %User{} = user) do
handle = Storage.owner_handle(user)
url = user_actor_url(user)
{pub_pem, priv_pem} = generate_keypair()
%{
actor_type: "Person",
actor_url: url,
inbox_url: inbox_url(url),
outbox_url: outbox_url(url),
followers_url: followers_url(url),
following_url: following_url(url),
shared_inbox_url: shared_inbox_url(),
preferred_username: handle,
name: handle,
public_key_pem: pub_pem,
public_key_id: public_key_id(url),
private_key_pem: priv_pem,
local_user_id: user.id
}
end
defp build_local_attrs(:org, %Organization{} = org) do
url = org_actor_url(org)
{pub_pem, priv_pem} = generate_keypair()
%{
actor_type: "Group",
actor_url: url,
inbox_url: inbox_url(url),
outbox_url: outbox_url(url),
followers_url: followers_url(url),
following_url: following_url(url),
shared_inbox_url: shared_inbox_url(),
preferred_username: org.handle,
name: org.name || org.handle,
summary: org.description,
public_key_pem: pub_pem,
public_key_id: public_key_id(url),
private_key_pem: priv_pem,
local_organization_id: org.id
}
end
defp build_local_attrs(:repo, %Repository{} = repo) do
url = repo_actor_url(repo)
handle = Repo.preload(repo, [:owner, :organization]) |> Storage.repo_handle()
{pub_pem, priv_pem} = generate_keypair()
%{
# ForgeFed `Repository` actor type. Implementations that don't
# know it fall back to treating us as an opaque actor and can
# still follow / receive notes.
actor_type: "Repository",
actor_url: url,
inbox_url: inbox_url(url),
outbox_url: outbox_url(url),
followers_url: followers_url(url),
following_url: following_url(url),
shared_inbox_url: shared_inbox_url(),
preferred_username: handle <> "/" <> repo.name,
name: handle <> "/" <> repo.name,
summary: repo.description,
public_key_pem: pub_pem,
public_key_id: public_key_id(url),
private_key_pem: priv_pem,
local_repository_id: repo.id
}
end
# ── lookup ───────────────────────────────────────────────────────────
def get_actor_by_url(url) when is_binary(url), do: Repo.get_by(Actor, actor_url: url)
@doc "Look up a local actor by `preferred_username` (used by WebFinger)."
def find_local_actor_by_handle(handle) when is_binary(handle) do
Repo.one(
from a in Actor,
where: a.kind == "local" and a.preferred_username == ^handle,
limit: 1
)
end
# ── keypair ──────────────────────────────────────────────────────────
@doc "Generate a 2048-bit RSA keypair. Returns `{public_pem, private_pem}`."
def generate_keypair do
private_key = :public_key.generate_key({:rsa, 2048, 65_537})
public_key =
case private_key do
{:RSAPrivateKey, _, n, e, _, _, _, _, _, _, _} ->
{:RSAPublicKey, n, e}
end
{pem(public_key, :SubjectPublicKeyInfo), pem(private_key, :PrivateKeyInfo)}
end
defp pem(key, type) do
entry = :public_key.pem_entry_encode(type, key)
:public_key.pem_encode([entry])
end
# ── moderation gates ─────────────────────────────────────────────────
@doc """
Decide whether an inbound delivery from `actor_url` is acceptable.
Returns:
* `:ok` — process normally
* `{:drop, reason}` — record and stop; don't surface anywhere
The caller is responsible for recording the InboxDelivery row regardless
so audit and replay remain possible.
"""
def screen_inbound(actor_url) when is_binary(actor_url) do
host = URI.parse(actor_url).host || ""
cond do
Repo.exists?(from b in ActorBlock, where: b.actor_url == ^actor_url) ->
{:drop, "actor_blocked"}
Repo.exists?(from b in InstanceBlock, where: b.host == ^host) ->
{:drop, "instance_blocked"}
federation_mode() == :allowlist and
not Repo.exists?(from a in InstanceAllowlist, where: a.host == ^host) ->
{:drop, "not_on_allowlist"}
true ->
:ok
end
end
# ── moderation CRUD ──────────────────────────────────────────────────
def block_instance(host, opts \\ []) do
%InstanceBlock{}
|> InstanceBlock.changeset(%{
host: host,
severity: Keyword.get(opts, :severity, "suspend"),
reason: Keyword.get(opts, :reason)
})
|> Repo.insert(on_conflict: {:replace, [:severity, :reason]}, conflict_target: [:host])
end
def unblock_instance(host) do
Repo.delete_all(from b in InstanceBlock, where: b.host == ^host)
:ok
end
def block_actor(actor_url, reason \\ nil) do
%ActorBlock{}
|> ActorBlock.changeset(%{actor_url: actor_url, reason: reason})
|> Repo.insert(on_conflict: {:replace, [:reason]}, conflict_target: [:actor_url])
end
def unblock_actor(actor_url) do
Repo.delete_all(from b in ActorBlock, where: b.actor_url == ^actor_url)
:ok
end
def allowlist_instance(host, note \\ nil) do
%InstanceAllowlist{}
|> InstanceAllowlist.changeset(%{host: host, note: note})
|> Repo.insert(on_conflict: :nothing, conflict_target: [:host])
end
end