9.7 KiB · text 5af55d9
defmodule GitGudWeb.ApActorController do
@moduledoc """
Serves ActivityPub actor documents and their associated collections
(inbox/outbox/followers/following) as JSON-LD.
Actor lookups:
* GET /@:handle — user (Person)
* GET /orgs/:handle — org (Group)
* GET /r/:owner/:name — repo (Repository, ForgeFed)
"""
use GitGudWeb, :controller
alias GitGud.Federation
alias GitGud.Federation.ActorJSON
alias GitGud.Federation.HttpSignatures
alias GitGud.Federation.InboxDelivery
alias GitGud.Federation.RateLimiter
alias GitGud.Federation.RemoteActors
alias GitGud.Federation.Workers.ProcessInbox
alias GitGud.Organizations
alias GitGud.Repositories
alias GitGudWeb.Plugs.CacheRawBody
@content_type "application/activity+json"
def user(conn, %{"handle" => handle}) do
case Federation.find_local_actor_by_handle(handle) do
nil ->
case Repositories.resolve_namespace(handle) do
{:ok, %GitGud.Accounts.User{} = user} ->
{:ok, actor} = Federation.ensure_local_actor(user)
render_actor(conn, actor)
_ ->
send_resp(conn, 404, "")
end
actor ->
render_actor(conn, actor)
end
end
def org(conn, %{"handle" => handle}) do
try do
org = Organizations.get_organization_by_handle!(handle)
{:ok, actor} = Federation.ensure_local_actor(org)
render_actor(conn, actor)
rescue
Ecto.NoResultsError -> send_resp(conn, 404, "")
end
end
def repo(conn, %{"owner" => owner, "name" => name}) do
try do
repo = Repositories.get_repository_by_path!(owner, name)
{:ok, actor} = Federation.ensure_local_actor(repo)
render_actor(conn, actor)
rescue
Ecto.NoResultsError -> send_resp(conn, 404, "")
end
end
# ── inbox (write side) ──────────────────────────────────────────────
def inbox(conn, _params) do
body = conn.body_params || %{}
source_url = Map.get(body, "actor")
source_host = host_of(source_url)
target_actor =
case resolve_target_actor(conn) do
{:ok, actor} -> actor
_ -> nil
end
case {target_actor, source_url, RateLimiter.check(source_host)} do
{nil, _, _} ->
send_resp(conn, 404, "")
{_, nil, _} ->
send_resp(conn, 400, "")
{actor, _src, {:error, :rate_limited, retry_after}} ->
record_rate_limited(conn, actor, source_url, body)
conn
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|> send_resp(429, "")
{actor, src, :ok} ->
screen_and_record(conn, actor, src, body)
end
end
defp host_of(nil), do: nil
defp host_of(url) when is_binary(url) do
case URI.parse(url) do
%URI{host: h} when is_binary(h) -> h
_ -> nil
end
end
defp record_rate_limited(conn, target_actor, source_url, body) do
raw_body = CacheRawBody.get(conn) || Jason.encode!(body)
%InboxDelivery{}
|> InboxDelivery.changeset(%{
target_actor_id: target_actor.id,
source_actor_url: source_url,
source_host: host_of(source_url),
raw_body: raw_body,
headers: capture_headers(conn),
signature_verified: false,
status: "dropped_moderation",
status_reason: "rate_limited",
processed_at: DateTime.utc_now(:second)
})
|> GitGud.Repo.insert!()
end
defp resolve_target_actor(conn) do
case conn.path_info do
["@" <> handle, "inbox"] ->
case Federation.find_local_actor_by_handle(handle) do
nil -> :error
actor -> {:ok, actor}
end
["orgs", handle, "inbox"] ->
case Organizations.get_organization_by_handle!(handle) do
org ->
{:ok, _} = Federation.ensure_local_actor(org)
{:ok, Federation.find_local_actor_by_handle(handle)}
end
["r", owner, name, "inbox"] ->
case Repositories.get_repository_by_path!(owner, name) do
repo ->
{:ok, actor} = Federation.ensure_local_actor(repo)
{:ok, actor}
end
_ ->
:error
end
rescue
Ecto.NoResultsError -> :error
end
defp screen_and_record(conn, target_actor, source_url, body) do
raw_body = CacheRawBody.get(conn) || Jason.encode!(body)
moderation_result = Federation.screen_inbound(source_url)
signature_result =
case moderation_result do
:ok -> verify_signature(conn, source_url, raw_body)
# Don't bother verifying a signature we're going to drop anyway.
{:drop, _} -> {:skipped, "moderation_drop"}
end
{status, reason} = decide_status(moderation_result, signature_result)
signature_verified = signature_result == :ok
source_host =
case URI.parse(source_url) do
%URI{host: h} when is_binary(h) -> h
_ -> nil
end
delivery =
%InboxDelivery{}
|> InboxDelivery.changeset(%{
target_actor_id: target_actor.id,
source_actor_url: source_url,
source_host: source_host,
raw_body: raw_body,
headers: capture_headers(conn),
signature_verified: signature_verified,
status: status,
status_reason: reason,
processed_at: if(status in ~w(dropped_moderation rejected), do: DateTime.utc_now(:second))
})
|> GitGud.Repo.insert!()
if status == "accepted" do
{:ok, _} = ProcessInbox.new_job(delivery.id) |> Oban.insert()
end
# Always 202 — the network can't tell whether we accepted or
# silently dropped, which is the intended behaviour for moderation.
send_resp(conn, 202, "")
end
defp decide_status({:drop, reason}, _sig), do: {"dropped_moderation", reason}
defp decide_status(:ok, :ok), do: {"accepted", nil}
defp decide_status(:ok, {:skipped, _reason}),
do: {"accepted", "signature_unverified"}
defp decide_status(:ok, {:error, reason}),
do: {"rejected", "signature: " <> to_string(reason)}
defp verify_signature(conn, source_url, raw_body) do
sig_header = Plug.Conn.get_req_header(conn, "signature") |> List.first()
with sig when is_binary(sig) <- sig_header,
{:ok, params} <- HttpSignatures.parse_header(sig),
{:ok, actor} <- RemoteActors.fetch(params["keyId"] || source_url),
:ok <- ensure_actor_matches_source(actor, source_url),
:ok <- check_digest(conn, raw_body),
:ok <- check_date(conn),
:ok <- verify_signing_string(conn, params, actor) do
:ok
else
nil -> {:error, :missing_signature}
{:error, _} = err -> err
{:skipped, _} = skip -> skip
end
end
defp ensure_actor_matches_source(%{actor_url: url}, source_url) when url == source_url, do: :ok
defp ensure_actor_matches_source(_actor, _source_url),
do: {:error, :key_actor_mismatch}
defp check_digest(conn, body) do
digest = Plug.Conn.get_req_header(conn, "digest") |> List.first()
HttpSignatures.verify_digest(digest, body)
end
defp check_date(conn) do
date = Plug.Conn.get_req_header(conn, "date") |> List.first()
HttpSignatures.verify_clock_skew(date)
end
defp verify_signing_string(conn, params, actor) do
headers_param = params["headers"] || "(request-target) host date digest"
lookup = fn
"host" ->
# Plug stores Host on conn.host and strips it from req_headers.
case Plug.Conn.get_req_header(conn, "host") do
[v | _] -> v
[] -> conn.host
end
name ->
Plug.Conn.get_req_header(conn, name) |> List.first()
end
path =
conn.request_path <> if(conn.query_string == "", do: "", else: "?" <> conn.query_string)
signing = HttpSignatures.signing_string(headers_param, lookup, "post #{path}")
HttpSignatures.verify(actor.public_key_pem, signing, params["signature"])
end
defp capture_headers(conn) do
conn.req_headers
|> Enum.into(%{}, fn {k, v} -> {String.downcase(k), v} end)
end
def outbox(conn, params), do: render_collection(conn, params, "outbox")
def followers(conn, params), do: render_collection(conn, params, "followers")
def following(conn, params), do: render_collection(conn, params, "following")
def repositories(conn, %{"handle" => handle}) do
try do
org = Organizations.get_organization_by_handle!(handle)
repos = Repositories.list_repositories_for(org)
items = Enum.map(repos, &Federation.repo_actor_url/1)
body =
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => Federation.base_url() <> conn.request_path,
"type" => "OrderedCollection",
"totalItems" => length(items),
"orderedItems" => items
}
|> Jason.encode!()
conn
|> put_resp_content_type(@content_type, nil)
|> send_resp(200, body)
rescue
Ecto.NoResultsError -> send_resp(conn, 404, "")
end
end
defp render_actor(conn, actor) do
body = ActorJSON.render(actor) |> Jason.encode!()
conn
|> put_resp_content_type(@content_type, nil)
|> send_resp(200, body)
end
defp render_collection(conn, params, kind) do
actor_url = collection_owner_url(conn, params, kind)
body =
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => actor_url,
"type" => "OrderedCollection",
"totalItems" => 0,
# Pagination scaffolded but no pages exist yet.
"first" => actor_url <> "?page=1"
}
|> Jason.encode!()
conn
|> put_resp_content_type(@content_type, nil)
|> send_resp(200, body)
end
# The collection's @id is its own URL. For an empty collection we just
# echo the requested path back.
defp collection_owner_url(conn, _params, _kind) do
Federation.base_url() <> conn.request_path
end
end