diff --git a/lib/birdy_chat/identity.ex b/lib/birdy_chat/identity.ex index cbb502f..3c62f1b 100644 --- a/lib/birdy_chat/identity.ex +++ b/lib/birdy_chat/identity.ex @@ -1,4 +1,15 @@ defmodule BirdyChat.Identity do + @moduledoc """ + Server identity, populated on startup from the following environment variables: + + - BIRDY_CHAT_IDENTITY: Name of the server, a string that can be formatted into an integer. + - BIRDY_CHAT_PEERS: List of peer servers in format of + `1::http://localhost:4001;2::http://localhost:4002` + + When the environment variables are not present the server starts in test mode and can only + communicate with itself. + """ + use Agent defstruct [:identity, :peers, :mode] diff --git a/lib/birdy_chat/message.ex b/lib/birdy_chat/message.ex index d3adba7..a28e00c 100644 --- a/lib/birdy_chat/message.ex +++ b/lib/birdy_chat/message.ex @@ -1,4 +1,9 @@ defmodule BirdyChat.Message do + @moduledoc """ + Main module for input validation. I decided to re-use Ecto because of existence of Phoenix.Ecto + that clearly integrates the error messages produced from Ecto into HTTP plumbing of Phoenix. + """ + use Ecto.Schema embedded_schema do @@ -9,12 +14,20 @@ defmodule BirdyChat.Message do field :server, :string end - def validate(params) do + @doc """ + Validation for inter-server communication. It is essentially the same as validate/1 + but without the requirement to communicate with home server only. + + This can be also designed as a function that accepts other function or some configuration option + but two separately named functions are easier to understand and less prone to misuse. + """ + def validate_for_inter_server_use(params) do changeset = %__MODULE__{} |> Ecto.Changeset.cast(params, [:from, :to, :message]) |> Ecto.Changeset.validate_required([:from, :to, :message]) |> put_routing() + |> validate_is_local() if changeset.valid? do {:ok, changeset} @@ -23,6 +36,45 @@ defmodule BirdyChat.Message do end end + def validate(params) do + changeset = + %__MODULE__{} + |> Ecto.Changeset.cast(params, [:from, :to, :message]) + |> Ecto.Changeset.validate_required([:from, :to, :message]) + |> put_routing() + |> validate_home_server() + + if changeset.valid? do + {:ok, changeset} + else + {:error, changeset} + end + end + + defp validate_is_local(%Ecto.Changeset{changes: %{routing: :local}} = changeset) do + changeset + end + + defp validate_is_local(%Ecto.Changeset{} = changeset) do + changeset + |> Ecto.Changeset.add_error(:from, "you can only communicate with your home server") + end + + defp validate_home_server(%Ecto.Changeset{changes: %{from: from}} = changeset) do + identity = BirdyChat.Identity.identity() + + if String.starts_with?(from, identity) do + changeset + else + changeset + |> Ecto.Changeset.add_error(:from, "you can only communicate with your home server") + end + end + + defp validate_home_server(%Ecto.Changeset{} = changeset) do + changeset + end + defp put_routing(%Ecto.Changeset{changes: %{to: to}} = changeset) do identity = BirdyChat.Identity.identity() diff --git a/lib/birdy_chat_web.ex b/lib/birdy_chat_web.ex index 6d766a2..f38f95c 100644 --- a/lib/birdy_chat_web.ex +++ b/lib/birdy_chat_web.ex @@ -88,8 +88,8 @@ defmodule BirdyChatWeb do import BirdyChatWeb.CoreComponents # Common modules used in templates - alias Phoenix.LiveView.JS alias BirdyChatWeb.Layouts + alias Phoenix.LiveView.JS # Routes generation with the ~p sigil unquote(verified_routes()) diff --git a/lib/birdy_chat_web/api/messages/controller.ex b/lib/birdy_chat_web/api/messages/controller.ex index ea86b9b..ee0693b 100644 --- a/lib/birdy_chat_web/api/messages/controller.ex +++ b/lib/birdy_chat_web/api/messages/controller.ex @@ -1,4 +1,8 @@ defmodule BirdyChatWeb.Api.Messages.Controller do + @moduledoc """ + The endpoint to be used by users from the "home server". + """ + use BirdyChatWeb, :controller def create(conn, params) do diff --git a/lib/birdy_chat_web/api/messages/json.ex b/lib/birdy_chat_web/api/messages/json.ex index 74613fa..194bb7e 100644 --- a/lib/birdy_chat_web/api/messages/json.ex +++ b/lib/birdy_chat_web/api/messages/json.ex @@ -1,4 +1,6 @@ defmodule BirdyChatWeb.Api.Messages.JSON do + @moduledoc false + def render("create.json", %{message: message}) do message end diff --git a/lib/birdy_chat_web/api/server/internal/controller.ex b/lib/birdy_chat_web/api/server/internal/controller.ex index 4d6ed90..cdb9766 100644 --- a/lib/birdy_chat_web/api/server/internal/controller.ex +++ b/lib/birdy_chat_web/api/server/internal/controller.ex @@ -1,21 +1,36 @@ defmodule BirdyChatWeb.Api.Server.Internal.Controller do + @moduledoc """ + A controller for handling inter-server communication. It started off with using Erlang term + format instead of JSON as communication language but then I removed it for the following + reasons: + + 1. The messages are mostly binaries anyway, there is no big efficiency gain from skipping JSON. + 2. Testing JSON is much easier than testing erlang term format. + 3. Erlang term format can give an illusion of extra security but unless the transport is HTTPS + then the communication is still inherently unsafe. + 4. Erlang term format is difficult to handle for unfamiliar developers, you need to remember + about safe conversion to avoid atom exhaustion attacks or sending an `rm -rf /` function over + the wire. + + The endpoint is protected by simple authentication that requires the secret key of all servers + being the same. It is good enough for a demo, but for any real application it would need to be + reconsidered. + """ + use BirdyChatWeb, :controller def create(conn, params) do - if authorised?(conn.req_headers, params) do - case BirdyChat.Message.validate(params) do - {:ok, changeset} -> - case BirdyChat.MessageWriter.write(changeset.changes) do - :ok -> - conn - |> put_status(:created) - |> render(:create, message: changeset.changes) - end - end - else + with true <- authorised?(conn.req_headers, params), + {:ok, changeset} <- BirdyChat.Message.validate_for_inter_server_use(params), + :ok <- BirdyChat.MessageWriter.write(changeset.changes) do conn - |> put_status(:forbidden) - |> render(:error, message: "Unauthorised") + |> put_status(:created) + |> render(:create, message: changeset.changes) + else + _any -> + conn + |> put_status(:forbidden) + |> render(:error, message: "Unauthorised") end end diff --git a/lib/birdy_chat_web/api/server/internal/json.ex b/lib/birdy_chat_web/api/server/internal/json.ex index c45c2a0..56352d7 100644 --- a/lib/birdy_chat_web/api/server/internal/json.ex +++ b/lib/birdy_chat_web/api/server/internal/json.ex @@ -1,4 +1,6 @@ defmodule BirdyChatWeb.Api.Server.Internal.JSON do + @moduledoc false + def render("create.json", %{message: message}) do message end diff --git a/lib/birdy_chat_web/components/core_components.ex b/lib/birdy_chat_web/components/core_components.ex index b50a31b..59a56ef 100644 --- a/lib/birdy_chat_web/components/core_components.ex +++ b/lib/birdy_chat_web/components/core_components.ex @@ -29,6 +29,7 @@ defmodule BirdyChatWeb.CoreComponents do use Phoenix.Component use Gettext, backend: BirdyChatWeb.Gettext + alias Phoenix.HTML.Form alias Phoenix.LiveView.JS @doc """ @@ -200,9 +201,7 @@ defmodule BirdyChatWeb.CoreComponents do def input(%{type: "checkbox"} = assigns) do assigns = - assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) - end) + assign_new(assigns, :checked, fn -> Form.normalize_value("checkbox", assigns[:value]) end) ~H"""