Federate client profile requests

This commit is contained in:
Pim Kunis 2021-09-09 16:00:58 +02:00
parent 213980a5be
commit f565383468
5 changed files with 125 additions and 42 deletions

View file

@ -174,9 +174,9 @@ defmodule Architex do
@doc """ @doc """
Validate a changeset's field where the reason for invalidation is not needed. Validate a changeset's field where the reason for invalidation is not needed.
""" """
@spec validate_change_simple(Ecto.Changeset.t(), atom(), (term() -> boolean())) :: @spec validate_change_truthy(Ecto.Changeset.t(), atom(), (term() -> boolean())) ::
Ecto.Changeset.t() Ecto.Changeset.t()
def validate_change_simple(changeset, field, func) do def validate_change_truthy(changeset, field, func) do
augmented_func = fn _, val -> augmented_func = fn _, val ->
if func.(val), do: [], else: [{field, "invalid"}] if func.(val), do: [], else: [{field, "invalid"}]
end end

View file

@ -7,6 +7,7 @@ defmodule ArchitexWeb.Client.ProfileController do
alias Architex.{Repo, Account} alias Architex.{Repo, Account}
alias Architex.Types.UserId alias Architex.Types.UserId
alias ArchitexWeb.Federation.HTTPClient
alias Plug.Conn alias Plug.Conn
alias Ecto.Changeset alias Ecto.Changeset
@ -33,9 +34,16 @@ defmodule ArchitexWeb.Client.ProfileController do
put_error(conn, :not_found, "User was not found.") put_error(conn, :not_found, "User was not found.")
end end
else else
# TODO: Use federation to lookup information. case HTTPClient.client(domain) |> HTTPClient.query_profile(user_id) do
{:ok, response} ->
conn
|> put_status(200)
|> json(response)
{:error, _, _} ->
put_error(conn, :not_found, "User was not found.") put_error(conn, :not_found, "User was not found.")
end end
end
:error -> :error ->
put_error(conn, :not_found, "User ID is invalid.") put_error(conn, :not_found, "User ID is invalid.")
@ -77,9 +85,16 @@ defmodule ArchitexWeb.Client.ProfileController do
put_error(conn, :not_found, "User was not found.") put_error(conn, :not_found, "User was not found.")
end end
else else
# TODO: Use federation to lookup information. case HTTPClient.client(domain) |> HTTPClient.query_profile(user_id, Atom.to_string(property_key)) do
{:ok, response} ->
conn
|> put_status(200)
|> json(response)
{:error, _, _} ->
put_error(conn, :not_found, "User was not found.") put_error(conn, :not_found, "User was not found.")
end end
end
:error -> :error ->
put_error(conn, :not_found, "User ID is invalid.") put_error(conn, :not_found, "User ID is invalid.")
@ -108,7 +123,12 @@ defmodule ArchitexWeb.Client.ProfileController do
update_property(conn, :avatar_url, avatar_url, user_id) update_property(conn, :avatar_url, avatar_url, user_id)
end end
defp update_property(%Conn{assigns: %{account: account}} = conn, property_key, property, user_id) do defp update_property(
%Conn{assigns: %{account: account}} = conn,
property_key,
property,
user_id
) do
if Account.get_mxid(account) == user_id do if Account.get_mxid(account) == user_id do
account account
|> Changeset.change([{property_key, property}]) |> Changeset.change([{property_key, property}])

View file

@ -38,15 +38,27 @@ defmodule ArchitexWeb.Federation.QueryController do
Action for GET /_matrix/federation/v1/query/profile. Action for GET /_matrix/federation/v1/query/profile.
""" """
def profile(conn, params) do def profile(conn, params) do
with {:ok, %ProfileRequest{user_id: %UserId{localpart: localpart, domain: domain}}} <- with {:ok,
%ProfileRequest{user_id: %UserId{localpart: localpart, domain: domain}, field: field}} <-
ProfileRequest.validate(params) do ProfileRequest.validate(params) do
if domain == Architex.server_name() do if domain == Architex.server_name() do
case Repo.one(from a in Account, where: a.localpart == ^localpart) do case Repo.one(from a in Account, where: a.localpart == ^localpart) do
%Account{} -> %Account{displayname: displayname, avatar_url: avatar_url} ->
# TODO: Return displayname and avatar_url when we implement them. data = %{}
data =
if field == "displayname" or (field == nil and displayname != nil),
do: Map.put(data, :displayname, displayname),
else: data
data =
if field == "avatar_url" or (field == nil and avatar_url != nil),
do: Map.put(data, :avatar_url, avatar_url),
else: data
conn conn
|> put_status(200) |> put_status(200)
|> json(%{}) |> json(data)
nil -> nil ->
put_error(conn, :not_found, "User does not exist.") put_error(conn, :not_found, "User does not exist.")

View file

@ -1,4 +1,9 @@
defmodule ArchitexWeb.Federation.HTTPClient do defmodule ArchitexWeb.Federation.HTTPClient do
@moduledoc """
This module provides functions to interact with other homeservers
using the Matrix federation API.
"""
# TODO: Investigate request timeouts.
use Tesla use Tesla
alias ArchitexWeb.Endpoint alias ArchitexWeb.Endpoint
@ -6,11 +11,27 @@ defmodule ArchitexWeb.Federation.HTTPClient do
alias ArchitexWeb.Federation.Middleware.SignRequest alias ArchitexWeb.Federation.Middleware.SignRequest
alias ArchitexWeb.Router.Helpers, as: RouteHelpers alias ArchitexWeb.Router.Helpers, as: RouteHelpers
# TODO: Maybe create database-backed homeserver struct to pass to client function. @type t :: schema_response() | map_response()
# TODO: Fix error propagation.
@type schema_response ::
{:ok, struct()}
| {:error, :status, Tesla.Env.t()}
| {:error, :validation, Ecto.Changeset.t()}
| {:error, :request, any()}
@type map_response ::
{:ok, map()}
| {:error, :status, Tesla.Env.t()}
| {:error, :validation, Ecto.Changeset.t()}
| {:error, :request, any()}
@adapter {Tesla.Adapter.Finch, name: ArchitexWeb.HTTPClient} @adapter {Tesla.Adapter.Finch, name: ArchitexWeb.HTTPClient}
@doc """
Get a Tesla client for the given server name, to be used for
interacting with other homeservers.
"""
@spec client(String.t()) :: Tesla.Client.t()
def client(server_name) do def client(server_name) do
Tesla.client( Tesla.client(
[ [
@ -23,6 +44,10 @@ defmodule ArchitexWeb.Federation.HTTPClient do
) )
end end
@doc """
Get the signing keys of a homeserver.
"""
@spec get_signing_keys(Tesla.Client.t()) :: {:ok, GetSigningKeys.t()} | :error
def get_signing_keys(client) do def get_signing_keys(client) do
path = RouteHelpers.key_path(Endpoint, :get_signing_keys) path = RouteHelpers.key_path(Endpoint, :get_signing_keys)
@ -53,40 +78,61 @@ defmodule ArchitexWeb.Federation.HTTPClient do
end end
end end
@doc """
Get the profile of a user.
"""
@spec query_profile(Tesla.Client.t(), String.t(), String.t() | nil) :: map_response()
def query_profile(client, user_id, field \\ nil) do def query_profile(client, user_id, field \\ nil) do
path = RouteHelpers.query_path(Endpoint, :profile) |> Tesla.build_url(user_id: user_id) path = RouteHelpers.query_path(Endpoint, :profile) |> Tesla.build_url(user_id: user_id)
path = if field, do: Tesla.build_url(path, field: field), else: path path = if field, do: Tesla.build_url(path, field: field), else: path
Tesla.get(client, path) tesla_request(:get, client, path)
end end
def get_event(client, event_id) do # def get_event(client, event_id) do
path = RouteHelpers.event_path(Endpoint, :event, event_id) # path = RouteHelpers.event_path(Endpoint, :event, event_id)
Tesla.get(client, path) # Tesla.get(client, path)
# end
# def get_state(client, room_id, event_id) do
# path =
# RouteHelpers.event_path(Endpoint, :state, room_id) |> Tesla.build_url(event_id: event_id)
# Tesla.get(client, path)
# end
# def get_state_ids(client, room_id, event_id) do
# path =
# RouteHelpers.event_path(Endpoint, :state_ids, room_id)
# |> Tesla.build_url(event_id: event_id)
# Tesla.get(client, path)
# end
# Perform a Tesla request and validate the response with the given
# Ecto schema struct.
@spec tesla_request(atom(), Tesla.Client.t(), String.t(), module()) :: t()
defp tesla_request(method, client, path, request_schema \\ nil) do
case Tesla.request(client, url: path, method: method) do
{:ok, %Tesla.Env{status: status} = env} when status != 200 ->
{:error, :status, env}
{:ok, %Tesla.Env{body: body}} ->
if request_schema do
case apply(request_schema, :parse, [body]) do
{:ok, response} ->
{:ok, response}
{:error, changeset} ->
{:error, :validation, changeset}
end end
def get_state(client, room_id, event_id) do
path =
RouteHelpers.event_path(Endpoint, :state, room_id) |> Tesla.build_url(event_id: event_id)
Tesla.get(client, path)
end
def get_state_ids(client, room_id, event_id) do
path =
RouteHelpers.event_path(Endpoint, :state_ids, room_id)
|> Tesla.build_url(event_id: event_id)
Tesla.get(client, path)
end
defp tesla_request(method, client, path, request_schema) do
with {:ok, %Tesla.Env{body: body}} <- Tesla.request(client, url: path, method: method),
%Ecto.Changeset{valid?: true} = cs <- apply(request_schema, :changeset, [body]) do
{:ok, Ecto.Changeset.apply_changes(cs)}
else else
_ -> :error {:ok, body}
end
{:error, tesla_error} ->
{:error, :request, tesla_error}
end end
end end
end end

View file

@ -1,7 +1,13 @@
defmodule ArchitexWeb.Federation.Request.GetSigningKeys do defmodule ArchitexWeb.Federation.Request.GetSigningKeys do
use Ecto.Schema use ArchitexWeb.Request
import Ecto.Changeset @type t :: %__MODULE__{
server_name: String.t(),
verify_keys: %{optional(String.t()) => %{String.t() => String.t()}},
old_verify_keys: %{optional(String.t()) => map()},
signatures: %{optional(String.t()) => %{optional(String.t()) => String.t()}},
valid_until_ts: integer()
}
@primary_key false @primary_key false
embedded_schema do embedded_schema do
@ -12,17 +18,16 @@ defmodule ArchitexWeb.Federation.Request.GetSigningKeys do
field :valid_until_ts, :integer field :valid_until_ts, :integer
end end
def changeset(params) do def changeset(data, params) do
# TODO: There must be a better way to validate embedded maps? data
%__MODULE__{}
|> cast(params, [:server_name, :verify_keys, :old_verify_keys, :signatures, :valid_until_ts]) |> cast(params, [:server_name, :verify_keys, :old_verify_keys, :signatures, :valid_until_ts])
|> validate_required([:server_name, :verify_keys, :valid_until_ts]) |> validate_required([:server_name, :verify_keys, :valid_until_ts])
|> Architex.validate_change_simple(:verify_keys, fn map -> |> Architex.validate_change_truthy(:verify_keys, fn map ->
Enum.all?(map, fn {_, map} -> Enum.all?(map, fn {_, map} ->
is_map_key(map, "key") is_map_key(map, "key")
end) end)
end) end)
|> Architex.validate_change_simple(:old_verify_keys, fn map -> |> Architex.validate_change_truthy(:old_verify_keys, fn map ->
Enum.all?(map, fn Enum.all?(map, fn
{_, %{"key" => key, "expired_ts" => expired_ts}} {_, %{"key" => key, "expired_ts" => expired_ts}}
when is_binary(key) and is_integer(expired_ts) -> when is_binary(key) and is_integer(expired_ts) ->