fix: close review findings
This commit is contained in:
parent
a343febdd0
commit
bc82777d34
7 changed files with 176 additions and 11 deletions
|
|
@ -91,7 +91,7 @@ defmodule Aprsme.PartitionManager do
|
||||||
list_partitions()
|
list_partitions()
|
||||||
|> Enum.filter(fn name ->
|
|> Enum.filter(fn name ->
|
||||||
case partition_date(name) do
|
case partition_date(name) do
|
||||||
{:ok, date} -> Date.before?(date, cutoff_date)
|
{:ok, date} -> Date.compare(date, cutoff_date) != :gt
|
||||||
_ -> false
|
_ -> false
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,8 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
# Load historical packets for this callsign
|
# Load historical packets for this callsign
|
||||||
socket = load_callsign_history(socket, callsign, hours_back)
|
socket = load_callsign_history(socket, callsign, hours_back)
|
||||||
|
|
||||||
|
socket = ensure_callsign_subscription(socket)
|
||||||
|
|
||||||
# Store tracked callsign in socket
|
# Store tracked callsign in socket
|
||||||
socket = assign(socket, :tracked_callsign, callsign)
|
socket = assign(socket, :tracked_callsign, callsign)
|
||||||
|
|
||||||
|
|
@ -197,7 +199,11 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
if socket.assigns[:tracked_callsign] do
|
if socket.assigns[:tracked_callsign] do
|
||||||
callsign = socket.assigns.tracked_callsign
|
callsign = socket.assigns.tracked_callsign
|
||||||
socket = assign(socket, :tracked_callsign, nil)
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> remove_callsign_subscription()
|
||||||
|
|> assign(:tracked_callsign, nil)
|
||||||
|
|
||||||
Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from callsign: #{callsign}")
|
Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from callsign: #{callsign}")
|
||||||
|
|
||||||
|
|
@ -209,6 +215,19 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:streaming_packet, packet}, socket) do
|
def handle_info({:streaming_packet, packet}, socket) do
|
||||||
|
maybe_push_tracked_packet(socket, packet)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:postgres_packet, packet}, socket) do
|
||||||
|
maybe_push_tracked_packet(socket, packet)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_push_tracked_packet(socket, packet) do
|
||||||
# Check if packet matches tracked callsign (if any)
|
# Check if packet matches tracked callsign (if any)
|
||||||
should_send =
|
should_send =
|
||||||
if tracked_callsign = socket.assigns[:tracked_callsign] do
|
if tracked_callsign = socket.assigns[:tracked_callsign] do
|
||||||
|
|
@ -237,6 +256,10 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if callsign_subscription_active?(socket) do
|
||||||
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
||||||
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -275,7 +298,14 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
defp ensure_float(value) when is_integer(value), do: value * 1.0
|
defp ensure_float(value) when is_integer(value), do: value * 1.0
|
||||||
defp ensure_float(value) when is_float(value), do: value
|
defp ensure_float(value) when is_float(value), do: value
|
||||||
defp ensure_float(value) when is_binary(value), do: String.to_float(value)
|
|
||||||
|
defp ensure_float(value) when is_binary(value) do
|
||||||
|
case Float.parse(String.trim(value)) do
|
||||||
|
{float, ""} -> float
|
||||||
|
_ -> value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_float(value), do: value
|
defp ensure_float(value), do: value
|
||||||
|
|
||||||
defp ensure_integer(value, _default) when is_integer(value), do: value
|
defp ensure_integer(value, _default) when is_integer(value), do: value
|
||||||
|
|
@ -521,4 +551,26 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
{:reply, {:ok, %{bounds: bounds, message: "Bounds updated"}}, socket}
|
{:reply, {:ok, %{bounds: bounds, message: "Bounds updated"}}, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp ensure_callsign_subscription(socket) do
|
||||||
|
if callsign_subscription_active?(socket) do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
||||||
|
assign(socket, :callsign_subscription_active, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp remove_callsign_subscription(socket) do
|
||||||
|
if callsign_subscription_active?(socket) do
|
||||||
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
||||||
|
assign(socket, :callsign_subscription_active, false)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp callsign_subscription_active?(socket) do
|
||||||
|
Map.get(socket.assigns, :callsign_subscription_active, false)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule AprsmeWeb.Plugs.ApiCSRF do
|
defmodule AprsmeWeb.Plugs.ApiCSRF do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
CSRF protection for API endpoints using X-Requested-With header or API tokens
|
CSRF protection for API endpoints using `X-Requested-With` or a valid CSRF token.
|
||||||
"""
|
"""
|
||||||
import Phoenix.Controller
|
import Phoenix.Controller
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
@ -22,16 +22,12 @@ defmodule AprsmeWeb.Plugs.ApiCSRF do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_csrf_protection(conn) do
|
defp check_csrf_protection(conn) do
|
||||||
case {get_req_header(conn, "x-requested-with"), get_req_header(conn, "authorization")} do
|
case get_req_header(conn, "x-requested-with") do
|
||||||
{["XMLHttpRequest"], _} ->
|
["XMLHttpRequest"] ->
|
||||||
# Valid AJAX request
|
# Valid AJAX request
|
||||||
conn
|
conn
|
||||||
|
|
||||||
{_, ["Bearer " <> _token]} ->
|
_ ->
|
||||||
# Has authorization header (API token) - would need validation in production
|
|
||||||
conn
|
|
||||||
|
|
||||||
{_, _} ->
|
|
||||||
# Check for CSRF token in header
|
# Check for CSRF token in header
|
||||||
case get_req_header(conn, "x-csrf-token") do
|
case get_req_header(conn, "x-csrf-token") do
|
||||||
[token] when byte_size(token) > 0 ->
|
[token] when byte_size(token) > 0 ->
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,20 @@ defmodule Aprsme.PartitionManagerTest do
|
||||||
[today_name]
|
[today_name]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "drops the partition at the retention cutoff boundary" do
|
||||||
|
cutoff_date = Date.add(Date.utc_today(), -7)
|
||||||
|
name = PartitionManager.partition_name(cutoff_date)
|
||||||
|
{from_dt, to_dt} = PartitionManager.partition_range(cutoff_date)
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS #{name} PARTITION OF packets FOR VALUES FROM ('#{DateTime.to_iso8601(from_dt)}') TO ('#{DateTime.to_iso8601(to_dt)}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, dropped} = PartitionManager.drop_old_partitions(7)
|
||||||
|
|
||||||
|
assert name in dropped
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_partitions/0" do
|
describe "list_partitions/0" do
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,19 @@ defmodule Aprsme.Workers.PacketCleanupWorkerTest do
|
||||||
|
|
||||||
assert today_name in PartitionManager.list_partitions()
|
assert today_name in PartitionManager.list_partitions()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "drops the partition exactly at the cleanup cutoff" do
|
||||||
|
cutoff_date = Date.add(Date.utc_today(), -7)
|
||||||
|
name = PartitionManager.partition_name(cutoff_date)
|
||||||
|
{from_dt, to_dt} = PartitionManager.partition_range(cutoff_date)
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS #{name} PARTITION OF packets FOR VALUES FROM ('#{DateTime.to_iso8601(from_dt)}') TO ('#{DateTime.to_iso8601(to_dt)}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert :ok = PacketCleanupWorker.perform(%{"cleanup_days" => 7})
|
||||||
|
refute name in PartitionManager.list_partitions()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1 without cleanup_days" do
|
describe "perform/1 without cleanup_days" do
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,18 @@ defmodule AprsmeWeb.MobileChannelTest do
|
||||||
assert is_float(bounds.east)
|
assert is_float(bounds.east)
|
||||||
assert is_float(bounds.west)
|
assert is_float(bounds.west)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "rejects invalid numeric strings without crashing", %{socket: socket} do
|
||||||
|
ref =
|
||||||
|
push(socket, "subscribe_bounds", %{
|
||||||
|
"north" => "abc",
|
||||||
|
"south" => "33.0",
|
||||||
|
"east" => "-96.0",
|
||||||
|
"west" => "-96.2"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert_reply ref, :error, %{message: "All bounds must be numeric"}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "update_bounds" do
|
describe "update_bounds" do
|
||||||
|
|
@ -191,6 +203,28 @@ defmodule AprsmeWeb.MobileChannelTest do
|
||||||
|
|
||||||
assert_reply ref, :error, %{message: "North must be greater than south"}
|
assert_reply ref, :error, %{message: "North must be greater than south"}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "rejects invalid numeric strings on update without crashing", %{socket: socket} do
|
||||||
|
ref =
|
||||||
|
push(socket, "subscribe_bounds", %{
|
||||||
|
"north" => 33.2,
|
||||||
|
"south" => 33.0,
|
||||||
|
"east" => -96.0,
|
||||||
|
"west" => -96.2
|
||||||
|
})
|
||||||
|
|
||||||
|
assert_reply ref, :ok, _
|
||||||
|
|
||||||
|
ref =
|
||||||
|
push(socket, "update_bounds", %{
|
||||||
|
"north" => "bad",
|
||||||
|
"south" => "32.9",
|
||||||
|
"east" => "-95.9",
|
||||||
|
"west" => "-96.3"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert_reply ref, :error, %{message: "All bounds must be numeric"}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unsubscribe" do
|
describe "unsubscribe" do
|
||||||
|
|
@ -409,6 +443,23 @@ defmodule AprsmeWeb.MobileChannelTest do
|
||||||
refute_push "packet", _
|
refute_push "packet", _
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "receives live postgres packets after subscribing by callsign only", %{socket: socket} do
|
||||||
|
ref = push(socket, "subscribe_callsign", %{"callsign" => "W5ISP-9"})
|
||||||
|
assert_reply ref, :ok, _
|
||||||
|
|
||||||
|
packet = %{
|
||||||
|
"sender" => "W5ISP-9",
|
||||||
|
"lat" => 33.1,
|
||||||
|
"lon" => -96.1,
|
||||||
|
"received_at" => DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(socket.channel_pid, {:postgres_packet, packet})
|
||||||
|
|
||||||
|
assert_push "packet", pushed_packet
|
||||||
|
assert pushed_packet.callsign == "W5ISP-9"
|
||||||
|
end
|
||||||
|
|
||||||
test "wildcard callsign matches multiple SSIDs", %{socket: socket} do
|
test "wildcard callsign matches multiple SSIDs", %{socket: socket} do
|
||||||
# Subscribe to wildcard
|
# Subscribe to wildcard
|
||||||
ref = push(socket, "subscribe_callsign", %{"callsign" => "W5ISP*"})
|
ref = push(socket, "subscribe_callsign", %{"callsign" => "W5ISP*"})
|
||||||
|
|
|
||||||
39
test/aprsme_web/plugs/api_csrf_test.exs
Normal file
39
test/aprsme_web/plugs/api_csrf_test.exs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
defmodule AprsmeWeb.Plugs.ApiCSRFTest do
|
||||||
|
use AprsmeWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias AprsmeWeb.Plugs.ApiCSRF
|
||||||
|
|
||||||
|
test "allows JSON requests with XMLHttpRequest header", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> init_test_session(%{})
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("x-requested-with", "XMLHttpRequest")
|
||||||
|
|> ApiCSRF.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects JSON requests with arbitrary bearer token", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> init_test_session(%{})
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer definitely-not-valid")
|
||||||
|
|> ApiCSRF.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert conn.status == 403
|
||||||
|
assert Jason.decode!(conn.resp_body)["error"] == "CSRF protection failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows non-JSON requests without CSRF headers", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> init_test_session(%{})
|
||||||
|
|> put_req_header("content-type", "text/plain")
|
||||||
|
|> ApiCSRF.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue