Docs and cleanup
This commit is contained in:
parent
f0cf03141b
commit
22a7fd9c6d
13 changed files with 159 additions and 74 deletions
|
|
@ -1,4 +1,15 @@
|
||||||
defmodule BirdyChat.Identity do
|
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
|
use Agent
|
||||||
|
|
||||||
defstruct [:identity, :peers, :mode]
|
defstruct [:identity, :peers, :mode]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
defmodule BirdyChat.Message do
|
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
|
use Ecto.Schema
|
||||||
|
|
||||||
embedded_schema do
|
embedded_schema do
|
||||||
|
|
@ -9,12 +14,20 @@ defmodule BirdyChat.Message do
|
||||||
field :server, :string
|
field :server, :string
|
||||||
end
|
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 =
|
changeset =
|
||||||
%__MODULE__{}
|
%__MODULE__{}
|
||||||
|> Ecto.Changeset.cast(params, [:from, :to, :message])
|
|> Ecto.Changeset.cast(params, [:from, :to, :message])
|
||||||
|> Ecto.Changeset.validate_required([:from, :to, :message])
|
|> Ecto.Changeset.validate_required([:from, :to, :message])
|
||||||
|> put_routing()
|
|> put_routing()
|
||||||
|
|> validate_is_local()
|
||||||
|
|
||||||
if changeset.valid? do
|
if changeset.valid? do
|
||||||
{:ok, changeset}
|
{:ok, changeset}
|
||||||
|
|
@ -23,6 +36,45 @@ defmodule BirdyChat.Message do
|
||||||
end
|
end
|
||||||
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
|
defp put_routing(%Ecto.Changeset{changes: %{to: to}} = changeset) do
|
||||||
identity = BirdyChat.Identity.identity()
|
identity = BirdyChat.Identity.identity()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,8 @@ defmodule BirdyChatWeb do
|
||||||
import BirdyChatWeb.CoreComponents
|
import BirdyChatWeb.CoreComponents
|
||||||
|
|
||||||
# Common modules used in templates
|
# Common modules used in templates
|
||||||
alias Phoenix.LiveView.JS
|
|
||||||
alias BirdyChatWeb.Layouts
|
alias BirdyChatWeb.Layouts
|
||||||
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
# Routes generation with the ~p sigil
|
# Routes generation with the ~p sigil
|
||||||
unquote(verified_routes())
|
unquote(verified_routes())
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule BirdyChatWeb.Api.Messages.Controller do
|
defmodule BirdyChatWeb.Api.Messages.Controller do
|
||||||
|
@moduledoc """
|
||||||
|
The endpoint to be used by users from the "home server".
|
||||||
|
"""
|
||||||
|
|
||||||
use BirdyChatWeb, :controller
|
use BirdyChatWeb, :controller
|
||||||
|
|
||||||
def create(conn, params) do
|
def create(conn, params) do
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
defmodule BirdyChatWeb.Api.Messages.JSON do
|
defmodule BirdyChatWeb.Api.Messages.JSON do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
def render("create.json", %{message: message}) do
|
def render("create.json", %{message: message}) do
|
||||||
message
|
message
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,36 @@
|
||||||
defmodule BirdyChatWeb.Api.Server.Internal.Controller do
|
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
|
use BirdyChatWeb, :controller
|
||||||
|
|
||||||
def create(conn, params) do
|
def create(conn, params) do
|
||||||
if authorised?(conn.req_headers, params) do
|
with true <- authorised?(conn.req_headers, params),
|
||||||
case BirdyChat.Message.validate(params) do
|
{:ok, changeset} <- BirdyChat.Message.validate_for_inter_server_use(params),
|
||||||
{:ok, changeset} ->
|
:ok <- BirdyChat.MessageWriter.write(changeset.changes) do
|
||||||
case BirdyChat.MessageWriter.write(changeset.changes) do
|
|
||||||
:ok ->
|
|
||||||
conn
|
|
||||||
|> put_status(:created)
|
|
||||||
|> render(:create, message: changeset.changes)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
conn
|
conn
|
||||||
|> put_status(:forbidden)
|
|> put_status(:created)
|
||||||
|> render(:error, message: "Unauthorised")
|
|> render(:create, message: changeset.changes)
|
||||||
|
else
|
||||||
|
_any ->
|
||||||
|
conn
|
||||||
|
|> put_status(:forbidden)
|
||||||
|
|> render(:error, message: "Unauthorised")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
defmodule BirdyChatWeb.Api.Server.Internal.JSON do
|
defmodule BirdyChatWeb.Api.Server.Internal.JSON do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
def render("create.json", %{message: message}) do
|
def render("create.json", %{message: message}) do
|
||||||
message
|
message
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ defmodule BirdyChatWeb.CoreComponents do
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
use Gettext, backend: BirdyChatWeb.Gettext
|
use Gettext, backend: BirdyChatWeb.Gettext
|
||||||
|
|
||||||
|
alias Phoenix.HTML.Form
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -200,9 +201,7 @@ defmodule BirdyChatWeb.CoreComponents do
|
||||||
|
|
||||||
def input(%{type: "checkbox"} = assigns) do
|
def input(%{type: "checkbox"} = assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
assign_new(assigns, :checked, fn ->
|
assign_new(assigns, :checked, fn -> Form.normalize_value("checkbox", assigns[:value]) end)
|
||||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
|
||||||
end)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="fieldset mb-2">
|
<div class="fieldset mb-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
cd -P -- "$(dirname -- "$0")"
|
|
||||||
PHX_SERVER=true exec ./birdy_chat start
|
|
||||||
12
rel/overlays/bin/server_1
Executable file
12
rel/overlays/bin/server_1
Executable file
|
|
@ -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
|
||||||
12
rel/overlays/bin/server_2
Executable file
12
rel/overlays/bin/server_2
Executable file
|
|
@ -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
|
||||||
|
|
@ -4,14 +4,17 @@ defmodule BirdyChatWeb.Api.MessagesTest do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
url = ~p"/api/messages"
|
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)
|
on_exit(fn -> File.rm(path) end)
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|
||||||
%{conn: conn, url: url}
|
%{conn: conn, url: url, username: username, path: path}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "POST /api/messages to other server" do
|
describe "POST /api/messages to other server" do
|
||||||
|
|
@ -92,16 +95,30 @@ defmodule BirdyChatWeb.Api.MessagesTest do
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns message and 201 when successful", %{conn: conn, url: url} do
|
test "returns error when you post a message to other server", %{conn: conn, url: url} do
|
||||||
message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"}
|
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)
|
payload = Jason.encode!(message)
|
||||||
conn = post(conn, url, payload)
|
conn = post(conn, url, payload)
|
||||||
assert result = json_response(conn, :created)
|
assert result = json_response(conn, :created)
|
||||||
|
|
||||||
expected_result = %{
|
expected_result = %{
|
||||||
"from" => "test2-user",
|
"from" => "test1-user",
|
||||||
"to" => "test1-user",
|
"to" => username,
|
||||||
"message" => "123",
|
"message" => "123",
|
||||||
"routing" => "local",
|
"routing" => "local",
|
||||||
"server" => "test1"
|
"server" => "test1"
|
||||||
|
|
@ -110,15 +127,15 @@ defmodule BirdyChatWeb.Api.MessagesTest do
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
end
|
end
|
||||||
|
|
||||||
test "writes message to file", %{conn: conn, url: url} do
|
test "writes message to file", %{conn: conn, url: url, username: username, path: path} do
|
||||||
message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"}
|
message = %{"from" => "test1-user", "to" => username, "message" => "123"}
|
||||||
payload = Jason.encode!(message)
|
payload = Jason.encode!(message)
|
||||||
conn = post(conn, url, payload)
|
conn = post(conn, url, payload)
|
||||||
assert result = json_response(conn, :created)
|
assert result = json_response(conn, :created)
|
||||||
|
|
||||||
expected_result = %{
|
expected_result = %{
|
||||||
"from" => "test2-user",
|
"from" => "test1-user",
|
||||||
"to" => "test1-user",
|
"to" => username,
|
||||||
"message" => "123",
|
"message" => "123",
|
||||||
"routing" => "local",
|
"routing" => "local",
|
||||||
"server" => "test1"
|
"server" => "test1"
|
||||||
|
|
@ -126,24 +143,22 @@ defmodule BirdyChatWeb.Api.MessagesTest do
|
||||||
|
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
|
|
||||||
path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"])
|
|
||||||
contents = File.read!(path)
|
contents = File.read!(path)
|
||||||
assert contents == "test2-user: 123\n"
|
assert contents == "test1-user: 123\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "appends message to file", %{conn: conn, url: url} do
|
test "appends message to file", %{conn: conn, url: url, username: username, path: path} do
|
||||||
message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"}
|
message = %{"from" => "test1-user", "to" => username, "message" => "123"}
|
||||||
payload = Jason.encode!(message)
|
payload = Jason.encode!(message)
|
||||||
post(conn, url, payload)
|
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)
|
payload = Jason.encode!(message)
|
||||||
conn = post(conn, url, payload)
|
conn = post(conn, url, payload)
|
||||||
assert json_response(conn, :created)
|
assert json_response(conn, :created)
|
||||||
|
|
||||||
path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"])
|
|
||||||
contents = File.read!(path)
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue