Add channel to be joined

This commit is contained in:
Maciej 2026-02-28 13:11:58 +02:00
parent 1a8d9cd840
commit 3afaf346c7
Signed by: maciej
GPG key ID: 28243AF437E32F99
6 changed files with 221 additions and 0 deletions

23
assets/js/user_socket.js Normal file
View file

@ -0,0 +1,23 @@
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "assets/js/app.js".
// Bring in Phoenix channels client library:
import {Socket} from "phoenix"
// And connect to the path in "lib/birdy_chat_web/endpoint.ex". We pass the
// token for authentication.
//
// Read the [`Using Token Authentication`](https://hexdocs.pm/phoenix/channels.html#using-token-authentication)
// section to see how the token should be used.
let socket = new Socket("/socket", {authToken: window.userToken})
socket.connect()
// Now that you are connected, you can join channels with a topic.
// Let's assume you have a channel with a topic named `room` and the
// subtopic is its id - in this case 42:
let channel = socket.channel("room:42", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
export default socket

View file

@ -0,0 +1,35 @@
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
# 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

View file

@ -0,0 +1,44 @@
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

View file

@ -15,6 +15,10 @@ defmodule BirdyChatWeb.Endpoint do
websocket: [connect_info: [session: @session_options]], websocket: [connect_info: [session: @session_options]],
longpoll: [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. # Serve at "/" the static files from "priv/static" directory.
# #
# When code reloading is disabled (e.g., in production), # When code reloading is disabled (e.g., in production),

View file

@ -0,0 +1,81 @@
defmodule BirdyChatWeb.ServerChannelTest do
use BirdyChatWeb.ChannelCase
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

View file

@ -0,0 +1,34 @@
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