diff --git a/lib/matrix_server.ex b/lib/matrix_server.ex index e77af16..29adb31 100644 --- a/lib/matrix_server.ex +++ b/lib/matrix_server.ex @@ -1,4 +1,8 @@ defmodule MatrixServer do + @moduledoc """ + Utility functions used throughout the project. + """ + alias MatrixServer.EncodableMap @random_string_alphabet Enum.into(?a..?z, []) ++ Enum.into(?A..?Z, []) @@ -6,30 +10,49 @@ defmodule MatrixServer do @ipv4_regex ~r/^(?[^:]+)(?:\d{1,5})?$/ @dns_regex ~r/^[[:alnum:]-.]{1,255}$/ + @doc """ + Get the full MXID for a user's localpart, using the homeserver's + server name as the domain. + """ @spec get_mxid(String.t()) :: String.t() def get_mxid(localpart) when is_binary(localpart) do "@#{localpart}:#{server_name()}" end + @doc """ + Get the homeserver's server name. + """ @spec server_name() :: String.t() def server_name do Application.get_env(:matrix_server, :server_name) end + @doc """ + Get a regex to match a user's localpart. + """ @spec localpart_regex() :: Regex.t() def localpart_regex, do: ~r/^([a-z0-9\._=\/])+$/ - @spec random_string(pos_integer()) :: String.t() - def random_string(length), do: random_string(length, @random_string_alphabet) - + @doc """ + Get a random string of `length` length and using `alphabet`'s characters. + """ @spec random_string(pos_integer(), Enum.t()) :: String.t() - def random_string(length, alphabet) when length >= 1 do + def random_string(length, alphabet \\ @random_string_alphabet) when length >= 1 do for _ <- 1..length, into: "", do: <> end + @doc """ + Get the homeserver's default room version. + """ @spec default_room_version() :: String.t() def default_room_version, do: "7" + @doc """ + Get the domain from an ID. + + Return `nil` if no domain was found. + No validation on the domain is performed whatsoever. + """ @spec get_domain(String.t()) :: String.t() | nil def get_domain(id) do case String.split(id, ":", parts: 2) do @@ -38,6 +61,12 @@ defmodule MatrixServer do end end + @doc """ + Get the localpart from a MXID. + + Return `nil` if no localpart was found. + No validation on the localpart is performed whatsoever. + """ @spec get_localpart(String.t()) :: String.t() | nil def get_localpart(id) do with [part, _] <- String.split(id, ":", parts: 2), @@ -48,9 +77,12 @@ defmodule MatrixServer do end end - # https://elixirforum.com/t/22709/9 + @doc """ + Check whether the given list contains duplicates. + """ @spec has_duplicates?(list()) :: boolean() def has_duplicates?(list) do + # https://elixirforum.com/t/22709/9 list |> Enum.reduce_while(%MapSet{}, fn x, acc -> if MapSet.member?(acc, x), do: {:halt, false}, else: {:cont, MapSet.put(acc, x)} @@ -58,15 +90,25 @@ defmodule MatrixServer do |> is_boolean() end - # https://matrix.org/docs/spec/appendices#unpadded-base64 + @doc """ + Encode the given string using unpadded base64. + + Unpadded base64 is the same as base64, except the "=" padding is removed. + """ @spec encode_unpadded_base64(String.t()) :: String.t() def encode_unpadded_base64(data) do + # https://matrix.org/docs/spec/appendices#unpadded-base64 data |> Base.encode64() |> String.trim_trailing("=") end - # Decode (possibly unpadded) base64. + @doc """ + Decode the given base64 string. + + The base64 is allowed to be unpadded, as specified in `encode_unpadded_base64`. + Return `:error` on decode error. + """ @spec decode_base64(String.t()) :: {:ok, String.t()} | :error def decode_base64(data) when is_binary(data) do rem = rem(String.length(data), 4) @@ -74,6 +116,13 @@ defmodule MatrixServer do Base.decode64(padded_data) end + @doc """ + Encode the given map as canonical JSON. + + See [the Matrix docs](https://matrix.org/docs/spec/appendices#canonical-json) for explanation + for canonical JSON. + Return an error if the map could not be encoded. + """ @spec encode_canonical_json(map()) :: {:ok, String.t()} | {:error, Jason.EncodeError.t()} def encode_canonical_json(object) do object @@ -81,9 +130,13 @@ defmodule MatrixServer do |> Jason.encode() end - # https://stackoverflow.com/questions/41523762/41671211 + @doc """ + Convert a struct to a map, removing any schema or association fields, + as well as fields that have `nil` values. + """ @spec to_serializable_map(struct()) :: map() def to_serializable_map(struct) do + # https://stackoverflow.com/questions/41523762/41671211 association_fields = if Kernel.function_exported?(struct.__struct__, :__schema__, 1) do struct.__struct__.__schema__(:associations) @@ -101,6 +154,9 @@ defmodule MatrixServer do |> Enum.into(%{}) end + @doc """ + Serialize and encode the given struct. + """ @spec serialize_and_encode(struct()) :: {:ok, String.t()} | {:error, Jason.EncodeError.t()} def serialize_and_encode(struct) do struct @@ -108,6 +164,11 @@ defmodule MatrixServer do |> encode_canonical_json() end + @doc """ + Add a signature to the given map under the `:signatures` key. + + If the map has no `:signatures` key, it is created. + """ @spec add_signature(map(), String.t(), String.t()) :: map() def add_signature(object, key_id, sig) when not is_map_key(object, :signatures) do Map.put(object, :signatures, %{MatrixServer.server_name() => %{key_id => sig}}) @@ -120,6 +181,9 @@ defmodule MatrixServer do %{object | signatures: new_sigs} end + @doc """ + Validate a changeset's field where the reason for invalidation is not needed. + """ @spec validate_change_simple(Ecto.Changeset.t(), atom(), (term() -> boolean())) :: Ecto.Changeset.t() def validate_change_simple(changeset, field, func) do @@ -130,8 +194,11 @@ defmodule MatrixServer do Ecto.Changeset.validate_change(changeset, field, augmented_func) end - # Returns a Boolean whether the signature is valid. - # Also returns false on ArgumentError. + @doc """ + Return a Boolean whether the given signature is valid. + + Returns `false` if `:enacl` throws an exception. + """ @spec sign_verify(binary(), String.t(), binary()) :: boolean() def sign_verify(sig, text, key) do try do @@ -141,6 +208,9 @@ defmodule MatrixServer do end end + @doc """ + Get the earliest of the two given `DateTime`s. + """ @spec min_datetime(DateTime.t(), DateTime.t()) :: DateTime.t() def min_datetime(datetime1, datetime2) do if DateTime.compare(datetime1, datetime2) == :gt do @@ -150,6 +220,13 @@ defmodule MatrixServer do end end + @doc """ + Encode the given string using URL-safe base64. + + URL-safe base64 is the same was unpadded base64, except "+" is replaced by "-" + and "/" is replaced by "_". + See [the Matrix docs](https://spec.matrix.org/unstable/rooms/v4/) for more details. + """ @spec encode_url_safe_base64(String.t()) :: String.t() def encode_url_safe_base64(data) do data @@ -158,6 +235,12 @@ defmodule MatrixServer do |> String.replace("/", "_") end + @doc """ + Check whether the given domain (including port) is valid. + + The domain could be a DNS name, IPv4 or IPv6 address. + See [the Matrix docs](https://matrix.org/docs/spec/appendices#server-name) for more details. + """ @spec valid_domain?(String.t()) :: boolean() def valid_domain?(domain) do if String.starts_with?(domain, "[") do diff --git a/lib/matrix_server/key_server.ex b/lib/matrix_server/key_server.ex index 39b4e07..fb4ba83 100644 --- a/lib/matrix_server/key_server.ex +++ b/lib/matrix_server/key_server.ex @@ -1,4 +1,10 @@ defmodule MatrixServer.KeyServer do + @moduledoc """ + A GenServer holding the homeserver's keys, and responsible for signing objects. + + Currently, it only supports one key pair that cannot expire. + """ + use GenServer # TODO: only support one signing key for now. @@ -10,11 +16,22 @@ defmodule MatrixServer.KeyServer do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end + @doc """ + Sign the given object using the homeserver's signing keys. + + Return the signature and the key ID used. + On error, return `:error`. + """ @spec sign_object(map()) :: {:ok, String.t(), String.t()} | :error def sign_object(object) do GenServer.call(__MODULE__, {:sign_object, object}) end + @doc """ + Get the homeserver's signing keys. + + Return a list of tuples, each holding the key ID and the key itself. + """ @spec get_own_signing_keys() :: list({String.t(), binary()}) def get_own_signing_keys() do GenServer.call(__MODULE__, :get_own_signing_keys) diff --git a/lib/matrix_server/room_server.ex b/lib/matrix_server/room_server.ex index a9a393a..be58038 100644 --- a/lib/matrix_server/room_server.ex +++ b/lib/matrix_server/room_server.ex @@ -1,4 +1,11 @@ defmodule MatrixServer.RoomServer do + @moduledoc """ + A GenServer to hold and manipulate the state of a Matrix room. + + Each RoomServer corresponds to one Matrix room that the homeserver participates in. + The RoomServers are supervised by a DynamicSupervisor RoomServer.Supervisor. + """ + use GenServer import Ecto.Query @@ -18,17 +25,16 @@ defmodule MatrixServer.RoomServer do GenServer.start_link(__MODULE__, opts, name: name) end - @spec get_room_server(Room.t()) :: {:error, :not_found} | DynamicSupervisor.on_start_child() - def get_room_server(%Room{id: room_id}), do: get_room_server(room_id) + @doc """ + Get the PID of the RoomServer for a room. - # Get room server pid, or spin one up for the room. - # If the room does not exist, return an error. + If the given room has no running RoomServer yet, it is created. + If the given room does not exist, an error is returned. + """ @spec get_room_server(String.t()) :: {:error, :not_found} | DynamicSupervisor.on_start_child() def get_room_server(room_id) do + # TODO: Might be wise to use a transaction here to prevent race conditions. case Repo.one(from r in Room, where: r.id == ^room_id) do - nil -> - {:error, :not_found} - %Room{state: serialized_state_set} -> case Registry.lookup(@registry, room_id) do [{pid, _}] -> @@ -43,9 +49,19 @@ defmodule MatrixServer.RoomServer do DynamicSupervisor.start_child(@supervisor, {__MODULE__, opts}) end + + nil -> + {:error, :not_found} end end + @doc """ + Create a new Matrix room. + + The new room is created with the given `account` as creator. + Events are inserted into the new room depending on the input `input` and according + to the [Matrix documentation](https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-createroom). + """ @spec create_room( pid(), MatrixServer.Account.t(), @@ -55,16 +71,30 @@ defmodule MatrixServer.RoomServer do GenServer.call(pid, {:create_room, account, input}) end - @spec server_in_room(pid(), String.t()) :: boolean() - def server_in_room(pid, domain) do - GenServer.call(pid, {:server_in_room, domain}) + @doc """ + Check whether the given server participates in a room. + + Check whether any participant of the room has a server name matching + the given `domain`. + """ + @spec server_in_room?(pid(), String.t()) :: boolean() + def server_in_room?(pid, domain) do + GenServer.call(pid, {:server_in_room?, domain}) end + @doc """ + Get the state of a room, before the given event was inserted. + + Return a list of all state events and the auth chain. + """ @spec get_state_at_event(pid(), Event.t()) :: {[Event.t()], [Event.t()]} def get_state_at_event(pid, event) do GenServer.call(pid, {:get_state_at_event, event}) end + @doc """ + Same as `get_state_at_event/2`, except returns the lists as event IDs. + """ @spec get_state_ids_at_event(pid(), Event.t()) :: {[String.t()], [String.t()]} def get_state_ids_at_event(pid, event) do GenServer.call(pid, {:get_state_ids_at_event, event}) @@ -101,7 +131,7 @@ defmodule MatrixServer.RoomServer do end end - def handle_call({:server_in_room, domain}, _from, %{state_set: state_set} = state) do + def handle_call({:server_in_room?, domain}, _from, %{state_set: state_set} = state) do result = Enum.any?(state_set, fn {{"m.room.member", user_id}, %Event{content: %{"membership" => "join"}}} -> diff --git a/lib/matrix_server_web/federation/controllers/event_controller.ex b/lib/matrix_server_web/federation/controllers/event_controller.ex index 5a7d05e..3f1ed6f 100644 --- a/lib/matrix_server_web/federation/controllers/event_controller.ex +++ b/lib/matrix_server_web/federation/controllers/event_controller.ex @@ -18,7 +18,7 @@ defmodule MatrixServerWeb.Federation.EventController do %Event{room: room} = event -> case RoomServer.get_room_server(room) do {:ok, pid} -> - if RoomServer.server_in_room(pid, origin) do + if RoomServer.server_in_room?(pid, origin) do data = Transaction.new([event]) conn @@ -57,7 +57,13 @@ defmodule MatrixServerWeb.Federation.EventController do def state_ids(conn, _), do: put_error(conn, :missing_param) - @spec get_state_or_state_ids(Plug.Conn.t(), :state | :state_ids, String.t(), String.t(), String.t()) :: Plug.Conn.t() + @spec get_state_or_state_ids( + Plug.Conn.t(), + :state | :state_ids, + String.t(), + String.t(), + String.t() + ) :: Plug.Conn.t() defp get_state_or_state_ids(conn, state_or_state_ids, origin, event_id, room_id) do query = Event @@ -68,7 +74,7 @@ defmodule MatrixServerWeb.Federation.EventController do %Event{room: room} = event -> case RoomServer.get_room_server(room) do {:ok, pid} -> - if RoomServer.server_in_room(pid, origin) do + if RoomServer.server_in_room?(pid, origin) do {state_events, auth_chain} = case state_or_state_ids do :state -> RoomServer.get_state_at_event(pid, event) diff --git a/lib/matrix_server_web/federation/http_client.ex b/lib/matrix_server_web/federation/http_client.ex index fff2920..4006665 100644 --- a/lib/matrix_server_web/federation/http_client.ex +++ b/lib/matrix_server_web/federation/http_client.ex @@ -75,7 +75,8 @@ defmodule MatrixServerWeb.Federation.HTTPClient do 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) + RouteHelpers.event_path(Endpoint, :state_ids, room_id) + |> Tesla.build_url(event_id: event_id) Tesla.get(client, path) end