Files
warmbly/realtime/lib/realtime_web/channels/user_socket.ex
T
Matthew Meszaros ce870a15b3 ci: fix Elixir formatting / Elixir version / missing pnpm lockfile
- realtime/: `mix format` applied; long Logger calls reformatted across
  config/runtime/application/connections/user_channel/user_socket/
  endpoint. CI's "Check formatting" step now passes.
- CI Elixir bumped from 1.16 → 1.18 (with OTP 27) to match mix.exs's
  `~> 1.18` requirement. Phoenix 1.8.7 + plug 1.19 also expect this.
- web/: generate + commit pnpm-lock.yaml so actions/setup-node@v4's
  pnpm cache step + `pnpm install --frozen-lockfile` can resolve.
2026-05-23 16:17:27 +00:00

100 lines
2.8 KiB
Elixir

defmodule RealtimeWeb.UserSocket do
@moduledoc """
WebSocket handler with authentication and connection management.
Supports both JWT tokens and API keys (prefixed with `wmbly_`).
Implements Discord-style error codes for rejection reasons.
Rate limits are based on user's subscription tier.
"""
use Phoenix.Socket
require Logger
alias Realtime.Auth
alias Realtime.Connections
alias Realtime.RateLimiter
alias Realtime.Subscription
# Channels
channel("user:*", RealtimeWeb.UserChannel)
channel("campaign:*", RealtimeWeb.CampaignChannel)
channel("account:*", RealtimeWeb.AccountChannel)
channel("bulk:*", RealtimeWeb.BulkChannel)
channel("org:*", RealtimeWeb.OrgChannel)
@impl true
def connect(%{"token" => token}, socket, connect_info) do
ip = get_ip(connect_info)
with {:ok, user_id, auth_type} <- Auth.verify_token(token, ip: ip),
limits <- Subscription.get_limits(user_id),
:ok <- check_connection_limit(user_id, ip, limits),
:ok <- check_rate_limit(user_id, limits) do
Logger.debug("Socket connected for user: #{user_id} (auth: #{auth_type})")
socket =
socket
|> assign(:user_id, user_id)
|> assign(:ip_address, ip)
|> assign(:auth_type, auth_type)
|> assign(:rate_limits, limits)
{:ok, socket}
else
{:error, reason} ->
code = Auth.error_code(reason)
message = Auth.error_message(reason)
Logger.warning("Socket connection rejected (#{code}): #{message}")
:error
{:error, :rate_limited, retry_after_ms} ->
Logger.warning("Socket connection rate limited, retry after #{retry_after_ms}ms")
:error
end
end
def connect(_params, _socket, _connect_info) do
Logger.warning("Socket connection rejected: missing token")
:error
end
@impl true
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
# Private functions
defp check_connection_limit(user_id, ip, limits) do
max_connections = Map.get(limits, :max_connections, 10)
case Connections.track(user_id, ip: ip, max_connections: max_connections) do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
defp check_rate_limit(user_id, limits) do
limit = Map.get(limits, :limit_ws_join_pm, 30)
case RateLimiter.check(user_id, :ws_join, limit) do
{:ok, _remaining} -> :ok
{:error, :rate_limited, retry_after_ms} -> {:error, :rate_limited, retry_after_ms}
end
end
defp get_ip(%{peer_data: %{address: address}}) do
address |> :inet.ntoa() |> to_string()
end
defp get_ip(%{x_headers: headers}) do
headers
|> Enum.find(fn {key, _} -> key == "x-forwarded-for" end)
|> case do
{_, ip} -> ip |> String.split(",") |> List.first() |> String.trim()
nil -> "unknown"
end
end
defp get_ip(_), do: "unknown"
end