prop/lib/microwaveprop_web/controllers/scores_controller.ex
Graham McIntire 7c37bf67fe
refactor(web): drop the unreachable viewport_too_large guard
Raising the area cap to 4600 put it above the largest area clamp/1 can
emit (45° lat × 100° lon = 4500 sq deg), so :viewport_too_large became
unreachable and the 413 handlers in both cell controllers became dead
code.

Remove the cap, the error branch, the max_viewport_area_sq_deg/0
accessor and both payload_too_large/1 handlers. The clamp is what
bounds request cost: post-clamp nothing can ask for more than the full
grid (~92k cells, ~1.4 MB), which is materialized once per valid_time
and served from cache, so a global viewport costs no more than a fully
zoomed-out map.

Replaces the cap test with one asserting the clamp never lets absurd
input escape the supported extent — the property the guard was
standing in for.
2026-08-05 17:51:55 -05:00

114 lines
3.8 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
_ -> 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 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, lons, score_bytes} =
Enum.reduce(scores, {[], [], []}, fn s, {lats_acc, lons_acc, scs_acc} ->
{[<<s.lat * 1.0::little-float-32>> | lats_acc], [<<s.lon * 1.0::little-float-32>> | lons_acc],
[<<clamp_score(s.score)::8>> | scs_acc]}
end)
<<header::binary, IO.iodata_to_binary(lats)::binary, IO.iodata_to_binary(lons)::binary,
IO.iodata_to_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