prop/lib/microwaveprop_web/controllers/weather_tile_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

158 lines
5.3 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.WeatherTileController do
use MicrowavepropWeb, :controller
alias Microwaveprop.Weather
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.GridBounds
plug RateLimiter, anon_limit: 120, auth_limit: 600
# Layer fields the binary cell-pack can carry. Used both as the
# whitelist for ?layers=... and as the default if the param is absent.
@cell_fields ~w(
temperature dewpoint_depression surface_rh surface_pressure_mb
surface_refractivity refractivity_gradient bl_height pwat
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
lapse_rate mid_lapse_rate inversion_strength inversion_base_m
ducting duct_base_m duct_strength
)a
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
def cells(conn, %{"time" => time_param} = params) do
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
{:ok, bounds} <- parse_bounds(params),
{:ok, bounds} <- GridBounds.clamp(bounds),
{:ok, layers} <- parse_layers(params["layers"]) do
rows = read_rows(valid_time, bounds, params["source"])
conn
|> put_resp_header("content-type", "application/octet-stream")
|> put_resp_header("cache-control", "public, max-age=60")
|> send_resp(200, encode_binary(rows, layers))
else
{:error, :viewport_too_large} -> payload_too_large(conn)
_ -> bad_request(conn)
end
end
def cells(conn, _params), do: bad_request(conn)
defp read_rows(valid_time, bounds, "hrdps"), do: Weather.weather_grid_hrdps_at(valid_time, bounds)
defp read_rows(valid_time, bounds, _other), do: Weather.weather_grid_at(valid_time, bounds)
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_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: :error
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
defp parse_layers(nil), do: {:ok, @cell_fields}
defp parse_layers(value) when is_binary(value) do
fields =
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.map(&safe_atom/1)
cond do
Enum.any?(fields, &is_nil/1) -> :error
Enum.empty?(fields) -> {:ok, @cell_fields}
true -> {:ok, fields}
end
end
defp parse_layers(_), do: :error
defp safe_atom(name) when is_binary(name) do
if name in Enum.map(@cell_fields, &Atom.to_string/1) do
String.to_existing_atom(name)
end
end
# Custom binary cell-pack format. Designed for cheap parsing in JS via
# DataView + Float32Array (no library, no JSON parse). The header is
# padded so all f32 arrays land on 4-byte boundaries — DataView reads
# tolerate misalignment but typed-array views over an ArrayBuffer
# don't.
#
# "WCEL" (4 bytes) — magic
# u8 — version (1)
# u32 LE — cell count
# u8 — layer count
# for each layer:
# u8 — name length
# u8 × name_length — name (UTF-8)
# u8 × N — zero padding to next 4-byte boundary
# f32 LE × cell_count — latitudes
# f32 LE × cell_count — longitudes
# for each layer:
# f32 LE × cell_count — values (NaN = null; booleans 0.0/1.0)
defp encode_binary(rows, layers) do
cell_count = length(rows)
layer_names = Enum.map(layers, &Atom.to_string/1)
header =
<<"WCEL", 1::8, cell_count::little-32, length(layer_names)::8>> <>
Enum.map_join(layer_names, fn n -> <<byte_size(n)::8>> <> n end)
pad_size = padding_to_align(byte_size(header), 4)
header_padded = header <> :binary.copy(<<0>>, pad_size)
lats = rows |> Enum.map(&encode_f32(&1.lat)) |> IO.iodata_to_binary()
lons = rows |> Enum.map(&encode_f32(&1.lon)) |> IO.iodata_to_binary()
values =
layers
|> Enum.map(fn field ->
Enum.map(rows, fn row -> encode_value_f32(Map.get(row, field)) end)
end)
|> IO.iodata_to_binary()
<<header_padded::binary, lats::binary, lons::binary, values::binary>>
end
defp padding_to_align(size, align) do
rem = rem(size, align)
if rem == 0, do: 0, else: align - rem
end
# f32 quiet NaN bit pattern in little-endian — used for null values so
# the JS side can check Number.isNaN on a Float32Array entry.
@f32_nan <<0::8, 0::8, 0xC0::8, 0x7F::8>>
defp encode_value_f32(nil), do: @f32_nan
defp encode_value_f32(true), do: <<1.0::little-float-32>>
defp encode_value_f32(false), do: <<0.0::little-float-32>>
defp encode_value_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_value_f32(_), do: @f32_nan
defp encode_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_f32(_), do: @f32_nan
end