From 984ac15084dcff4b8e696621c1afcfd19dddfce7 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sat, 28 Feb 2026 16:19:35 +0200 Subject: [PATCH 01/10] Prepare to join servers --- lib/birdy_chat/application.ex | 2 + lib/birdy_chat/identity.ex | 7 ++- lib/birdy_chat/message.ex | 17 ++++++ lib/birdy_chat/peer_client.ex | 9 +++ lib/birdy_chat/peer_supervisor.ex | 17 ++++++ lib/birdy_chat_web/api/messages/controller.ex | 23 ++++++- lib/birdy_chat_web/channels/server_channel.ex | 7 +++ test/birdy_chat/identity_test.exs | 4 +- test/birdy_chat/peer_test.exs | 3 + test/birdy_chat_web/api/messages_test.exs | 61 ++++++++++++++++--- .../channels/server_channel_test.exs | 2 +- .../controllers/page_controller_test.exs | 2 +- 12 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 lib/birdy_chat/peer_client.ex create mode 100644 lib/birdy_chat/peer_supervisor.ex create mode 100644 test/birdy_chat/peer_test.exs diff --git a/lib/birdy_chat/application.ex b/lib/birdy_chat/application.ex index 11c9fcb..dab31ec 100644 --- a/lib/birdy_chat/application.ex +++ b/lib/birdy_chat/application.ex @@ -10,6 +10,8 @@ defmodule BirdyChat.Application do children = [ BirdyChatWeb.Telemetry, BirdyChat.Identity, + BirdyChat.PeerSupervisor, + {Registry, keys: :unique, name: BirdyChat.PeerRegistry}, {DNSCluster, query: Application.get_env(:birdy_chat, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: BirdyChat.PubSub}, # Start a worker by calling: BirdyChat.Worker.start_link(arg) diff --git a/lib/birdy_chat/identity.ex b/lib/birdy_chat/identity.ex index 080386b..ae46c1d 100644 --- a/lib/birdy_chat/identity.ex +++ b/lib/birdy_chat/identity.ex @@ -49,7 +49,12 @@ defmodule BirdyChat.Identity do peers = System.get_env("BIRDY_CHAT_PEERS") case {identity, peers} do - {nil, nil} -> %__MODULE__{identity: "test", peers: [], mode: :singleton} + {nil, nil} -> + %__MODULE__{ + identity: "test1", + peers: %{"test2" => "http://localhost:4001"}, + mode: :test + } end end end diff --git a/lib/birdy_chat/message.ex b/lib/birdy_chat/message.ex index 3c86d43..7383466 100644 --- a/lib/birdy_chat/message.ex +++ b/lib/birdy_chat/message.ex @@ -19,4 +19,21 @@ defmodule BirdyChat.Message do {:error, changeset} end end + + def find_peer(%{to: to}) do + identity = BirdyChat.Identity.identity() + + if String.starts_with?(to, identity) do + {:ok, :local} + else + result = + 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} + end + end + end end diff --git a/lib/birdy_chat/peer_client.ex b/lib/birdy_chat/peer_client.ex new file mode 100644 index 0000000..49ed51e --- /dev/null +++ b/lib/birdy_chat/peer_client.ex @@ -0,0 +1,9 @@ +defmodule BirdyChat.PeerClient do + use Slipstream, restart: :temporary + + def start_link(args) do + name = args[:peer_name] + name = {:via, BirdyChat.PeerRegistry, {:peers, name}} + Slipstream.start_link(__MODULE__, args, name: name) + end +end diff --git a/lib/birdy_chat/peer_supervisor.ex b/lib/birdy_chat/peer_supervisor.ex new file mode 100644 index 0000000..0439df8 --- /dev/null +++ b/lib/birdy_chat/peer_supervisor.ex @@ -0,0 +1,17 @@ +defmodule BirdyChat.PeerSupervisor do + use DynamicSupervisor + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + def start_child(peer_name, peer_address) do + spec = {BirdyChat.PeerClient, peer_name: peer_name, peer_address: peer_address} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(init_arg) do + DynamicSupervisor.init(strategy: :one_for_one, extra_arguments: [init_arg]) + end +end diff --git a/lib/birdy_chat_web/api/messages/controller.ex b/lib/birdy_chat_web/api/messages/controller.ex index 79afd64..1c09fa5 100644 --- a/lib/birdy_chat_web/api/messages/controller.ex +++ b/lib/birdy_chat_web/api/messages/controller.ex @@ -4,11 +4,30 @@ defmodule BirdyChatWeb.Api.Messages.Controller do def create(conn, params) do case BirdyChat.Message.validate(params) do {:ok, changeset} -> - case BirdyChat.MessageWriter.write(changeset.changes) do - :ok -> + 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) + conn |> put_status(:created) |> render(:create, message: changeset.changes) + + {:error, :not_found} -> + changeset = + changeset + |> Ecto.Changeset.add_error(:to, "Unknown 'to' server") + + conn + |> put_status(:unprocessable_entity) + |> render(:error, changeset: changeset) end {:error, changeset} -> diff --git a/lib/birdy_chat_web/channels/server_channel.ex b/lib/birdy_chat_web/channels/server_channel.ex index cb83c2c..009aa1b 100644 --- a/lib/birdy_chat_web/channels/server_channel.ex +++ b/lib/birdy_chat_web/channels/server_channel.ex @@ -24,6 +24,13 @@ defmodule BirdyChatWeb.ServerChannel do end end + # This is how we send messages + def broadcast!(peer, message) do + channel = "server:#{peer}" + encoded_message = :erlang.term_to_binary(message) + BirdyChatWeb.Endpoint.broadcast!(channel, "new_message", {:binary, encoded_message}) + end + # Simple token-based authentication. Servers should use the same Phoenix secret key so they will # have the basis for generating tokens. defp authorised?(%{"token" => token}, server_id) do diff --git a/test/birdy_chat/identity_test.exs b/test/birdy_chat/identity_test.exs index 33bd710..e424e3a 100644 --- a/test/birdy_chat/identity_test.exs +++ b/test/birdy_chat/identity_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChat.IdentityTest do - use BirdyChat.DataCase + use BirdyChat.DataCase, async: true setup do registry = start_supervised!({Registry, keys: :unique, name: __MODULE__}) @@ -11,7 +11,7 @@ defmodule BirdyChat.IdentityTest do name = {:via, Registry, {__MODULE__, "identity"}} {:ok, process} = BirdyChat.Identity.start_link(name: name) mode = BirdyChat.Identity.mode(process) - assert mode == :singleton + assert mode == :test end test "can be started by giving values like environment variables" do diff --git a/test/birdy_chat/peer_test.exs b/test/birdy_chat/peer_test.exs new file mode 100644 index 0000000..600db31 --- /dev/null +++ b/test/birdy_chat/peer_test.exs @@ -0,0 +1,3 @@ +defmodule BirdyChat.PeerTest do + use BirdyChat.DataCase, async: true +end diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index ec2b102..ba12e7d 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -1,10 +1,12 @@ defmodule BirdyChatWeb.Api.MessagesTest do - use BirdyChatWeb.ConnCase + use BirdyChatWeb.ConnCase, async: true + + import Phoenix.ChannelTest setup %{conn: conn} do url = ~p"/api/messages" - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) on_exit(fn -> File.rm(path) end) conn = @@ -14,6 +16,45 @@ defmodule BirdyChatWeb.Api.MessagesTest do %{conn: conn, url: url} end + describe "POST /api/messages to other server" do + setup do + server_id = "test2" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) + + {:ok, _, socket} = + BirdyChatWeb.ServerSocket + |> socket("server_socket", %{server_id: server_id}) + |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) + + %{socket: socket} + end + + test "returns error when a peer is unknown", %{conn: conn, url: url} do + message = %{from: "test1-user", to: "fake-user", message: "123"} + + payload = Jason.encode!(message) + conn = post(conn, url, payload) + assert result = json_response(conn, :unprocessable_entity) + + expected_result = %{"errors" => %{"to" => ["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"} + + 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 + describe "POST /api/messages" do test "returns errors for invalid message", %{conn: conn, url: url} do payload = Jason.encode!(%{}) @@ -32,7 +73,7 @@ defmodule BirdyChatWeb.Api.MessagesTest do end test "returns message and 201 when successful", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "1-user", "message" => "123"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) @@ -40,30 +81,30 @@ defmodule BirdyChatWeb.Api.MessagesTest do end test "writes message to file", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "test-user", "message" => "123"} + 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 - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "2-user: 123\n" + assert contents == "test2-user: 123\n" end test "appends message to file", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "test-user", "message" => "123"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} payload = Jason.encode!(message) post(conn, url, payload) - message = %{"from" => "2-user", "to" => "test-user", "message" => "456"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "456"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert json_response(conn, :created) - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "2-user: 123\n2-user: 456\n" + assert contents == "test2-user: 123\ntest2-user: 456\n" end end end diff --git a/test/birdy_chat_web/channels/server_channel_test.exs b/test/birdy_chat_web/channels/server_channel_test.exs index 5a4fe8d..e57613d 100644 --- a/test/birdy_chat_web/channels/server_channel_test.exs +++ b/test/birdy_chat_web/channels/server_channel_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChatWeb.ServerChannelTest do - use BirdyChatWeb.ChannelCase + use BirdyChatWeb.ChannelCase, async: true setup do server_id = "test2" diff --git a/test/birdy_chat_web/controllers/page_controller_test.exs b/test/birdy_chat_web/controllers/page_controller_test.exs index dd8766c..5b5e8c5 100644 --- a/test/birdy_chat_web/controllers/page_controller_test.exs +++ b/test/birdy_chat_web/controllers/page_controller_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChatWeb.PageControllerTest do - use BirdyChatWeb.ConnCase + use BirdyChatWeb.ConnCase, async: true test "GET /", %{conn: conn} do conn = get(conn, ~p"/") From 45f55083fc42d2f463a8a81e3605b08507d1383c Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:05:55 +0200 Subject: [PATCH 02/10] 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) From f1bd4a0fdd4b04f2c34678399e03f163d3cbe6cf Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:20:38 +0200 Subject: [PATCH 03/10] Add remote request capability --- lib/birdy_chat/dispatcher.ex | 9 ++- lib/birdy_chat_web/channels/server_channel.ex | 42 ---------- lib/birdy_chat_web/channels/server_socket.ex | 44 ---------- lib/birdy_chat_web/endpoint.ex | 4 - test/birdy_chat_web/api/messages_test.exs | 36 +++++---- .../channels/server_channel_test.exs | 81 ------------------- 6 files changed, 28 insertions(+), 188 deletions(-) delete mode 100644 lib/birdy_chat_web/channels/server_channel.ex delete mode 100644 lib/birdy_chat_web/channels/server_socket.ex delete mode 100644 test/birdy_chat_web/channels/server_channel_test.exs diff --git a/lib/birdy_chat/dispatcher.ex b/lib/birdy_chat/dispatcher.ex index 31e134e..b47c823 100644 --- a/lib/birdy_chat/dispatcher.ex +++ b/lib/birdy_chat/dispatcher.ex @@ -7,19 +7,24 @@ defmodule BirdyChat.Dispatcher do end def send_to_remote(%{server: server, to: to} = message) do - {name, base_url} = + {_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 = + {_request, 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() + + # Handle more when you encounter errors + case result do + %Req.Response{status: 201} -> :ok + end end def req_opts do diff --git a/lib/birdy_chat_web/channels/server_channel.ex b/lib/birdy_chat_web/channels/server_channel.ex deleted file mode 100644 index 009aa1b..0000000 --- a/lib/birdy_chat_web/channels/server_channel.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule BirdyChatWeb.ServerChannel do - use BirdyChatWeb, :channel - - @impl true - def join("server:" <> server_id, payload, socket) do - if authorised?(payload, server_id) do - {:ok, socket} - else - {:error, %{reason: "unauthorised"}} - end - end - - @impl true - def handle_in("new_message", {:binary, message}, socket) do - result = :erlang.binary_to_term(message, [:safe]) - - # TODO: Add validation that this is the right server. - case BirdyChat.Message.validate(result) do - {:ok, changeset} -> - case BirdyChat.MessageWriter.write(changeset.changes) do - :ok -> - {:reply, {:ok, {:binary, message}}, socket} - end - end - end - - # This is how we send messages - def broadcast!(peer, message) do - channel = "server:#{peer}" - encoded_message = :erlang.term_to_binary(message) - BirdyChatWeb.Endpoint.broadcast!(channel, "new_message", {:binary, encoded_message}) - end - - # Simple token-based authentication. Servers should use the same Phoenix secret key so they will - # have the basis for generating tokens. - defp authorised?(%{"token" => token}, server_id) do - case Phoenix.Token.verify(BirdyChatWeb.Endpoint, "serverAuth", token, max_age: 86400) do - {:ok, id} -> id == server_id - {:error, :invalid} -> false - end - end -end diff --git a/lib/birdy_chat_web/channels/server_socket.ex b/lib/birdy_chat_web/channels/server_socket.ex deleted file mode 100644 index 8843d1b..0000000 --- a/lib/birdy_chat_web/channels/server_socket.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule BirdyChatWeb.ServerSocket do - use Phoenix.Socket - - # A Socket handler - # - # It's possible to control the websocket connection and - # assign values that can be accessed by your channel topics. - - ## Channels - - channel "server:*", BirdyChatWeb.ServerChannel - - # Socket params are passed from the client and can - # be used to verify and authenticate a user. After - # verification, you can put default assigns into - # the socket that will be set for all channels, ie - # - # {:ok, assign(socket, :user_id, verified_user_id)} - # - # To deny connection, return `:error` or `{:error, term}`. To control the - # response the client receives in that case, [define an error handler in the - # websocket - # configuration](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-websocket-configuration). - # - # See `Phoenix.Token` documentation for examples in - # performing token verification on connect. - @impl true - def connect(_params, socket, _connect_info) do - {:ok, socket} - end - - # Socket IDs are topics that allow you to identify all sockets for a given user: - # - # def id(socket), do: "user_socket:#{socket.assigns.user_id}" - # - # Would allow you to broadcast a "disconnect" event and terminate - # all active sockets and channels for a given user: - # - # Elixir.BirdyChatWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) - # - # Returning `nil` makes this socket anonymous. - @impl true - def id(socket), do: "user_socket:#{socket.assigns.server_id}" -end diff --git a/lib/birdy_chat_web/endpoint.ex b/lib/birdy_chat_web/endpoint.ex index 972f7ee..72a9ccf 100644 --- a/lib/birdy_chat_web/endpoint.ex +++ b/lib/birdy_chat_web/endpoint.ex @@ -15,10 +15,6 @@ defmodule BirdyChatWeb.Endpoint do websocket: [connect_info: [session: @session_options]], longpoll: [connect_info: [session: @session_options]] - socket "/socket", BirdyChatWeb.ServerSocket, - websocket: true, - longpoll: false - # Serve at "/" the static files from "priv/static" directory. # # When code reloading is disabled (e.g., in production), diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index cdd1328..4ecdd2c 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -1,8 +1,6 @@ defmodule BirdyChatWeb.Api.MessagesTest do use BirdyChatWeb.ConnCase, async: true - import Phoenix.ChannelTest - setup %{conn: conn} do url = ~p"/api/messages" @@ -17,18 +15,6 @@ defmodule BirdyChatWeb.Api.MessagesTest do end describe "POST /api/messages to other server" do - setup do - server_id = "test2" - token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) - - {:ok, _, socket} = - BirdyChatWeb.ServerSocket - |> socket("server_socket", %{server_id: server_id}) - |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) - - %{socket: socket} - end - test "returns error when a peer is unknown", %{conn: conn, url: url} do message = %{from: "test1-user", to: "fake-user", message: "123"} @@ -41,7 +27,7 @@ defmodule BirdyChatWeb.Api.MessagesTest do assert result == expected_result end - test "broadcasts message to websocket", %{conn: conn, url: url} do + test "send message to the other server via HTTP", %{conn: conn, url: url} do message = %{from: "test1-user", to: "test2-user", message: "123"} string_message = %{ @@ -52,6 +38,26 @@ defmodule BirdyChatWeb.Api.MessagesTest do "server" => "test2" } + Req.Test.expect(BirdyChat.Dispatcher, fn conn -> + expected_body_params = %{ + "from" => "test1-user", + "message" => "123", + "routing" => "remote", + "server" => "test2", + "to" => "test2-user" + } + + {"authorization", token} = + Enum.find(conn.req_headers, fn {key, _v} -> key == "authorization" end) + + {:ok, "test2-user"} = + Phoenix.Token.verify(BirdyChatWeb.Endpoint, "serverAuth", token, max_age: 1200) + + assert conn.body_params == expected_body_params + resp = Jason.encode!(expected_body_params) + Plug.Conn.send_resp(conn, :created, resp) + end) + payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) diff --git a/test/birdy_chat_web/channels/server_channel_test.exs b/test/birdy_chat_web/channels/server_channel_test.exs deleted file mode 100644 index e57613d..0000000 --- a/test/birdy_chat_web/channels/server_channel_test.exs +++ /dev/null @@ -1,81 +0,0 @@ -defmodule BirdyChatWeb.ServerChannelTest do - use BirdyChatWeb.ChannelCase, async: true - - setup do - server_id = "test2" - token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) - - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) - on_exit(fn -> File.rm(path) end) - - {:ok, _, socket} = - BirdyChatWeb.ServerSocket - |> socket("server_socket", %{server_id: server_id}) - |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) - - %{socket: socket} - end - - describe "authorization" do - test "requires a signed token to join a channel" do - server_id = "test2" - token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "fakeAuth", server_id) - - result = - BirdyChatWeb.ServerSocket - |> socket("server_socket", %{server_id: server_id}) - |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) - - assert result == {:error, %{reason: "unauthorised"}} - end - - test "requires server id in token and in channel to match" do - server_id = "test2" - token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) - - result = - BirdyChatWeb.ServerSocket - |> socket("server_socket", %{server_id: server_id}) - |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:fake", %{token: token}) - - assert result == {:error, %{reason: "unauthorised"}} - end - end - - describe "new_message" do - test "receives message from other server and saves it locally", %{socket: socket} do - message = %{from: "test2-user", to: "test-user", message: "123"} - bin = :erlang.term_to_binary(message) - ref = push(socket, "new_message", {:binary, bin}) - assert_reply ref, :ok, {:binary, ^bin} - - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) - contents = File.read!(path) - assert contents == "test2-user: 123\n" - end - - test "blows up when sent payload is not safe to decode", %{socket: socket} do - # This unsafe binary is taken from erlang docs directly: - bin = <<131, 119, 8, "tjenixen">> - Process.flag(:trap_exit, true) - push(socket, "new_message", {:binary, bin}) - - assert_receive {:EXIT, _pid, - {:badarg, - [ - {:erlang, :binary_to_term, - [<<131, 119, 8, 116, 106, 101, 110, 105, 120, 101, 110>>, [:safe]], - [error_info: %{module: :erl_erts_errors}]}, - {BirdyChatWeb.ServerChannel, :handle_in, 3, - [file: ~c"lib/birdy_chat_web/channels/server_channel.ex", line: 15]}, - {Phoenix.Channel.Server, :handle_info, 2, - [file: ~c"lib/phoenix/channel/server.ex", line: 332]}, - {:gen_server, :try_handle_info, 3, - [file: ~c"gen_server.erl", line: 2434]}, - {:gen_server, :handle_msg, 3, [file: ~c"gen_server.erl", line: 2420]}, - {:proc_lib, :init_p_do_apply, 3, [file: ~c"proc_lib.erl", line: 333]} - ]}}, - 100 - end - end -end From 47b0d73cc58c8081b2649fc2354c42423f5213bb Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:25:51 +0200 Subject: [PATCH 04/10] Remove distracting extra code --- config/config.exs | 9 - config/dev.exs | 3 - config/prod.exs | 6 - config/runtime.exs | 18 -- config/test.exs | 6 - lib/birdy_chat/mailer.ex | 3 - .../controllers/page_controller.ex | 7 - lib/birdy_chat_web/controllers/page_html.ex | 10 - .../controllers/page_html/home.html.heex | 202 ------------------ lib/birdy_chat_web/router.ex | 18 +- mix.exs | 4 - mix.lock | 5 - .../controllers/page_controller_test.exs | 8 - 13 files changed, 1 insertion(+), 298 deletions(-) delete mode 100644 lib/birdy_chat/mailer.ex delete mode 100644 lib/birdy_chat_web/controllers/page_controller.ex delete mode 100644 lib/birdy_chat_web/controllers/page_html.ex delete mode 100644 lib/birdy_chat_web/controllers/page_html/home.html.heex delete mode 100644 test/birdy_chat_web/controllers/page_controller_test.exs diff --git a/config/config.exs b/config/config.exs index 51096d6..5dbfd9b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,15 +21,6 @@ config :birdy_chat, BirdyChatWeb.Endpoint, pubsub_server: BirdyChat.PubSub, live_view: [signing_salt: "6cr1V8uA"] -# Configure the mailer -# -# By default it uses the "Local" adapter which stores the emails -# locally. You can see the emails in your browser, at "/dev/mailbox". -# -# For production it's recommended to configure a different adapter -# at the `config/runtime.exs`. -config :birdy_chat, BirdyChat.Mailer, adapter: Swoosh.Adapters.Local - # Configure esbuild (the version is required) config :esbuild, version: "0.25.4", diff --git a/config/dev.exs b/config/dev.exs index 5138034..dec572c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -77,6 +77,3 @@ config :phoenix_live_view, debug_attributes: true, # Enable helpful, but potentially expensive runtime checks enable_expensive_runtime_checks: true - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs index 4e08023..ecdb679 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -18,12 +18,6 @@ config :birdy_chat, BirdyChatWeb.Endpoint, hosts: ["localhost", "127.0.0.1"] ] -# Configure Swoosh API Client -config :swoosh, api_client: Swoosh.ApiClient.Req - -# Disable Swoosh Local Memory Storage -config :swoosh, local: false - # Do not print debug messages in production config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs index 26eec65..b6444aa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -82,22 +82,4 @@ if config_env() == :prod do # force_ssl: [hsts: true] # # Check `Plug.SSL` for all available options in `force_ssl`. - - # ## Configuring the mailer - # - # In production you need to configure the mailer to use a different adapter. - # Here is an example configuration for Mailgun: - # - # config :birdy_chat, BirdyChat.Mailer, - # adapter: Swoosh.Adapters.Mailgun, - # api_key: System.get_env("MAILGUN_API_KEY"), - # domain: System.get_env("MAILGUN_DOMAIN") - # - # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney, - # and Finch out-of-the-box. This configuration is typically done at - # compile-time in your config/prod.exs: - # - # config :swoosh, :api_client, Swoosh.ApiClient.Req - # - # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end diff --git a/config/test.exs b/config/test.exs index be9faa1..4a31ecc 100644 --- a/config/test.exs +++ b/config/test.exs @@ -7,12 +7,6 @@ config :birdy_chat, BirdyChatWeb.Endpoint, secret_key_base: "DsOg8g/AU3wogZIm99JWnoDyijeinMJFFfkFdwkSkFcjvHywCXjCxl//NY1cvm7Y", server: false -# In test we don't send emails -config :birdy_chat, BirdyChat.Mailer, adapter: Swoosh.Adapters.Test - -# Disable swoosh api client as it is only required for production adapters -config :swoosh, :api_client, false - # Print only warnings and errors during test config :logger, level: :warning diff --git a/lib/birdy_chat/mailer.ex b/lib/birdy_chat/mailer.ex deleted file mode 100644 index be833ef..0000000 --- a/lib/birdy_chat/mailer.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule BirdyChat.Mailer do - use Swoosh.Mailer, otp_app: :birdy_chat -end diff --git a/lib/birdy_chat_web/controllers/page_controller.ex b/lib/birdy_chat_web/controllers/page_controller.ex deleted file mode 100644 index 6350031..0000000 --- a/lib/birdy_chat_web/controllers/page_controller.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule BirdyChatWeb.PageController do - use BirdyChatWeb, :controller - - def home(conn, _params) do - render(conn, :home) - end -end diff --git a/lib/birdy_chat_web/controllers/page_html.ex b/lib/birdy_chat_web/controllers/page_html.ex deleted file mode 100644 index fe2290c..0000000 --- a/lib/birdy_chat_web/controllers/page_html.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule BirdyChatWeb.PageHTML do - @moduledoc """ - This module contains pages rendered by PageController. - - See the `page_html` directory for all templates available. - """ - use BirdyChatWeb, :html - - embed_templates "page_html/*" -end diff --git a/lib/birdy_chat_web/controllers/page_html/home.html.heex b/lib/birdy_chat_web/controllers/page_html/home.html.heex deleted file mode 100644 index b107fd0..0000000 --- a/lib/birdy_chat_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,202 +0,0 @@ - - -
-
- -
-

- Phoenix Framework - - v{Application.spec(:phoenix, :vsn)} - -

- -
- -

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/lib/birdy_chat_web/router.ex b/lib/birdy_chat_web/router.ex index 90954c5..58b2362 100644 --- a/lib/birdy_chat_web/router.ex +++ b/lib/birdy_chat_web/router.ex @@ -1,25 +1,10 @@ defmodule BirdyChatWeb.Router do use BirdyChatWeb, :router - pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :fetch_live_flash - plug :put_root_layout, html: {BirdyChatWeb.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers - end - pipeline :api do plug :accepts, ["json"] end - scope "/", BirdyChatWeb do - pipe_through :browser - - get "/", PageController, :home - end - scope "/api", BirdyChatWeb.Api do pipe_through [:api] @@ -32,7 +17,7 @@ defmodule BirdyChatWeb.Router do # pipe_through :api # end - # Enable LiveDashboard and Swoosh mailbox preview in development + # Enable LiveDashboard in development if Application.compile_env(:birdy_chat, :dev_routes) do # If you want to use the LiveDashboard in production, you should put # it behind authentication and allow only admins to access it. @@ -45,7 +30,6 @@ defmodule BirdyChatWeb.Router do pipe_through :browser live_dashboard "/dashboard", metrics: BirdyChatWeb.Telemetry - forward "/mailbox", Plug.Swoosh.MailboxPreview end end end diff --git a/mix.exs b/mix.exs index b72f908..9d6d261 100644 --- a/mix.exs +++ b/mix.exs @@ -56,7 +56,6 @@ defmodule BirdyChat.MixProject do app: false, compile: false, depth: 1}, - {:swoosh, "~> 1.16"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, @@ -66,9 +65,6 @@ defmodule BirdyChat.MixProject do {:bandit, "~> 1.5"}, {:credo, "~> 1.0", only: [:dev, :test]}, - # Phoenix sockets client - {:slipstream, "~> 1.0"}, - # Telemetry {:opentelemetry, "~> 1.0"}, {:opentelemetry_exporter, "~> 1.0"} diff --git a/mix.lock b/mix.lock index 8f01ae6..f6329e1 100644 --- a/mix.lock +++ b/mix.lock @@ -21,12 +21,10 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, @@ -43,16 +41,13 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "slipstream": {:hex, :slipstream, "1.2.2", "6b07124ac5f62a50327aa38c84edd0284920ac8aba548e04738827838f233ed0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint_web_socket, "~> 0.2 or ~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.1 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ccb873ddb21aadb37c5c7745014febe6da0aa2cef0c4e73e7d08ce11d18aacd0"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "swoosh": {:hex, :swoosh, "1.22.0", "0d65a95f89aedb5011af13295742294e309b4b4aaca556858d81e3b372b58abc", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c01ced23d8786d1ee1a03e4c16574290b2ccd6267beb8c81d081c4a34574ef6e"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } diff --git a/test/birdy_chat_web/controllers/page_controller_test.exs b/test/birdy_chat_web/controllers/page_controller_test.exs deleted file mode 100644 index 5b5e8c5..0000000 --- a/test/birdy_chat_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule BirdyChatWeb.PageControllerTest do - use BirdyChatWeb.ConnCase, async: true - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" - end -end From 50126aa21c51a5f6a603a18ca8b6b1c3583feee2 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:27:11 +0200 Subject: [PATCH 05/10] Remove more extra code --- lib/birdy_chat/application.ex | 2 -- lib/birdy_chat/peer_client.ex | 9 --------- lib/birdy_chat/peer_supervisor.ex | 17 ----------------- 3 files changed, 28 deletions(-) delete mode 100644 lib/birdy_chat/peer_client.ex delete mode 100644 lib/birdy_chat/peer_supervisor.ex diff --git a/lib/birdy_chat/application.ex b/lib/birdy_chat/application.ex index dab31ec..11c9fcb 100644 --- a/lib/birdy_chat/application.ex +++ b/lib/birdy_chat/application.ex @@ -10,8 +10,6 @@ defmodule BirdyChat.Application do children = [ BirdyChatWeb.Telemetry, BirdyChat.Identity, - BirdyChat.PeerSupervisor, - {Registry, keys: :unique, name: BirdyChat.PeerRegistry}, {DNSCluster, query: Application.get_env(:birdy_chat, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: BirdyChat.PubSub}, # Start a worker by calling: BirdyChat.Worker.start_link(arg) diff --git a/lib/birdy_chat/peer_client.ex b/lib/birdy_chat/peer_client.ex deleted file mode 100644 index 49ed51e..0000000 --- a/lib/birdy_chat/peer_client.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule BirdyChat.PeerClient do - use Slipstream, restart: :temporary - - def start_link(args) do - name = args[:peer_name] - name = {:via, BirdyChat.PeerRegistry, {:peers, name}} - Slipstream.start_link(__MODULE__, args, name: name) - end -end diff --git a/lib/birdy_chat/peer_supervisor.ex b/lib/birdy_chat/peer_supervisor.ex deleted file mode 100644 index 0439df8..0000000 --- a/lib/birdy_chat/peer_supervisor.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule BirdyChat.PeerSupervisor do - use DynamicSupervisor - - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - def start_child(peer_name, peer_address) do - spec = {BirdyChat.PeerClient, peer_name: peer_name, peer_address: peer_address} - DynamicSupervisor.start_child(__MODULE__, spec) - end - - @impl true - def init(init_arg) do - DynamicSupervisor.init(strategy: :one_for_one, extra_arguments: [init_arg]) - end -end From eb6631dd8b775586d3754ecae6d7273f742735f0 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 10:38:47 +0200 Subject: [PATCH 06/10] Add release --- lib/birdy_chat_web/router.ex | 9 +++++++++ rel/overlays/bin/server | 5 +++++ rel/overlays/bin/server.bat | 2 ++ 3 files changed, 16 insertions(+) create mode 100755 rel/overlays/bin/server create mode 100755 rel/overlays/bin/server.bat diff --git a/lib/birdy_chat_web/router.ex b/lib/birdy_chat_web/router.ex index 58b2362..e4b210a 100644 --- a/lib/birdy_chat_web/router.ex +++ b/lib/birdy_chat_web/router.ex @@ -1,6 +1,15 @@ defmodule BirdyChatWeb.Router do use BirdyChatWeb, :router + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {BirdyChatWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + pipeline :api do plug :accepts, ["json"] end diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..8a9148e --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./birdy_chat start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat new file mode 100755 index 0000000..f91dc6f --- /dev/null +++ b/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\birdy_chat" start From f0cf03141ba58b047d8610a49bb05bce500a04e1 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 11:35:32 +0200 Subject: [PATCH 07/10] Cleanup work --- .gitignore | 1 + config/runtime.exs | 2 +- lib/birdy_chat/dispatcher.ex | 26 ++++++++++++++++--- lib/birdy_chat/identity.ex | 6 +++++ lib/birdy_chat/message_writer.ex | 4 ++- lib/birdy_chat_web/api/messages/controller.ex | 4 +-- lib/birdy_chat_web/api/messages/json.ex | 4 +++ mix.exs | 6 ++++- mix.lock | 2 ++ priv/messages/README.md | 1 + test/birdy_chat_web/api/messages_test.exs | 12 ++++++++- 11 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 priv/messages/README.md diff --git a/.gitignore b/.gitignore index baf5cbc..588507e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ birdy_chat-*.tar # Ignore messages folder /priv/messages/ +!priv/messages/README.md # In case you use Node.js/npm, you want to ignore these. npm-debug.log diff --git a/config/runtime.exs b/config/runtime.exs index b6444aa..4c7f23e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -21,7 +21,7 @@ if System.get_env("PHX_SERVER") do end config :birdy_chat, BirdyChatWeb.Endpoint, - http: [port: String.to_integer(System.get_env("PORT", "4000"))] + http: [port: String.to_integer(System.get_env("BIRDY_CHAT_PORT", "4000"))] if config_env() == :prod do # The secret key base is used to sign/encrypt cookies and other secrets. diff --git a/lib/birdy_chat/dispatcher.ex b/lib/birdy_chat/dispatcher.ex index b47c823..e0a3b7d 100644 --- a/lib/birdy_chat/dispatcher.ex +++ b/lib/birdy_chat/dispatcher.ex @@ -1,4 +1,18 @@ defmodule BirdyChat.Dispatcher do + @moduledoc """ + Main dispatcher of messages - decides to either write them to local file system or send them via + HTTP to peers. + + It originally started as a websocket connection between servers, but then I decided to rip + it out and replace with simple HTTP request-response for the following reasons: + + 1. HTTP guarantees immediate feedback (request succeeds or not), making the addition of caching + or retries easy. + 2. HTTP requests have well-know semantic so I can i.e use HTTP statuses for error signals + instead of inventing my own error language. + """ + + @spec dispatch(Ecto.Changeset.t()) :: :ok | {:error, String.t()} def dispatch(%Ecto.Changeset{changes: changes} = changeset) do case changes do %{routing: :local} -> BirdyChat.MessageWriter.write(changeset.changes) @@ -6,13 +20,13 @@ defmodule BirdyChat.Dispatcher do end end - def send_to_remote(%{server: server, to: to} = message) do - {_name, base_url} = + defp send_to_remote(%{server: server, from: from} = 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) + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", from) {_request, result} = Req.new(url: api_url, retry: false, method: :post) @@ -24,6 +38,12 @@ defmodule BirdyChat.Dispatcher do # Handle more when you encounter errors case result do %Req.Response{status: 201} -> :ok + # This should never happen under normal circumstances so I am commenting this out but maybe + # needs a second look. + # %Req.Response{status: 403} -> {:error, "Unauthorised"} + + # Peer is down. + %Req.TransportError{reason: :econnrefused} -> {:error, "peer #{name} is unreachable"} end end diff --git a/lib/birdy_chat/identity.ex b/lib/birdy_chat/identity.ex index ae46c1d..cbb502f 100644 --- a/lib/birdy_chat/identity.ex +++ b/lib/birdy_chat/identity.ex @@ -55,6 +55,12 @@ defmodule BirdyChat.Identity do peers: %{"test2" => "http://localhost:4001"}, mode: :test } + + {identity, peers} -> + peers = parse_peers(peers) + identity = parse_identity(identity) + + %__MODULE__{identity: identity, peers: peers, mode: :connected} end end end diff --git a/lib/birdy_chat/message_writer.ex b/lib/birdy_chat/message_writer.ex index b6f53aa..c9d565a 100644 --- a/lib/birdy_chat/message_writer.ex +++ b/lib/birdy_chat/message_writer.ex @@ -1,5 +1,7 @@ defmodule BirdyChat.MessageWriter do - @moduledoc false + @moduledoc """ + Simple file writer that stores messages in priv folder of Elixir application/release. + """ @spec write(%{to: String.t(), from: String.t(), message: String.t()}) :: :ok def write(message) do diff --git a/lib/birdy_chat_web/api/messages/controller.ex b/lib/birdy_chat_web/api/messages/controller.ex index 66161bd..ea86b9b 100644 --- a/lib/birdy_chat_web/api/messages/controller.ex +++ b/lib/birdy_chat_web/api/messages/controller.ex @@ -10,10 +10,10 @@ defmodule BirdyChatWeb.Api.Messages.Controller do |> put_status(:created) |> render(:create, message: changeset.changes) - :error -> + {:error, error} -> conn |> put_status(:unprocessable_entity) - |> render(:error, changeset: changeset) + |> render(:error, message: error) end {:error, changeset} -> diff --git a/lib/birdy_chat_web/api/messages/json.ex b/lib/birdy_chat_web/api/messages/json.ex index a238764..74613fa 100644 --- a/lib/birdy_chat_web/api/messages/json.ex +++ b/lib/birdy_chat_web/api/messages/json.ex @@ -3,6 +3,10 @@ defmodule BirdyChatWeb.Api.Messages.JSON 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} diff --git a/mix.exs b/mix.exs index 9d6d261..fbb8c69 100644 --- a/mix.exs +++ b/mix.exs @@ -63,7 +63,10 @@ defmodule BirdyChat.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, - {:credo, "~> 1.0", only: [:dev, :test]}, + + # Static analysis tools + {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, # Telemetry {:opentelemetry, "~> 1.0"}, @@ -90,6 +93,7 @@ defmodule BirdyChat.MixProject do precommit: [ "compile --warnings-as-errors", "credo --strict", + "dialyzer", "deps.unlock --unused", "format", "test" diff --git a/mix.lock b/mix.lock index f6329e1..7b2c04f 100644 --- a/mix.lock +++ b/mix.lock @@ -7,9 +7,11 @@ "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, diff --git a/priv/messages/README.md b/priv/messages/README.md new file mode 100644 index 0000000..d677767 --- /dev/null +++ b/priv/messages/README.md @@ -0,0 +1 @@ +This folder is used for storing messages local to this server. The contents are otherwise ignored. diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index 4ecdd2c..e1c7cd1 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -15,6 +15,16 @@ defmodule BirdyChatWeb.Api.MessagesTest do end describe "POST /api/messages to other server" do + test "returns error when the peer is down", %{conn: conn, url: url} do + message = %{from: "test1-user", to: "test2-user", message: "123"} + Req.Test.expect(BirdyChat.Dispatcher, &Req.Test.transport_error(&1, :econnrefused)) + + payload = Jason.encode!(message) + conn = post(conn, url, payload) + assert result = json_response(conn, :unprocessable_entity) + assert result == %{"errors" => %{"general" => "peer test2 is unreachable"}} + end + test "returns error when a peer is unknown", %{conn: conn, url: url} do message = %{from: "test1-user", to: "fake-user", message: "123"} @@ -50,7 +60,7 @@ defmodule BirdyChatWeb.Api.MessagesTest do {"authorization", token} = Enum.find(conn.req_headers, fn {key, _v} -> key == "authorization" end) - {:ok, "test2-user"} = + {:ok, "test1-user"} = Phoenix.Token.verify(BirdyChatWeb.Endpoint, "serverAuth", token, max_age: 1200) assert conn.body_params == expected_body_params From 22a7fd9c6db65a9e22f8544933c3f22743471861 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 12:46:28 +0200 Subject: [PATCH 08/10] Docs and cleanup --- lib/birdy_chat/identity.ex | 11 ++++ lib/birdy_chat/message.ex | 54 ++++++++++++++++++- lib/birdy_chat_web.ex | 2 +- lib/birdy_chat_web/api/messages/controller.ex | 4 ++ lib/birdy_chat_web/api/messages/json.ex | 2 + .../api/server/internal/controller.ex | 41 +++++++++----- .../api/server/internal/json.ex | 2 + .../components/core_components.ex | 5 +- rel/overlays/bin/server | 5 -- rel/overlays/bin/server_1 | 12 +++++ rel/overlays/bin/server_2 | 12 +++++ test/birdy_chat_web/api/messages_test.exs | 49 +++++++++++------ test/support/channel_case.ex | 34 ------------ 13 files changed, 159 insertions(+), 74 deletions(-) delete mode 100755 rel/overlays/bin/server create mode 100755 rel/overlays/bin/server_1 create mode 100755 rel/overlays/bin/server_2 delete mode 100644 test/support/channel_case.ex 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"""
diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server deleted file mode 100755 index 8a9148e..0000000 --- a/rel/overlays/bin/server +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -cd -P -- "$(dirname -- "$0")" -PHX_SERVER=true exec ./birdy_chat start diff --git a/rel/overlays/bin/server_1 b/rel/overlays/bin/server_1 new file mode 100755 index 0000000..4fbccde --- /dev/null +++ b/rel/overlays/bin/server_1 @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" + +export SECRET_KEY_BASE=Yhmq6FzYQt4g5AFHfSdMBKKf4oRo4KRo703FK6b7RwmH5pXlyQNompUOF7/EEC5t +export BIRDY_CHAT_PORT=4001 +export BIRDY_CHAT_IDENTITY=1 +export BIRDY_CHAT_PEERS=2::http://localhost:4002 +export PHX_SERVER=true +export RELEASE_NAME=server_1 +exec ./birdy_chat start diff --git a/rel/overlays/bin/server_2 b/rel/overlays/bin/server_2 new file mode 100755 index 0000000..a565979 --- /dev/null +++ b/rel/overlays/bin/server_2 @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" + +export SECRET_KEY_BASE=Yhmq6FzYQt4g5AFHfSdMBKKf4oRo4KRo703FK6b7RwmH5pXlyQNompUOF7/EEC5t +export BIRDY_CHAT_PORT=4002 +export BIRDY_CHAT_IDENTITY=2 +export BIRDY_CHAT_PEERS=1::http://localhost:4001 +export PHX_SERVER=true +export RELEASE_NAME=server_2 +exec ./birdy_chat start diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index e1c7cd1..e945288 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -4,14 +4,17 @@ defmodule BirdyChatWeb.Api.MessagesTest do setup %{conn: conn} do url = ~p"/api/messages" - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) + 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) conn = conn |> put_req_header("content-type", "application/json") - %{conn: conn, url: url} + %{conn: conn, url: url, username: username, path: path} end describe "POST /api/messages to other server" do @@ -92,16 +95,30 @@ defmodule BirdyChatWeb.Api.MessagesTest do assert result == expected_result end - test "returns message and 201 when successful", %{conn: conn, url: url} do - message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} + test "returns error when you post a message to other server", %{conn: conn, url: url} do + message = %{"from" => "nothomeserver-user", "to" => "test1-user", "message" => "123"} + + payload = Jason.encode!(message) + conn = post(conn, url, payload) + assert result = json_response(conn, :unprocessable_entity) + + expected_result = %{ + "errors" => %{"from" => ["you can only communicate with your home server"]} + } + + assert result == expected_result + end + + test "returns message and 201 when successful", %{conn: conn, url: url, username: username} do + message = %{"from" => "test1-user", "to" => username, "message" => "123"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) expected_result = %{ - "from" => "test2-user", - "to" => "test1-user", + "from" => "test1-user", + "to" => username, "message" => "123", "routing" => "local", "server" => "test1" @@ -110,15 +127,15 @@ defmodule BirdyChatWeb.Api.MessagesTest do assert result == expected_result end - test "writes message to file", %{conn: conn, url: url} do - message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} + test "writes message to file", %{conn: conn, url: url, username: username, path: path} do + message = %{"from" => "test1-user", "to" => username, "message" => "123"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) expected_result = %{ - "from" => "test2-user", - "to" => "test1-user", + "from" => "test1-user", + "to" => username, "message" => "123", "routing" => "local", "server" => "test1" @@ -126,24 +143,22 @@ defmodule BirdyChatWeb.Api.MessagesTest do assert result == expected_result - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "test2-user: 123\n" + assert contents == "test1-user: 123\n" end - test "appends message to file", %{conn: conn, url: url} do - message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} + test "appends message to file", %{conn: conn, url: url, username: username, path: path} do + message = %{"from" => "test1-user", "to" => username, "message" => "123"} payload = Jason.encode!(message) post(conn, url, payload) - message = %{"from" => "test2-user", "to" => "test1-user", "message" => "456"} + message = %{"from" => "test1-user", "to" => username, "message" => "456"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert json_response(conn, :created) - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "test2-user: 123\ntest2-user: 456\n" + assert contents == "test1-user: 123\ntest1-user: 456\n" end end end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex deleted file mode 100644 index 0cd840c..0000000 --- a/test/support/channel_case.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule BirdyChatWeb.ChannelCase do - @moduledoc """ - This module defines the test case to be used by - channel tests. - - Such tests rely on `Phoenix.ChannelTest` and also - import other functionality to make it easier - to build common data structures and query the data layer. - - Finally, if the test case interacts with the database, - we enable the SQL sandbox, so changes done to the database - are reverted at the end of every test. If you are using - PostgreSQL, you can even run database tests asynchronously - by setting `use BirdyChatWeb.ChannelCase, async: true`, although - this option is not recommended for other databases. - """ - - use ExUnit.CaseTemplate - - using do - quote do - # Import conveniences for testing with channels - import Phoenix.ChannelTest - import BirdyChatWeb.ChannelCase - - # The default endpoint for testing - @endpoint BirdyChatWeb.Endpoint - end - end - - setup _tags do - :ok - end -end From a597daa920239c2729bb52850ddd73a0989cf01b Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 12:54:33 +0200 Subject: [PATCH 09/10] Precommit value is clean --- .gitignore | 4 ++++ mix.exs | 10 +++------- mix.lock | 11 ----------- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 588507e..a58a312 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ birdy_chat-*.tar # Ignore digested assets cache. /priv/static/cache_manifest.json +/priv/static/favicon-*.ico +/priv/static/images/logo-*.svg +/priv/static/robots-*.txt + # Ignore messages folder /priv/messages/ !priv/messages/README.md diff --git a/mix.exs b/mix.exs index fbb8c69..ab10555 100644 --- a/mix.exs +++ b/mix.exs @@ -21,7 +21,7 @@ defmodule BirdyChat.MixProject do def application do [ mod: {BirdyChat.Application, []}, - extra_applications: [:logger, :runtime_tools, :opentelemetry] + extra_applications: [:logger, :runtime_tools] ] end @@ -66,11 +66,7 @@ defmodule BirdyChat.MixProject do # Static analysis tools {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, - - # Telemetry - {:opentelemetry, "~> 1.0"}, - {:opentelemetry_exporter, "~> 1.0"} + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false} ] end @@ -90,10 +86,10 @@ defmodule BirdyChat.MixProject do "esbuild birdy_chat --minify", "phx.digest" ], + build_release: ["setup", "assets.deploy", "release"], precommit: [ "compile --warnings-as-errors", "credo --strict", - "dialyzer", "deps.unlock --unused", "format", "test" diff --git a/mix.lock b/mix.lock index 7b2c04f..e5ebd8e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,8 @@ %{ - "acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"}, "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, - "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, @@ -18,10 +15,7 @@ "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, - "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, - "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, - "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, @@ -29,9 +23,6 @@ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, - "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, - "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -43,13 +34,11 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } From 64e1deca92b62fdfa2b03214020bb9101f299354 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sun, 1 Mar 2026 13:22:29 +0200 Subject: [PATCH 10/10] Add documentation --- README.md | 66 +++++++++++++++---- .../api/server/internal/controller.ex | 2 +- rel/overlays/bin/server.bat | 2 - rel/overlays/bin/server_1 | 2 +- rel/overlays/bin/server_2 | 2 +- rel/overlays/bin/server_3 | 12 ++++ 6 files changed, 69 insertions(+), 17 deletions(-) delete mode 100755 rel/overlays/bin/server.bat create mode 100755 rel/overlays/bin/server_3 diff --git a/README.md b/README.md index 5626ecc..8cfed24 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,60 @@ -# BirdyChat +# BirdyChat Tech Challenge -To start your Phoenix server: +This repository implements BirdyChat tech challenge. -* Run `mix setup` to install and setup dependencies -* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` +# Start here -Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. +Firstly, check out this repository locally. +Then install required versions of Elixir and Erlang from .tool-versions. asdf-vm will pick them up automatically. +Then you run the test suite with `mix test`. +Then, a scripted demo release is prepared in the prod environment: -Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). +``` +MIX_ENV=prod mix build_release +``` -## Learn more +Then you can run 3 servers connected to one another: -* Official website: https://www.phoenixframework.org/ -* Guides: https://hexdocs.pm/phoenix/overview.html -* Docs: https://hexdocs.pm/phoenix -* Forum: https://elixirforum.com/c/phoenix-forum -* Source: https://github.com/phoenixframework/phoenix +`_build/prod/rel/birdy_chat/bin/server_1` - runs at localhost:4001 +`_build/prod/rel/birdy_chat/bin/server_2` - runs at localhost:4002 +`_build/prod/rel/birdy_chat/bin/server_3` - runs at localhost:4003 + +Out of the box they communicate with one another. Send a json request to one of them and observe the results. Feel free to modify the examples below. + +### A local request: + +``` +curl --request POST \ + --url http://localhost:4001/api/messages \ + --header 'content-type: application/json' \ + --data '{"message":"123","to":"1-user","from":"1-user"}' +``` + +### A remote request: + +``` +curl --request POST \ + --url http://localhost:4001/api/messages \ + --header 'content-type: application/json' \ + --data '{"message":"123","to":"2-user","from":"1-user"}' +``` + +### A request to unknown server: + +``` +curl --request POST \ + --url http://localhost:4001/api/messages \ + --header 'content-type: application/json' \ + --data '{"message":"123","to":"4-user","from":"1-user"}' +``` + +Files are saved to `priv/messages`. + + +## Key rundown of technical details + +First and foremost, I tried to keep it as simple as possible - stick to know conventions, leverage existing libraries or frameworks, hence the usage of both Phoenix and Ecto - conventions they provide are understandable to pretty much any Elixir developer. + +All important modules are documented and there is a test suite that exacutes all known code paths. + +Servers authenticate with one another using Phoenix tokens. Servers communicate via HTTP using JSON. There were other options but I decided to use this one for reasons enumerated in documentation for BirdyChatWeb.Api.Server.Internal.Controller. diff --git a/lib/birdy_chat_web/api/server/internal/controller.ex b/lib/birdy_chat_web/api/server/internal/controller.ex index cdb9766..c368d5b 100644 --- a/lib/birdy_chat_web/api/server/internal/controller.ex +++ b/lib/birdy_chat_web/api/server/internal/controller.ex @@ -14,7 +14,7 @@ defmodule BirdyChatWeb.Api.Server.Internal.Controller do 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. + reconsidered. HTTPS would be a non-negotiable requirement for any user-facing deployment. """ use BirdyChatWeb, :controller diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat deleted file mode 100755 index f91dc6f..0000000 --- a/rel/overlays/bin/server.bat +++ /dev/null @@ -1,2 +0,0 @@ -set PHX_SERVER=true -call "%~dp0\birdy_chat" start diff --git a/rel/overlays/bin/server_1 b/rel/overlays/bin/server_1 index 4fbccde..a2ec8ca 100755 --- a/rel/overlays/bin/server_1 +++ b/rel/overlays/bin/server_1 @@ -6,7 +6,7 @@ cd -P -- "$(dirname -- "$0")" export SECRET_KEY_BASE=Yhmq6FzYQt4g5AFHfSdMBKKf4oRo4KRo703FK6b7RwmH5pXlyQNompUOF7/EEC5t export BIRDY_CHAT_PORT=4001 export BIRDY_CHAT_IDENTITY=1 -export BIRDY_CHAT_PEERS=2::http://localhost:4002 +export BIRDY_CHAT_PEERS="2::http://localhost:4002;3::http://localhost:4003" export PHX_SERVER=true export RELEASE_NAME=server_1 exec ./birdy_chat start diff --git a/rel/overlays/bin/server_2 b/rel/overlays/bin/server_2 index a565979..8572ff4 100755 --- a/rel/overlays/bin/server_2 +++ b/rel/overlays/bin/server_2 @@ -6,7 +6,7 @@ cd -P -- "$(dirname -- "$0")" export SECRET_KEY_BASE=Yhmq6FzYQt4g5AFHfSdMBKKf4oRo4KRo703FK6b7RwmH5pXlyQNompUOF7/EEC5t export BIRDY_CHAT_PORT=4002 export BIRDY_CHAT_IDENTITY=2 -export BIRDY_CHAT_PEERS=1::http://localhost:4001 +export BIRDY_CHAT_PEERS="1::http://localhost:4001g;3::http://localhost:4003" export PHX_SERVER=true export RELEASE_NAME=server_2 exec ./birdy_chat start diff --git a/rel/overlays/bin/server_3 b/rel/overlays/bin/server_3 new file mode 100755 index 0000000..21cb303 --- /dev/null +++ b/rel/overlays/bin/server_3 @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" + +export SECRET_KEY_BASE=Yhmq6FzYQt4g5AFHfSdMBKKf4oRo4KRo703FK6b7RwmH5pXlyQNompUOF7/EEC5t +export BIRDY_CHAT_PORT=4003 +export BIRDY_CHAT_IDENTITY=3 +export BIRDY_CHAT_PEERS="1::http://localhost:4001;2::http://localhost:4002" +export PHX_SERVER=true +export RELEASE_NAME=server_3 +exec ./birdy_chat start