From f56538346826c26017e7dcc7b9789e9309bfc9d4 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Thu, 9 Sep 2021 16:00:58 +0200 Subject: [PATCH] Federate client profile requests --- lib/architex.ex | 4 +- .../client/controllers/profile_controller.ex | 30 +++++- .../controllers/query_controller.ex | 20 +++- lib/architex_web/federation/http_client.ex | 94 ++++++++++++++----- .../federation/request/get_signing_keys.ex | 19 ++-- 5 files changed, 125 insertions(+), 42 deletions(-) diff --git a/lib/architex.ex b/lib/architex.ex index 09657d8..e09c623 100644 --- a/lib/architex.ex +++ b/lib/architex.ex @@ -174,9 +174,9 @@ defmodule Architex do @doc """ 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() - def validate_change_simple(changeset, field, func) do + def validate_change_truthy(changeset, field, func) do augmented_func = fn _, val -> if func.(val), do: [], else: [{field, "invalid"}] end diff --git a/lib/architex_web/client/controllers/profile_controller.ex b/lib/architex_web/client/controllers/profile_controller.ex index 9d9ea34..153d66a 100644 --- a/lib/architex_web/client/controllers/profile_controller.ex +++ b/lib/architex_web/client/controllers/profile_controller.ex @@ -7,6 +7,7 @@ defmodule ArchitexWeb.Client.ProfileController do alias Architex.{Repo, Account} alias Architex.Types.UserId + alias ArchitexWeb.Federation.HTTPClient alias Plug.Conn alias Ecto.Changeset @@ -33,8 +34,15 @@ defmodule ArchitexWeb.Client.ProfileController do put_error(conn, :not_found, "User was not found.") end else - # TODO: Use federation to lookup information. - put_error(conn, :not_found, "User was not found.") + 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.") + end end :error -> @@ -77,8 +85,15 @@ defmodule ArchitexWeb.Client.ProfileController do put_error(conn, :not_found, "User was not found.") end else - # TODO: Use federation to lookup information. - put_error(conn, :not_found, "User was not found.") + 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.") + end end :error -> @@ -108,7 +123,12 @@ defmodule ArchitexWeb.Client.ProfileController do update_property(conn, :avatar_url, avatar_url, user_id) 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 account |> Changeset.change([{property_key, property}]) diff --git a/lib/architex_web/federation/controllers/query_controller.ex b/lib/architex_web/federation/controllers/query_controller.ex index 4784e2d..3468f02 100644 --- a/lib/architex_web/federation/controllers/query_controller.ex +++ b/lib/architex_web/federation/controllers/query_controller.ex @@ -38,15 +38,27 @@ defmodule ArchitexWeb.Federation.QueryController do Action for GET /_matrix/federation/v1/query/profile. """ 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 if domain == Architex.server_name() do case Repo.one(from a in Account, where: a.localpart == ^localpart) do - %Account{} -> - # TODO: Return displayname and avatar_url when we implement them. + %Account{displayname: displayname, avatar_url: avatar_url} -> + 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 |> put_status(200) - |> json(%{}) + |> json(data) nil -> put_error(conn, :not_found, "User does not exist.") diff --git a/lib/architex_web/federation/http_client.ex b/lib/architex_web/federation/http_client.ex index 46d6ccd..9e14261 100644 --- a/lib/architex_web/federation/http_client.ex +++ b/lib/architex_web/federation/http_client.ex @@ -1,4 +1,9 @@ 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 alias ArchitexWeb.Endpoint @@ -6,11 +11,27 @@ defmodule ArchitexWeb.Federation.HTTPClient do alias ArchitexWeb.Federation.Middleware.SignRequest alias ArchitexWeb.Router.Helpers, as: RouteHelpers - # TODO: Maybe create database-backed homeserver struct to pass to client function. - # TODO: Fix error propagation. + @type t :: schema_response() | map_response() + + @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} + @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 Tesla.client( [ @@ -23,6 +44,10 @@ defmodule ArchitexWeb.Federation.HTTPClient do ) 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 path = RouteHelpers.key_path(Endpoint, :get_signing_keys) @@ -53,40 +78,61 @@ defmodule ArchitexWeb.Federation.HTTPClient do 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 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 - Tesla.get(client, path) + tesla_request(:get, client, path) end - def get_event(client, event_id) do - path = RouteHelpers.event_path(Endpoint, :event, event_id) + # def get_event(client, event_id) do + # path = RouteHelpers.event_path(Endpoint, :event, event_id) - Tesla.get(client, path) - end + # 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) + # 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 + # 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) + # 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 + # 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 - _ -> :error + # 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 + else + {:ok, body} + end + + {:error, tesla_error} -> + {:error, :request, tesla_error} end end end diff --git a/lib/architex_web/federation/request/get_signing_keys.ex b/lib/architex_web/federation/request/get_signing_keys.ex index 0bd21a0..be65e29 100644 --- a/lib/architex_web/federation/request/get_signing_keys.ex +++ b/lib/architex_web/federation/request/get_signing_keys.ex @@ -1,7 +1,13 @@ 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 embedded_schema do @@ -12,17 +18,16 @@ defmodule ArchitexWeb.Federation.Request.GetSigningKeys do field :valid_until_ts, :integer end - def changeset(params) do - # TODO: There must be a better way to validate embedded maps? - %__MODULE__{} + def changeset(data, params) do + data |> cast(params, [:server_name, :verify_keys, :old_verify_keys, :signatures, :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} -> is_map_key(map, "key") end) end) - |> Architex.validate_change_simple(:old_verify_keys, fn map -> + |> Architex.validate_change_truthy(:old_verify_keys, fn map -> Enum.all?(map, fn {_, %{"key" => key, "expired_ts" => expired_ts}} when is_binary(key) and is_integer(expired_ts) ->