prop/lib/microwaveprop_web/controllers/scores_controller.ex
Graham McIntire 14b90ee9f3
fix(security,perf): address 9 audit findings (access control, DoS, crashes)
- Beacon detail endpoints (LiveView + REST API) now hide unapproved
  beacons from anonymous and unauthorized viewers; only the submitter
  and admins can see pending records before approval. Adds
  Beacons.get_visible_beacon/2 with scope-aware checks.
- API contact pagination now honors per_page end-to-end.
  Radio.list_contacts/1 accepts :per_page and clamps to 200.
- API rate limiter: ETS table is now owned by a long-lived Sweeper
  GenServer (won't die with a request task); Sweeper periodically
  prunes expired-window rows to bound memory; init_table/0 race is
  rescued.
- /scores/cells and /weather/cells: add per-IP rate limiting and a
  shared GridBounds clamp/413 guard so global / oversized viewports
  no longer drive unbounded binary responses.
- NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte]
  + Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time
  for the 12200x5400 n0q frame goes from quadratic to linear.
- LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch
  String.to_integer/String.to_float to Integer.parse/Float.parse,
  fall back to defaults instead of raising.
- PathLive and MapLive band-event handlers: replace
  String.to_integer(params["band"]) with parse_int / parse_band_param
  so a non-numeric band parameter no longer crashes the LiveView.
2026-05-11 18:53:21 -05:00

118 lines
4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.ScoresController do
@moduledoc """
HTTP endpoint serving propagation score grids in a compact binary
cell-pack format. Replaces the old `update_scores` LiveView push for
the /map overlay so the client can fetch directly (cacheable, doesn't
block the LiveView channel) and decode via DataView + typed-array
views without a JSON parse.
Rate-limited per IP and bounds-capped to the propagation grid extent
so a malicious caller cannot trigger global / oversized binary
responses.
"""
use MicrowavepropWeb, :controller
alias Microwaveprop.Propagation
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.GridBounds
plug RateLimiter, anon_limit: 120, auth_limit: 600
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
def cells(conn, %{"band" => band_param} = params) do
with {band_mhz, ""} <- Integer.parse(band_param),
{:ok, valid_time} <- parse_optional_time(params["time"]),
{:ok, bounds} <- parse_bounds(params),
{:ok, bounds} <- GridBounds.clamp(bounds) do
scores =
if valid_time do
Propagation.scores_at(band_mhz, valid_time, bounds)
else
Propagation.latest_scores(band_mhz, bounds)
end
conn
|> put_resp_header("content-type", "application/octet-stream")
|> put_resp_header("cache-control", "public, max-age=60")
|> send_resp(200, encode_binary(scores))
else
{:error, :viewport_too_large} -> payload_too_large(conn)
_ -> bad_request(conn)
end
end
def cells(conn, _params), do: bad_request(conn)
defp bad_request(conn) do
conn
|> put_status(400)
|> json(%{error: "invalid params"})
end
defp payload_too_large(conn) do
conn
|> put_status(413)
|> json(%{error: "viewport_too_large", detail: "Requested bounds exceed the maximum supported viewport area."})
end
defp parse_optional_time(nil), do: {:ok, nil}
defp parse_optional_time(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> {:ok, dt}
_ -> :error
end
end
defp parse_optional_time(_), do: :error
defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
with {:ok, south} <- parse_float(s),
{:ok, north} <- parse_float(n),
{:ok, west} <- parse_float(w),
{:ok, east} <- parse_float(e) do
{:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
end
end
defp parse_bounds(_), do: {:ok, nil}
defp parse_float(value) when is_binary(value) do
case Float.parse(value) do
{f, ""} -> {:ok, f}
_ -> :error
end
end
defp parse_float(_), do: :error
# Binary cell-pack format. Each entry is a 0-100 score at a (lat, lon).
# Layout chosen so the f32 lat/lon arrays land on 4-byte boundaries
# for typed-array decoding in JS — DataView reads tolerate
# misalignment but Float32Array views over an ArrayBuffer don't.
#
# "PSCR" (4 bytes) — magic
# u8 — version (1)
# u8 × 3 — reserved padding (zero)
# u32 LE — cell count
# f32 LE × cell_count — latitudes
# f32 LE × cell_count — longitudes
# u8 × cell_count — scores (0..100)
defp encode_binary(scores) do
cell_count = length(scores)
header = <<"PSCR", 1::8, 0::8, 0::8, 0::8, cell_count::little-32>>
lats = scores |> Enum.map(&<<&1.lat * 1.0::little-float-32>>) |> IO.iodata_to_binary()
lons = scores |> Enum.map(&<<&1.lon * 1.0::little-float-32>>) |> IO.iodata_to_binary()
score_bytes = scores |> Enum.map(&<<clamp_score(&1.score)::8>>) |> IO.iodata_to_binary()
<<header::binary, lats::binary, lons::binary, score_bytes::binary>>
end
defp clamp_score(s) when is_integer(s) and s < 0, do: 0
defp clamp_score(s) when is_integer(s) and s > 100, do: 100
defp clamp_score(s) when is_integer(s), do: s
defp clamp_score(s) when is_float(s), do: s |> round() |> clamp_score()
defp clamp_score(_), do: 0
end