From 45f55083fc42d2f463a8a81e3605b08507d1383c Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:05:55 +0200 Subject: [PATCH] Switch to HTTP --- config/test.exs | 3 + lib/birdy_chat/dispatcher.ex | 29 +++++++ lib/birdy_chat/message.ex | 27 ++++-- lib/birdy_chat_web/api/messages/controller.ex | 20 +---- .../api/server/internal/controller.ex | 34 ++++++++ .../api/server/internal/json.ex | 18 ++++ lib/birdy_chat_web/router.ex | 1 + .../api/internal/messages_test.exs | 82 +++++++++++++++++++ test/birdy_chat_web/api/messages_test.exs | 37 +++++++-- 9 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 lib/birdy_chat/dispatcher.ex create mode 100644 lib/birdy_chat_web/api/server/internal/controller.ex create mode 100644 lib/birdy_chat_web/api/server/internal/json.ex create mode 100644 test/birdy_chat_web/api/internal/messages_test.exs diff --git a/config/test.exs b/config/test.exs index 3d83e54..be9faa1 100644 --- a/config/test.exs +++ b/config/test.exs @@ -23,6 +23,9 @@ config :phoenix, :plug_init_mode, :runtime config :phoenix_live_view, enable_expensive_runtime_checks: true +# Mock out HTTP requests in test +config :birdy_chat, BirdyChat.Dispatcher, req_opts: [plug: {Req.Test, BirdyChat.Dispatcher}] + # Sort query params output of verified routes for robust url comparisons config :phoenix, sort_verified_routes_query_params: true diff --git a/lib/birdy_chat/dispatcher.ex b/lib/birdy_chat/dispatcher.ex new file mode 100644 index 0000000..31e134e --- /dev/null +++ b/lib/birdy_chat/dispatcher.ex @@ -0,0 +1,29 @@ +defmodule BirdyChat.Dispatcher do + def dispatch(%Ecto.Changeset{changes: changes} = changeset) do + case changes do + %{routing: :local} -> BirdyChat.MessageWriter.write(changeset.changes) + %{routing: :remote} -> send_to_remote(changeset.changes) + end + end + + def send_to_remote(%{server: server, to: to} = message) do + {name, base_url} = + BirdyChat.Identity.peers() + |> Enum.find(fn {name, _url} -> name == server end) + + api_url = base_url <> "/api/internal" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", to) + + result = + Req.new(url: api_url, retry: false, method: :post) + |> Req.merge(req_opts()) + |> Req.Request.put_new_header("authorization", token) + |> Req.merge(json: message) + |> Req.Request.run_request() + end + + def req_opts do + Application.get_env(:birdy_chat, __MODULE__) + |> Access.get(:req_opts, []) + end +end diff --git a/lib/birdy_chat/message.ex b/lib/birdy_chat/message.ex index 7383466..d3adba7 100644 --- a/lib/birdy_chat/message.ex +++ b/lib/birdy_chat/message.ex @@ -5,6 +5,8 @@ defmodule BirdyChat.Message do field :from, :string field :to, :string field :message, :string + field :routing, Ecto.Enum, values: [:remote, :local] + field :server, :string end def validate(params) do @@ -12,6 +14,7 @@ defmodule BirdyChat.Message do %__MODULE__{} |> Ecto.Changeset.cast(params, [:from, :to, :message]) |> Ecto.Changeset.validate_required([:from, :to, :message]) + |> put_routing() if changeset.valid? do {:ok, changeset} @@ -20,20 +23,32 @@ defmodule BirdyChat.Message do end end - def find_peer(%{to: to}) do + defp put_routing(%Ecto.Changeset{changes: %{to: to}} = changeset) do identity = BirdyChat.Identity.identity() if String.starts_with?(to, identity) do - {:ok, :local} + changeset + |> Ecto.Changeset.put_change(:routing, :local) + |> Ecto.Changeset.put_change(:server, identity) else - result = + server = BirdyChat.Identity.peers() |> Enum.find(fn {name, _url} -> String.starts_with?(to, name) end) - case result do - {name, _url} -> {:ok, name} - nil -> {:error, :not_found} + case server do + {name, _url} -> + changeset + |> Ecto.Changeset.put_change(:routing, :remote) + |> Ecto.Changeset.put_change(:server, name) + + nil -> + changeset + |> Ecto.Changeset.add_error(:server, "unknown 'to' server") end end end + + defp put_routing(%Ecto.Changeset{} = changeset) do + changeset + end end diff --git a/lib/birdy_chat_web/api/messages/controller.ex b/lib/birdy_chat_web/api/messages/controller.ex index 1c09fa5..66161bd 100644 --- a/lib/birdy_chat_web/api/messages/controller.ex +++ b/lib/birdy_chat_web/api/messages/controller.ex @@ -4,27 +4,13 @@ defmodule BirdyChatWeb.Api.Messages.Controller do def create(conn, params) do case BirdyChat.Message.validate(params) do {:ok, changeset} -> - case BirdyChat.Message.find_peer(changeset.changes) do - {:ok, :local} -> - case BirdyChat.MessageWriter.write(changeset.changes) do - :ok -> - conn - |> put_status(:created) - |> render(:create, message: changeset.changes) - end - - {:ok, peer_name} -> - BirdyChatWeb.ServerChannel.broadcast!(peer_name, changeset.changes) - + case BirdyChat.Dispatcher.dispatch(changeset) do + :ok -> conn |> put_status(:created) |> render(:create, message: changeset.changes) - {:error, :not_found} -> - changeset = - changeset - |> Ecto.Changeset.add_error(:to, "Unknown 'to' server") - + :error -> conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset) diff --git a/lib/birdy_chat_web/api/server/internal/controller.ex b/lib/birdy_chat_web/api/server/internal/controller.ex new file mode 100644 index 0000000..4d6ed90 --- /dev/null +++ b/lib/birdy_chat_web/api/server/internal/controller.ex @@ -0,0 +1,34 @@ +defmodule BirdyChatWeb.Api.Server.Internal.Controller do + 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 + conn + |> put_status(:forbidden) + |> render(:error, message: "Unauthorised") + end + end + + defp authorised?(headers, %{"from" => from}) do + case Enum.find(headers, fn {key, _value} -> key == "authorization" end) do + nil -> + false + + {"authorization", token} -> + case Phoenix.Token.verify(BirdyChatWeb.Endpoint, "serverAuth", token, max_age: 1200) do + {:ok, id} -> id == from + {:error, :invalid} -> false + end + end + end +end diff --git a/lib/birdy_chat_web/api/server/internal/json.ex b/lib/birdy_chat_web/api/server/internal/json.ex new file mode 100644 index 0000000..c45c2a0 --- /dev/null +++ b/lib/birdy_chat_web/api/server/internal/json.ex @@ -0,0 +1,18 @@ +defmodule BirdyChatWeb.Api.Server.Internal.JSON do + def render("create.json", %{message: message}) do + message + end + + def render("error.json", %{message: message}) do + %{errors: %{"general" => Gettext.dgettext(BirdyChatWeb.Gettext, "errors", message, [])}} + end + + def render("error.json", %{changeset: changeset}) do + errors = Ecto.Changeset.traverse_errors(changeset, &get_error/1) + %{errors: errors} + end + + def get_error({msg, opts}) do + Gettext.dgettext(BirdyChatWeb.Gettext, "errors", msg, opts) + end +end diff --git a/lib/birdy_chat_web/router.ex b/lib/birdy_chat_web/router.ex index 0dc6d3b..90954c5 100644 --- a/lib/birdy_chat_web/router.ex +++ b/lib/birdy_chat_web/router.ex @@ -24,6 +24,7 @@ defmodule BirdyChatWeb.Router do pipe_through [:api] post "/messages", Messages.Controller, :create + post "/internal", Server.Internal.Controller, :create end # Other scopes may use custom stacks. diff --git a/test/birdy_chat_web/api/internal/messages_test.exs b/test/birdy_chat_web/api/internal/messages_test.exs new file mode 100644 index 0000000..60b85c7 --- /dev/null +++ b/test/birdy_chat_web/api/internal/messages_test.exs @@ -0,0 +1,82 @@ +defmodule BirdyChatWeb.Api.Internal.MessagesTest do + use BirdyChatWeb.ConnCase, async: true + + setup do + url = ~p"/api/internal" + %{url: url} + end + + describe "authorisation" do + setup %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + + %{conn: conn} + end + + test "returns 403 when authorisation token is missing", %{url: url, conn: conn} do + message = %{"from" => "test2-user", "to" => "test2-someone", "message" => "123"} + body = Jason.encode!(message) + conn = post(conn, url, body) + assert result = json_response(conn, :forbidden) + assert result == %{"errors" => %{"general" => "Unauthorised"}} + end + + test "returns 403 when authorisation token is invalid", %{url: url, conn: conn} do + server_id = "test2-user" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "FakeAuth", server_id) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", token) + + message = %{"from" => "test2-user", "to" => "test2-someone", "message" => "123"} + body = Jason.encode!(message) + conn = post(conn, url, body) + assert result = json_response(conn, :forbidden) + assert result == %{"errors" => %{"general" => "Unauthorised"}} + end + end + + describe "POST /api/server/messages" do + setup %{conn: conn} do + unique_user_id = System.unique_integer([:positive]) + username = "test1-user#{unique_user_id}" + + path = Application.app_dir(:birdy_chat, ["priv", "messages", "#{username}.txt"]) + on_exit(fn -> File.rm(path) end) + + server_id = "test2-user" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", token) + + %{conn: conn, username: username, path: path} + end + + test "saves message locally", %{conn: conn, username: username, url: url, path: path} do + message = %{"from" => "test2-user", "to" => username, "message" => "123"} + body = Jason.encode!(message) + conn = post(conn, url, body) + assert result = json_response(conn, :created) + + expected_result = %{ + "from" => "test2-user", + "message" => "123", + "to" => username, + "routing" => "local", + "server" => "test1" + } + + assert result == expected_result + + contents = File.read!(path) + assert contents == "test2-user: 123\n" + end + end +end diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index ba12e7d..cdd1328 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -36,22 +36,26 @@ defmodule BirdyChatWeb.Api.MessagesTest do conn = post(conn, url, payload) assert result = json_response(conn, :unprocessable_entity) - expected_result = %{"errors" => %{"to" => ["Unknown 'to' server"]}} + expected_result = %{"errors" => %{"server" => ["unknown 'to' server"]}} assert result == expected_result end test "broadcasts message to websocket", %{conn: conn, url: url} do message = %{from: "test1-user", to: "test2-user", message: "123"} - string_message = %{"from" => "test1-user", "message" => "123", "to" => "test2-user"} + + string_message = %{ + "from" => "test1-user", + "message" => "123", + "to" => "test2-user", + "routing" => "remote", + "server" => "test2" + } payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) assert result == string_message - - assert_broadcast "new_message", {:binary, encoded_message} - assert :erlang.binary_to_term(encoded_message, [:safe]) == message end end @@ -74,10 +78,20 @@ defmodule BirdyChatWeb.Api.MessagesTest do test "returns message and 201 when successful", %{conn: conn, url: url} do message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} + payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) - assert result == message + + expected_result = %{ + "from" => "test2-user", + "to" => "test1-user", + "message" => "123", + "routing" => "local", + "server" => "test1" + } + + assert result == expected_result end test "writes message to file", %{conn: conn, url: url} do @@ -85,7 +99,16 @@ defmodule BirdyChatWeb.Api.MessagesTest do payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) - assert result == message + + expected_result = %{ + "from" => "test2-user", + "to" => "test1-user", + "message" => "123", + "routing" => "local", + "server" => "test1" + } + + assert result == expected_result path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path)