Docs and cleanup

This commit is contained in:
Maciej 2026-03-01 12:46:28 +02:00
parent f0cf03141b
commit 22a7fd9c6d
Signed by: maciej
GPG key ID: 28243AF437E32F99
13 changed files with 159 additions and 74 deletions

View file

@ -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]

View file

@ -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()

View file

@ -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())

View file

@ -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

View file

@ -1,4 +1,6 @@
defmodule BirdyChatWeb.Api.Messages.JSON do
@moduledoc false
def render("create.json", %{message: message}) do
message
end

View file

@ -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

View file

@ -1,4 +1,6 @@
defmodule BirdyChatWeb.Api.Server.Internal.JSON do
@moduledoc false
def render("create.json", %{message: message}) do
message
end

View file

@ -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"""
<div class="fieldset mb-2">